ankusa-sdk 0.3.0 → 0.6.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 +87 -1
- data/README.md +170 -25
- data/lib/ankusa/admin.rb +49 -9
- data/lib/ankusa/claim_check.rb +26 -11
- data/lib/ankusa/lifecycle.rb +112 -0
- data/lib/ankusa/message.rb +266 -0
- data/lib/ankusa/routes.rb +8 -5
- data/lib/ankusa/sdk/version.rb +1 -1
- data/lib/ankusa/sdk.rb +3 -0
- data/lib/ankusa/signature.rb +101 -0
- data/lib/ankusa/sources.rb +7 -2
- data/lib/ankusa/webhook.rb +131 -4
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3dbf23058e4a0799746070c44c18c750554e2298a2ae1dbe149d25bc9f9f4b22
|
|
4
|
+
data.tar.gz: 3aeaae91dbb2d4530678e9da87a365aafc5df73ab684047066aefae45b802ce6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 78397c0e96fc065f7a4d30d0bcf533b07d86dec71d9c185d55fdd415f0ee5b1373e83e9af43bcc70ddb89b5a71d48970bfd38954e5cc2afce0ddcf0e06a0fd90
|
|
7
|
+
data.tar.gz: 0d0cc364cd091f83daa7425bd1df37e7a41b1cadaefc00b00776a193dbef97a3d5ea527f15ba80a01a5f97bca77a37d7c6c5162bca9849f8cbd247ad1135a3b9
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,90 @@ this project follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.6.0] - 2026-10-28
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `Message#traceparent` and `Message#tracestate`: the hook's W3C trace context
|
|
14
|
+
from the message, verbatim (`nil` when none); a non-string value is
|
|
15
|
+
`invalid_field`.
|
|
16
|
+
|
|
17
|
+
- `verification` on parsed headers, decoded messages and received deliveries:
|
|
18
|
+
how Ankusa verified the hook at ingest (`{status, flagged}`, `null` from a
|
|
19
|
+
node older than the field).
|
|
20
|
+
- `Ankusa.parse_claim_ref` and `ClaimCheckClient#redeem` accept signed,
|
|
21
|
+
expiring v2 refs
|
|
22
|
+
(`urn:ankusa:claim:v2:<tenant>:<claim_id>:<exp_unix>:<signature>`): the
|
|
23
|
+
parsed `path` carries `?exp=&sig=` and `redeem` requests it as is. The
|
|
24
|
+
gateway's `400 bad_signature` and `410 ref_expired` are a
|
|
25
|
+
`ClaimRejectedError`. The SDK never checks the signature or the expiry.
|
|
26
|
+
- `Ankusa.receive_delivery(headers, body, secrets: [], allow_unsigned: false,
|
|
27
|
+
tolerance_seconds: 300, now: nil)`, the receiver-side decision on one
|
|
28
|
+
HTTP-sink delivery. The sink signs every delivery unless configured
|
|
29
|
+
`allow_unsigned: true`, so a receiver is closed by default: with `secrets`
|
|
30
|
+
the delivery must verify (as `verify_signature`); with none and no
|
|
31
|
+
`allow_unsigned: true` it raises `InvalidSignatureError` (`invalid_secret`).
|
|
32
|
+
The signature is judged before the hook id. Returns a `ReceivedDelivery`
|
|
33
|
+
(`headers`, `verified`).
|
|
34
|
+
- `scope` on `AdminRejectedError` and `RoutesRejectedError`: the scope a `403
|
|
35
|
+
forbidden` says the credential lacks (control planes require a credential
|
|
36
|
+
since Ankusa 0.6).
|
|
37
|
+
- `Admin#list_audit(**params)`: `GET /v1/audit`, the audit trail (newest first,
|
|
38
|
+
paged by `cursor`/`limit`, filtered by `since`, `until`, `actor`, `action`,
|
|
39
|
+
`tenant`).
|
|
40
|
+
- **Delivery metadata (CON-4).** `HookHeaders#received_at` (when the edge accepted the hook, unix milliseconds, from `x-ankusa-received-at`) and `HookHeaders#body_sha256` (the lowercase hex SHA-256 of the body sent, from `x-ankusa-body-sha256`): nil when the header is absent, empty or malformed, never an error.
|
|
41
|
+
|
|
42
|
+
- **Wire-format evolution path (CON-2).** `Ankusa.decode_message` accepts every
|
|
43
|
+
`v` from `Ankusa::MIN_SUPPORTED_VERSION` to `Ankusa::MAX_SUPPORTED_VERSION`
|
|
44
|
+
(1 and 2) into the one `Message`, with `v` as the wire said it: a `v: 2`
|
|
45
|
+
message is a `v: 1` message plus additive fields, so the keys it does not know
|
|
46
|
+
are ignored. `Message#instance` and `Message#region`: the name of the Ankusa
|
|
47
|
+
instance that published the message and the region it is configured with (nil
|
|
48
|
+
when null or absent). `Ankusa.decode_lifecycle` and `Ankusa::LifecycleEvent`
|
|
49
|
+
decode the CloudEvents lifecycle events of the instance's lifecycle sinks.
|
|
50
|
+
|
|
51
|
+
- `Message#trusted?`, `HookHeaders#trusted?` and `Verification#trusted?`: true
|
|
52
|
+
only when the node verified the hook at ingest (status `"ok"`) and did not
|
|
53
|
+
flag it. A flagged, `"failed"` or `"skipped"` hook and one with no
|
|
54
|
+
`verification` are untrusted.
|
|
55
|
+
|
|
56
|
+
## [0.5.0] - 2026-10-14
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- `Ankusa.verify_signature(headers, body, secrets, tolerance_seconds: 300, now: nil)`
|
|
61
|
+
verifies the Standard Webhooks signature an HTTP sink with a `secret` adds,
|
|
62
|
+
comparing with `OpenSSL.fixed_length_secure_compare`; failures raise
|
|
63
|
+
`Ankusa::InvalidSignatureError` (`code`, `field`, never retryable).
|
|
64
|
+
- `Ankusa::Message` and `Ankusa.decode_message(data)`, plus
|
|
65
|
+
`Ankusa::InvalidMessageError` (with `code`, `field` and `retryable?` false):
|
|
66
|
+
decode and validate the v1 queue message every sink delivers — the inline
|
|
67
|
+
(`body_base64`) and claim-check (`claim`) body forms, `sha256`,
|
|
68
|
+
`dedupe_key`, `replay_id`, and the forwarded `headers`, ignoring unknown
|
|
69
|
+
keys.
|
|
70
|
+
- `Message#idempotency_key` / `HookHeaders#idempotency_key`, the key to store
|
|
71
|
+
in a processed-ids table: the key Ankusa shipped (the message's
|
|
72
|
+
`idempotency_key` field, or the `x-ankusa-idempotency-key` header), and for a
|
|
73
|
+
message or delivery that predates it `tenant:source_id:dedupe_key` (tenant
|
|
74
|
+
`default` when there is none) when a `dedupe_key` is set, else `id`, with an
|
|
75
|
+
optional `#replay:<replay_id>` suffix (`include_replay: true`). The decoded
|
|
76
|
+
field stays in `to_h[:idempotency_key]`.
|
|
77
|
+
- `HookHeaders#dedupe_key` / `#replay_id` / `#idempotency_key`'s field, parsed
|
|
78
|
+
from `x-ankusa-dedupe-key` / `x-ankusa-replay-id` / `x-ankusa-idempotency-key`.
|
|
79
|
+
- `AdminClient#create_replay`, `#get_replay`, `#list_replays` and
|
|
80
|
+
`#update_replay` for the `POST/GET/PATCH /v1/replays` replay-job API (kinds
|
|
81
|
+
`dlq`, `archive`, `quarantine`).
|
|
82
|
+
|
|
83
|
+
### Changed
|
|
84
|
+
|
|
85
|
+
- `ClaimCheckClient#redeem` raises `ClaimCheckUnavailableError` (retryable)
|
|
86
|
+
for a gateway `408` or `429` instead of `ClaimRejectedError`.
|
|
87
|
+
|
|
88
|
+
### Removed
|
|
89
|
+
|
|
90
|
+
- `AdminClient#replay_dead_letters`, replaced by the replay-job API above.
|
|
91
|
+
`POST /v1/dlq/replay` no longer exists.
|
|
92
|
+
|
|
9
93
|
## [0.3.0] - 2026-10-01
|
|
10
94
|
|
|
11
95
|
### Added
|
|
@@ -37,5 +121,7 @@ this project follows [Semantic Versioning](https://semver.org/).
|
|
|
37
121
|
family, which carries `.status` and `.body` instead), so a consumer needs
|
|
38
122
|
exactly one bit to route a failure to dead-letter or retry.
|
|
39
123
|
|
|
40
|
-
[Unreleased]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.
|
|
124
|
+
[Unreleased]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.6.0...HEAD
|
|
125
|
+
[0.6.0]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.5.0...sdk-ruby-v0.6.0
|
|
126
|
+
[0.5.0]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.3.0...sdk-ruby-v0.5.0
|
|
41
127
|
[0.3.0]: https://github.com/jamescarr/ankusa/releases/tag/sdk-ruby-v0.3.0
|
data/README.md
CHANGED
|
@@ -5,8 +5,8 @@ deployments: one gem meant to bundle everything a non-Elixir consumer needs to
|
|
|
5
5
|
talk to an Ankusa deployment. Today that's the
|
|
6
6
|
[claim-check gateway](https://github.com/jamescarr/ankusa/blob/main/docs/claim-check.md)
|
|
7
7
|
client, the route-management and operator (admin) clients, the
|
|
8
|
-
source-management client,
|
|
9
|
-
(ingest) land here as they're built.
|
|
8
|
+
source-management client, a webhook-receiving header helper, and the queue
|
|
9
|
+
message decoder; more clients (ingest) land here as they're built.
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
@@ -26,14 +26,6 @@ then, in code:
|
|
|
26
26
|
require "ankusa/sdk"
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
Before the first RubyGems release, depend on it as a path gem the same way the
|
|
30
|
-
Elixir packages in this monorepo depend on `ankusa` core before their first Hex
|
|
31
|
-
release:
|
|
32
|
-
|
|
33
|
-
```ruby
|
|
34
|
-
gem "ankusa-sdk", path: "packages/sdk-ruby"
|
|
35
|
-
```
|
|
36
|
-
|
|
37
29
|
Everything the SDK exposes lives under `Ankusa::`.
|
|
38
30
|
|
|
39
31
|
## Claim-check client
|
|
@@ -81,9 +73,9 @@ consumer needs exactly one bit to decide dead-letter vs. retry:
|
|
|
81
73
|
| --- | --- | --- |
|
|
82
74
|
| `InvalidClaimRefError` | `false` | `ref` isn't a well-formed claim-check URN, or `sha256` isn't 64-char lowercase hex |
|
|
83
75
|
| `ClaimNotFoundError` | `false` | gateway `404`: expired by retention, or never written |
|
|
84
|
-
| `ClaimRejectedError` | `false` | gateway `4xx` other than `404` (`.status`, `.body`) |
|
|
76
|
+
| `ClaimRejectedError` | `false` | gateway `4xx` other than `404`, `408`, `429` (`.status`, `.body`) |
|
|
85
77
|
| `ClaimIntegrityError` | `false` | sha256 of the returned bytes doesn't match |
|
|
86
|
-
| `ClaimCheckUnavailableError` | `true` | gateway `5xx
|
|
78
|
+
| `ClaimCheckUnavailableError` | `true` | gateway `5xx`, `408`, `429`, or unreachable |
|
|
87
79
|
|
|
88
80
|
`health` hits `GET /health` for a liveness probe. There's no `close`: the
|
|
89
81
|
default transport opens one connection per request.
|
|
@@ -124,11 +116,149 @@ def call(env)
|
|
|
124
116
|
end
|
|
125
117
|
```
|
|
126
118
|
|
|
127
|
-
`HookHeaders#
|
|
119
|
+
`HookHeaders#idempotency_key` is what a receiver dedupes on, not the hook id
|
|
120
|
+
(`x-ankusa-id` stays required): delivery is at-least-once (see
|
|
128
121
|
"HTTP handoff" in
|
|
129
122
|
[`docs/integrations.md`](https://github.com/jamescarr/ankusa/blob/main/docs/integrations.md)),
|
|
130
|
-
so the same hook can arrive twice after a retry.
|
|
131
|
-
|
|
123
|
+
so the same hook can arrive twice after a retry. When the source extracted the
|
|
124
|
+
provider's own event key, `HookHeaders#dedupe_key` carries it (and `replay_id`
|
|
125
|
+
marks a replay); `HookHeaders#idempotency_key` returns the tenant-scoped key
|
|
126
|
+
Ankusa computed and shipped in `x-ankusa-idempotency-key` — see "Consuming
|
|
127
|
+
queue messages". Header lookup is always case-insensitive, regardless of
|
|
128
|
+
whether the mapping passed in already is.
|
|
129
|
+
|
|
130
|
+
### Verifying signed deliveries
|
|
131
|
+
|
|
132
|
+
An HTTP sink with a `secret` signs every delivery the
|
|
133
|
+
[Standard Webhooks](https://www.standardwebhooks.com/) way:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
# Inside a Rack app's `call(env)`, before anything parses the body:
|
|
137
|
+
begin
|
|
138
|
+
body = env["rack.input"].read # the raw request body, exactly as received
|
|
139
|
+
Ankusa.verify_signature(header_map(env), body, [ENV.fetch("ANKUSA_WHSEC")])
|
|
140
|
+
rescue Ankusa::InvalidSignatureError => e
|
|
141
|
+
return [401, {"content-type" => "application/json"},
|
|
142
|
+
[JSON.generate(error: "invalid_signature", code: e.code)]]
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Secrets are `whsec_` + base64, or any other string used as its own bytes;
|
|
147
|
+
pass several during a rotation. `tolerance_seconds:` (default 300) bounds the
|
|
148
|
+
`webhook-timestamp` window. Failures are `Ankusa::InvalidSignatureError` with
|
|
149
|
+
`code` and `field`, never retryable.
|
|
150
|
+
|
|
151
|
+
### Receiving a delivery (signed by default)
|
|
152
|
+
|
|
153
|
+
Ankusa's HTTP sink signs every delivery unless it is configured
|
|
154
|
+
`allow_unsigned: true`, so a receiver is closed by default.
|
|
155
|
+
`Ankusa.receive_delivery` is the one call that decides: with the sink's
|
|
156
|
+
`secrets:` it verifies as above; with none, and no `allow_unsigned: true`, it
|
|
157
|
+
refuses every delivery (`Ankusa::InvalidSignatureError`, code
|
|
158
|
+
`invalid_secret`) — otherwise any host that can reach your URL could inject
|
|
159
|
+
hooks. A configured secret is never weakened by `allow_unsigned:`. The
|
|
160
|
+
signature is judged before the hook id, so a delivery that fails it is a `401`
|
|
161
|
+
even when it also lacks `x-ankusa-id` (`Ankusa::MissingHookIdError`, `400`).
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
begin
|
|
165
|
+
received = Ankusa.receive_delivery(header_map(env), body, secrets: [ENV.fetch("ANKUSA_WHSEC")])
|
|
166
|
+
# received.headers is the HookHeaders; received.verified is false only for an
|
|
167
|
+
# unsigned delivery `allow_unsigned: true` let through.
|
|
168
|
+
rescue Ankusa::InvalidSignatureError => e
|
|
169
|
+
return [401, {"content-type" => "application/json"},
|
|
170
|
+
[JSON.generate(error: "invalid_signature", code: e.code)]]
|
|
171
|
+
rescue Ankusa::MissingHookIdError
|
|
172
|
+
return [400, {"content-type" => "application/json"},
|
|
173
|
+
[JSON.generate(error: "missing x-ankusa-id")]]
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`hook.verification` (also on `Ankusa.parse_headers` and `Ankusa.decode_message`
|
|
178
|
+
results) says how the *node* verified the hook at ingest, `nil` from a node
|
|
179
|
+
older than the field. `hook.verification&.flagged == true` means the node
|
|
180
|
+
accepted a failed check (`on_verify_failure: accept_flag`) — treat it as
|
|
181
|
+
unverified. `hook.trusted?` (on messages and parsed headers) is that decision
|
|
182
|
+
in one call: true only for status `"ok"` with `flagged` false, so a flagged,
|
|
183
|
+
`"failed"` or `"skipped"` hook, or one from a node older than the field, is
|
|
184
|
+
untrusted.
|
|
185
|
+
|
|
186
|
+
## Consuming queue messages
|
|
187
|
+
|
|
188
|
+
The message's `v` is the wire format's major version: `Ankusa.decode_message`
|
|
189
|
+
accepts every `v` from `Ankusa::MIN_SUPPORTED_VERSION` to
|
|
190
|
+
`Ankusa::MAX_SUPPORTED_VERSION` (1 and 2) into the one `Message`, with `v` as
|
|
191
|
+
the wire said it. A `v: 2` message is a `v: 1` message plus additive fields, so
|
|
192
|
+
a consumer reads it the same way; any other `v` is `unsupported_version`
|
|
193
|
+
(permanent: dead-letter it). `message.instance` and `message.region` are the
|
|
194
|
+
Ankusa instance that published the message and the region it is configured with
|
|
195
|
+
(nil for none). A message from the instance's lifecycle sinks carries a
|
|
196
|
+
CloudEvents event as its body: `Ankusa.decode_lifecycle(body)` returns an
|
|
197
|
+
`Ankusa::LifecycleEvent`.
|
|
198
|
+
|
|
199
|
+
Every message sink — RabbitMQ, Kafka, NATS, Redis — delivers one JSON message
|
|
200
|
+
per hook (the HTTP sink sends the original body plus `x-ankusa-*` headers
|
|
201
|
+
instead):
|
|
202
|
+
the identity fields, the body (inline `body_base64` or a claim-check `claim`),
|
|
203
|
+
`sha256`, and, when present, `dedupe_key`, `replay_id`, `idempotency_key` and
|
|
204
|
+
the forwarded provider `headers`. `Ankusa.decode_message` validates all of it
|
|
205
|
+
and `Message#idempotency_key` gives the value to store in a processed-ids
|
|
206
|
+
table:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
require "ankusa/sdk"
|
|
210
|
+
|
|
211
|
+
CLAIM_CHECK = Ankusa::ClaimCheckClient.new(ENV.fetch("CLAIM_CHECK_URL", "http://localhost:4001"))
|
|
212
|
+
DB = PG.connect(ENV.fetch("DATABASE_URL"))
|
|
213
|
+
DB.exec("CREATE TABLE IF NOT EXISTS processed (key text primary key)")
|
|
214
|
+
|
|
215
|
+
def consume(raw)
|
|
216
|
+
begin
|
|
217
|
+
message = Ankusa.decode_message(raw)
|
|
218
|
+
rescue Ankusa::InvalidMessageError => e
|
|
219
|
+
# Poison message: dead-letter, never requeue. `e.retryable?` is false;
|
|
220
|
+
# `e.code` is e.g. "integrity"; `e.field` names the offending key.
|
|
221
|
+
raise
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
key = message.idempotency_key # the key Ankusa shipped (tenant:source_id:dedupe_key, else id)
|
|
225
|
+
return if DB.exec_params("SELECT 1 FROM processed WHERE key = $1", [key]).any?
|
|
226
|
+
|
|
227
|
+
body = message.claim ? CLAIM_CHECK.redeem(message.claim, message.sha256) : message.body
|
|
228
|
+
handle(body)
|
|
229
|
+
DB.exec_params("INSERT INTO processed (key) VALUES ($1)", [key])
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
- `idempotency_key` is the key Ankusa computed once for the hook:
|
|
234
|
+
`tenant:source_id:dedupe_key` when the source extracted the provider's own
|
|
235
|
+
event key, else `id` — so a provider retry that arrives with a fresh Ankusa
|
|
236
|
+
`id` still collapses onto the same row, and two tenants that share a provider
|
|
237
|
+
event id do not. For a message from a node that predates the field the
|
|
238
|
+
helper computes the same key itself (tenant `default` when there is none).
|
|
239
|
+
The decoded field alone is `message.to_h[:idempotency_key]`.
|
|
240
|
+
- A replayed delivery is dropped by default. To reprocess replays instead, pass
|
|
241
|
+
`include_replay: true`: the key then ends in `#replay:<replay_id>`.
|
|
242
|
+
- `message.body` is the decoded inline bytes (nil for the claim form);
|
|
243
|
+
`message.headers` holds the forwarded provider request headers (lowercased).
|
|
244
|
+
`message.sha256` is checked against the inline bytes by `decode_message`
|
|
245
|
+
itself, and against the claim's bytes by `CLAIM_CHECK.redeem`.
|
|
246
|
+
- `decode_message` raises `InvalidMessageError` before you act on a message:
|
|
247
|
+
bad JSON, a non-object, an unsupported `v`, a bad field type, both or neither
|
|
248
|
+
body form, invalid base64, a length/sha256 mismatch, or a claim whose tenant
|
|
249
|
+
differs from `tenant_id`.
|
|
250
|
+
|
|
251
|
+
A webhook receiver can take the same shortcut straight off the HTTP sink's
|
|
252
|
+
headers with `Ankusa.parse_headers(headers).idempotency_key`, which reads
|
|
253
|
+
`x-ankusa-idempotency-key` (falling back to computing it, where `source` and
|
|
254
|
+
`tenant` play `source_id` and `tenant_id`).
|
|
255
|
+
|
|
256
|
+
When a sink has grown a backlog, or a downstream processor failed after the
|
|
257
|
+
sink accepted a batch, re-drive it with a replay job over the admin client (see
|
|
258
|
+
"Admin client" below): `"kind" => "dlq"` re-sends rows that dead-lettered,
|
|
259
|
+
`"kind" => "archive"` re-sends hooks over a time window, `"kind" => "quarantine"`
|
|
260
|
+
re-verifies held hooks and releases those that pass. Replays keep the
|
|
261
|
+
original `id` and `dedupe_key` and add `replay_id`.
|
|
132
262
|
|
|
133
263
|
## Routes client
|
|
134
264
|
|
|
@@ -162,8 +292,9 @@ unfollowed redirect, a non-JSON success body, or unreachable; retryable).
|
|
|
162
292
|
## Admin client
|
|
163
293
|
|
|
164
294
|
The operator API on `admin.port` (default 4002): health, Prometheus metrics, the
|
|
165
|
-
redacted config, the DLQ,
|
|
166
|
-
`quarantine` tags of
|
|
295
|
+
redacted config, the DLQ, the audit trail, replay jobs, and the quarantine
|
|
296
|
+
list — the `operations`, `dlq`, `audit`, `replays`, and `quarantine` tags of
|
|
297
|
+
`admin.v1.yaml`.
|
|
167
298
|
|
|
168
299
|
```ruby
|
|
169
300
|
require "ankusa/sdk"
|
|
@@ -172,14 +303,27 @@ admin = Ankusa::AdminClient.new(ENV.fetch("ADMIN_URL", "http://localhost:4002"))
|
|
|
172
303
|
|
|
173
304
|
admin.health # {"status" => "ok", "instance" => ..., "roles" => [...]}
|
|
174
305
|
admin.list_dead_letters({"limit" => 10}) # {"total" => ..., "entries" => [...]}
|
|
175
|
-
admin.replay_dead_letters({"source_id" => "demo"})
|
|
176
306
|
admin.list_quarantined
|
|
307
|
+
admin.list_audit({"tenant" => "acme", "limit" => 50}) # {"entries" => [...], "next_cursor" => ...}
|
|
308
|
+
|
|
309
|
+
job = admin.create_replay({"kind" => "dlq", "source_id" => "stripe", "rate" => 500})
|
|
310
|
+
admin.get_replay(job["id"])
|
|
311
|
+
admin.list_replays # {"replays" => [Replay, ...]}, newest first
|
|
312
|
+
admin.update_replay(job["id"], {"state" => "paused"}) # resume, pause or cancel
|
|
177
313
|
```
|
|
178
314
|
|
|
315
|
+
`create_replay` is idempotent for retries: a second POST of the same filter
|
|
316
|
+
while the job is `running`/`paused` returns the existing job (`200`) instead of
|
|
317
|
+
starting another (`202`). `list_replays` returns `{"replays" => [...]}` exactly
|
|
318
|
+
as the API sends it. `list_audit` returns the audit trail newest first,
|
|
319
|
+
`{"entries" => [...], "next_cursor" => ...}`, paged by `cursor`/`limit` and
|
|
320
|
+
filtered by `since`, `until`, `actor`, `action` and `tenant`.
|
|
321
|
+
|
|
179
322
|
Methods: `health`, `metrics` (Prometheus text), `config`, `list_dead_letters`,
|
|
180
|
-
`
|
|
181
|
-
`
|
|
182
|
-
`AdminRejectedError` (any other 4xx
|
|
323
|
+
`list_audit`, `create_replay`, `get_replay`, `list_replays`, `update_replay`,
|
|
324
|
+
`list_quarantined`. Failures are `AdminError` subclasses: `RoleNotEnabledError`
|
|
325
|
+
(409 `role_not_enabled`, carrying `role`), `AdminRejectedError` (any other 4xx —
|
|
326
|
+
a missing replay is a 404 with `code` `replay_not_found`), and
|
|
183
327
|
`AdminUnavailableError` (5xx, an unfollowed redirect, or unreachable;
|
|
184
328
|
retryable).
|
|
185
329
|
|
|
@@ -236,15 +380,16 @@ lib/ankusa/
|
|
|
236
380
|
connection.rb # shared URL/query/body/JSON rules (@api private)
|
|
237
381
|
webhook.rb # x-ankusa-* header parsing for HTTP-sink receivers
|
|
238
382
|
claim_check.rb # the claim-check gateway client
|
|
383
|
+
message.rb # decode the v1 queue message + the idempotency key
|
|
239
384
|
routes.rb # the route-management client (routes.admin.port)
|
|
240
385
|
admin.rb # the operator client (admin.port)
|
|
241
386
|
sources.rb # the tenant-scoped source-management client (admin.port)
|
|
242
387
|
test/
|
|
243
388
|
test_helper.rb
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
389
|
+
conformance_test.rb # runs the language-neutral vectors in conformance/
|
|
390
|
+
routes_test.rb
|
|
391
|
+
admin_test.rb
|
|
392
|
+
sources_test.rb
|
|
248
393
|
```
|
|
249
394
|
|
|
250
395
|
## Develop
|
data/lib/ankusa/admin.rb
CHANGED
|
@@ -34,14 +34,16 @@ module Ankusa
|
|
|
34
34
|
# The listener rejected the request (any other 4xx).
|
|
35
35
|
#
|
|
36
36
|
# `status` is the HTTP status; `code` is the body's `error` field
|
|
37
|
-
# (`invalid_filter`, ...).
|
|
37
|
+
# (`invalid_filter`, ...). `scope` is the scope a 403 forbidden says the
|
|
38
|
+
# credential lacks, nil when absent or not a 403.
|
|
38
39
|
class AdminRejectedError < AdminError
|
|
39
|
-
attr_reader :status, :code
|
|
40
|
+
attr_reader :status, :code, :scope
|
|
40
41
|
|
|
41
|
-
def initialize(message, status:, code:)
|
|
42
|
+
def initialize(message, status:, code:, scope: nil)
|
|
42
43
|
super(message)
|
|
43
44
|
@status = status
|
|
44
45
|
@code = code
|
|
46
|
+
@scope = scope
|
|
45
47
|
end
|
|
46
48
|
end
|
|
47
49
|
|
|
@@ -81,12 +83,42 @@ module Ankusa
|
|
|
81
83
|
json(request("GET", "/v1/dlq", query: params))
|
|
82
84
|
end
|
|
83
85
|
|
|
84
|
-
#
|
|
86
|
+
# GET /v1/audit -> a page of the audit trail, newest first.
|
|
85
87
|
#
|
|
86
|
-
# `
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
# `params` may carry `cursor`, `limit`, `since`, `until`, `actor`,
|
|
89
|
+
# `action` and `tenant`; absent keys are omitted from the query string
|
|
90
|
+
# rather than sent as `=`.
|
|
91
|
+
def list_audit(params = nil)
|
|
92
|
+
json(request("GET", "/v1/audit", query: params))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# POST /v1/replays -> the created Replay, as a Hash.
|
|
96
|
+
#
|
|
97
|
+
# `spec` is the replay spec: `{"kind" => "dlq", ...}`,
|
|
98
|
+
# `{"kind" => "archive", "from" => ms, "to" => ms, ...}` or
|
|
99
|
+
# `{"kind" => "quarantine", ...}`. A retried POST
|
|
100
|
+
# with the same normalized filter returns the running job with 200 instead
|
|
101
|
+
# of starting a second one.
|
|
102
|
+
def create_replay(spec)
|
|
103
|
+
json(request("POST", "/v1/replays", json: spec))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# GET /v1/replays/{id} -> the Replay, as a Hash.
|
|
107
|
+
def get_replay(id)
|
|
108
|
+
json(request("GET", "/v1/replays/#{replay_path(id)}"))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# GET /v1/replays -> {"replays" => [Replay, ...]}, newest first.
|
|
112
|
+
def list_replays
|
|
113
|
+
json(request("GET", "/v1/replays"))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# PATCH /v1/replays/{id} -> the updated Replay, as a Hash.
|
|
117
|
+
#
|
|
118
|
+
# `patch` may carry `state` (`running`/`paused`/`cancelled`), `rate` and
|
|
119
|
+
# `max_lag_ms`.
|
|
120
|
+
def update_replay(id, patch)
|
|
121
|
+
json(request("PATCH", "/v1/replays/#{replay_path(id)}", json: patch))
|
|
90
122
|
end
|
|
91
123
|
|
|
92
124
|
# GET /v1/quarantine -> recent quarantined hooks, newest first.
|
|
@@ -99,6 +131,12 @@ module Ankusa
|
|
|
99
131
|
|
|
100
132
|
private
|
|
101
133
|
|
|
134
|
+
# Percent-encodes a replay id as one path segment, so a `/`, `?` or `#` in
|
|
135
|
+
# an id can't reshape the URL.
|
|
136
|
+
def replay_path(id)
|
|
137
|
+
id.to_s.b.gsub(/[^A-Za-z0-9_.~-]/) { |char| format("%%%02X", char.ord) }
|
|
138
|
+
end
|
|
139
|
+
|
|
102
140
|
def request(http_method, path, query: nil, json: nil)
|
|
103
141
|
response = @connection.request(http_method, path, query: query, json: json)
|
|
104
142
|
raise_for_status(response)
|
|
@@ -126,10 +164,12 @@ module Ankusa
|
|
|
126
164
|
end
|
|
127
165
|
|
|
128
166
|
if status >= 400 && status < 500
|
|
167
|
+
scope = body.is_a?(Hash) ? body["scope"] : nil
|
|
129
168
|
raise AdminRejectedError.new(
|
|
130
169
|
"admin listener rejected the request (#{status}): #{body.inspect}",
|
|
131
170
|
status: status,
|
|
132
|
-
code: code
|
|
171
|
+
code: code,
|
|
172
|
+
scope: scope
|
|
133
173
|
)
|
|
134
174
|
end
|
|
135
175
|
|
data/lib/ankusa/claim_check.rb
CHANGED
|
@@ -11,8 +11,9 @@ module Ankusa
|
|
|
11
11
|
# right.
|
|
12
12
|
#
|
|
13
13
|
# Non-retryable: the ref or expected sha256 is malformed, the gateway said
|
|
14
|
-
# 404
|
|
15
|
-
#
|
|
14
|
+
# 404 or another 4xx (except 408/429), or the bytes that came back don't
|
|
15
|
+
# match the sha256.
|
|
16
|
+
# Retryable: the gateway said 5xx, 408 or 429, or the request never completed
|
|
16
17
|
# (network error, timeout).
|
|
17
18
|
class ClaimCheckError < Error
|
|
18
19
|
def retryable? = false
|
|
@@ -28,7 +29,7 @@ module Ankusa
|
|
|
28
29
|
class ClaimNotFoundError < ClaimCheckError
|
|
29
30
|
end
|
|
30
31
|
|
|
31
|
-
# The gateway rejected the request (400 or any other
|
|
32
|
+
# The gateway rejected the request (400 or any other 4xx but 404, 408 and 429).
|
|
32
33
|
class ClaimRejectedError < ClaimCheckError
|
|
33
34
|
attr_reader :status, :body
|
|
34
35
|
|
|
@@ -46,7 +47,7 @@ module Ankusa
|
|
|
46
47
|
class ClaimIntegrityError < ClaimCheckError
|
|
47
48
|
end
|
|
48
49
|
|
|
49
|
-
# The gateway is unreachable, or answered 5xx
|
|
50
|
+
# The gateway is unreachable, or answered 5xx, 408 or 429. Safe to retry.
|
|
50
51
|
class ClaimCheckUnavailableError < ClaimCheckError
|
|
51
52
|
def retryable? = true
|
|
52
53
|
end
|
|
@@ -57,24 +58,31 @@ module Ankusa
|
|
|
57
58
|
# Mirrors `#/components/schemas/Ref` in priv/openapi/claim_check.v1.yaml --
|
|
58
59
|
# keep the two in sync. A ref is one string:
|
|
59
60
|
# urn:ankusa:claim:v1:<tenant>:<claim_id>
|
|
61
|
+
# urn:ankusa:claim:v2:<tenant>:<claim_id>:<exp_unix>:<signature>
|
|
62
|
+
# <exp_unix> is a decimal of 1-12 digits with no leading zero and <signature>
|
|
63
|
+
# 43 unpadded base64url characters. The gateway checks the signature; the
|
|
64
|
+
# SDK only carries it.
|
|
60
65
|
#
|
|
61
66
|
# \A...\z, never ^...$: Ruby's ^ and $ are line anchors, so a ref ending in a
|
|
62
67
|
# newline would otherwise parse.
|
|
63
68
|
CLAIM_REF_PATTERN = /\Aurn:ankusa:claim:v1:([A-Za-z0-9_-]{1,64}):([0-7][0-9A-HJKMNP-TV-Z]{25})\z/
|
|
69
|
+
CLAIM_REF_V2_PATTERN =
|
|
70
|
+
/\Aurn:ankusa:claim:v2:([A-Za-z0-9_-]{1,64}):([0-7][0-9A-HJKMNP-TV-Z]{25}):([1-9][0-9]{0,11}):([A-Za-z0-9_-]{43})\z/
|
|
64
71
|
|
|
65
72
|
SHA256_PATTERN = /\A[0-9a-f]{64}\z/
|
|
66
73
|
|
|
67
74
|
# Parses a claim-check ref (the `claim` field of a queue message) into the
|
|
68
|
-
# tenant id, claim id, and GET /v1/claims/{tenant_id}/{claim_id} path
|
|
69
|
-
#
|
|
70
|
-
# well-formed ref.
|
|
75
|
+
# tenant id, claim id, and GET /v1/claims/{tenant_id}/{claim_id} path (with
|
|
76
|
+
# ?exp=&sig= for a v2 ref). Raises `InvalidClaimRefError` -- never worth
|
|
77
|
+
# retrying -- if `ref` isn't a well-formed ref.
|
|
71
78
|
def self.parse_claim_ref(ref)
|
|
72
|
-
match = ref.is_a?(String) ? CLAIM_REF_PATTERN.match(ref) : nil
|
|
79
|
+
match = ref.is_a?(String) ? (CLAIM_REF_V2_PATTERN.match(ref) || CLAIM_REF_PATTERN.match(ref)) : nil
|
|
73
80
|
raise InvalidClaimRefError, "invalid claim-check ref: #{ref}" if match.nil?
|
|
74
81
|
|
|
75
|
-
tenant_id = match
|
|
76
|
-
|
|
77
|
-
|
|
82
|
+
tenant_id, claim_id, exp, sig = match.captures
|
|
83
|
+
path = "/v1/claims/#{tenant_id}/#{claim_id}"
|
|
84
|
+
path += "?exp=#{exp}&sig=#{sig}" if sig
|
|
85
|
+
ParsedClaimRef.new(tenant_id: tenant_id, claim_id: claim_id, path: path)
|
|
78
86
|
end
|
|
79
87
|
|
|
80
88
|
# Redeem claim-check refs against a deployment's claim-check gateway.
|
|
@@ -133,6 +141,13 @@ module Ankusa
|
|
|
133
141
|
raise ClaimNotFoundError, "claim not found: #{parsed.tenant_id}/#{parsed.claim_id}"
|
|
134
142
|
end
|
|
135
143
|
|
|
144
|
+
# A gateway (or a proxy in front of it) that is throttling or timing out
|
|
145
|
+
# is telling the caller to come back, not that the claim is gone.
|
|
146
|
+
if [408, 429].include?(status)
|
|
147
|
+
raise ClaimCheckUnavailableError,
|
|
148
|
+
"claim-check gateway busy (#{status}): #{Connection.error_body(response).inspect}"
|
|
149
|
+
end
|
|
150
|
+
|
|
136
151
|
if status >= 400 && status < 500
|
|
137
152
|
body = Connection.error_body(response)
|
|
138
153
|
raise ClaimRejectedError.new(
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ankusa
|
|
6
|
+
# One decoded lifecycle event: the CloudEvents 1.0 (structured mode) event an
|
|
7
|
+
# Ankusa instance publishes when a source or a route is created, updated or
|
|
8
|
+
# deleted, when a tenant is erased, or when the instance is restored from a
|
|
9
|
+
# backup. It arrives as the body of a queue message from the instance's
|
|
10
|
+
# lifecycle sinks (content type `application/cloudevents+json`, `source_id`
|
|
11
|
+
# `ankusa:lifecycle`): decode the message with `Ankusa.decode_message`,
|
|
12
|
+
# redeem its body, and hand the bytes to `Ankusa.decode_lifecycle`.
|
|
13
|
+
#
|
|
14
|
+
# Delivery is at least once and in commit order. `id` is derived from the
|
|
15
|
+
# change, so the same event always has the same id, and `revision` (per
|
|
16
|
+
# `subject`) lets a consumer ignore a stale update. `sequence` is the
|
|
17
|
+
# instance's audit sequence number, the decimal string it is on the wire.
|
|
18
|
+
# `data` is the entity as the admin API shows it, secrets redacted (nil when
|
|
19
|
+
# the event carries none). An optional field the event omitted or said `null`
|
|
20
|
+
# is nil.
|
|
21
|
+
LifecycleEvent = Data.define(
|
|
22
|
+
:specversion, :id, :source, :type, :subject, :time, :datacontenttype, :data, :revision,
|
|
23
|
+
:actor, :instance, :sequence, :replayid, :traceparent, :tracestate
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
LIFECYCLE_REQUIRED_FIELDS = %w[id source type subject time].freeze
|
|
27
|
+
LIFECYCLE_DECIMAL = /\A[0-9]+\z/
|
|
28
|
+
private_constant :LIFECYCLE_REQUIRED_FIELDS, :LIFECYCLE_DECIMAL
|
|
29
|
+
|
|
30
|
+
# Decodes one lifecycle event (the JSON of the CloudEvent) into a
|
|
31
|
+
# `LifecycleEvent`. The checks run in a fixed order and the first failure
|
|
32
|
+
# wins, raising `InvalidMessageError` (never retryable): `invalid_json`,
|
|
33
|
+
# `not_an_object`, `unsupported_version` (`specversion` is not "1.0") or
|
|
34
|
+
# `invalid_field` with the offending `field`. Keys and event types this SDK
|
|
35
|
+
# does not know are ignored, so a consumer keeps working when a newer node
|
|
36
|
+
# announces more.
|
|
37
|
+
def self.decode_lifecycle(data)
|
|
38
|
+
parsed =
|
|
39
|
+
begin
|
|
40
|
+
JSON.parse(data)
|
|
41
|
+
rescue JSON::ParserError
|
|
42
|
+
raise InvalidMessageError.new("event is not JSON", code: "invalid_json")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
unless parsed.is_a?(Hash)
|
|
46
|
+
raise InvalidMessageError.new("event is not a JSON object", code: "not_an_object")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
unless parsed["specversion"] == "1.0"
|
|
50
|
+
raise InvalidMessageError.new("unsupported specversion", code: "unsupported_version")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
LIFECYCLE_REQUIRED_FIELDS.each do |field|
|
|
54
|
+
value = parsed[field]
|
|
55
|
+
lifecycle_invalid_field(field) unless value.is_a?(String) && !value.empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
datacontenttype = lifecycle_optional_string(parsed, "datacontenttype")
|
|
59
|
+
|
|
60
|
+
body = parsed["data"]
|
|
61
|
+
lifecycle_invalid_field("data") unless body.nil? || body.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
revision = parsed["revision"]
|
|
64
|
+
lifecycle_invalid_field("revision") unless revision.nil? || lifecycle_revision?(revision)
|
|
65
|
+
|
|
66
|
+
actor = lifecycle_optional_string(parsed, "actor")
|
|
67
|
+
instance = lifecycle_optional_string(parsed, "instance")
|
|
68
|
+
|
|
69
|
+
sequence = parsed["sequence"]
|
|
70
|
+
lifecycle_invalid_field("sequence") unless sequence.nil? || lifecycle_decimal?(sequence)
|
|
71
|
+
|
|
72
|
+
LifecycleEvent.new(
|
|
73
|
+
specversion: "1.0",
|
|
74
|
+
id: parsed["id"],
|
|
75
|
+
source: parsed["source"],
|
|
76
|
+
type: parsed["type"],
|
|
77
|
+
subject: parsed["subject"],
|
|
78
|
+
time: parsed["time"],
|
|
79
|
+
datacontenttype: datacontenttype,
|
|
80
|
+
data: body,
|
|
81
|
+
revision: revision,
|
|
82
|
+
actor: actor,
|
|
83
|
+
instance: instance,
|
|
84
|
+
sequence: sequence,
|
|
85
|
+
replayid: lifecycle_optional_string(parsed, "replayid"),
|
|
86
|
+
traceparent: lifecycle_optional_string(parsed, "traceparent"),
|
|
87
|
+
tracestate: lifecycle_optional_string(parsed, "tracestate")
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.lifecycle_optional_string(parsed, field)
|
|
92
|
+
value = parsed[field]
|
|
93
|
+
lifecycle_invalid_field(field) unless value.nil? || value.is_a?(String)
|
|
94
|
+
value
|
|
95
|
+
end
|
|
96
|
+
private_class_method :lifecycle_optional_string
|
|
97
|
+
|
|
98
|
+
def self.lifecycle_revision?(value)
|
|
99
|
+
value.is_a?(Integer) && value >= 1
|
|
100
|
+
end
|
|
101
|
+
private_class_method :lifecycle_revision?
|
|
102
|
+
|
|
103
|
+
def self.lifecycle_decimal?(value)
|
|
104
|
+
value.is_a?(String) && LIFECYCLE_DECIMAL.match?(value)
|
|
105
|
+
end
|
|
106
|
+
private_class_method :lifecycle_decimal?
|
|
107
|
+
|
|
108
|
+
def self.lifecycle_invalid_field(field)
|
|
109
|
+
raise InvalidMessageError.new("invalid or missing field: #{field}", code: "invalid_field", field: field)
|
|
110
|
+
end
|
|
111
|
+
private_class_method :lifecycle_invalid_field
|
|
112
|
+
end
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Ankusa
|
|
7
|
+
# Raised by `Ankusa.decode_message` when a queue message fails one of the v1
|
|
8
|
+
# decode rules. Always worth dead-lettering, never worth retrying: the bytes
|
|
9
|
+
# will not change on a redelivery.
|
|
10
|
+
#
|
|
11
|
+
# `code` names the rule that failed (`invalid_json`, `unsupported_version`,
|
|
12
|
+
# `invalid_field`, `ambiguous_body`, `missing_body`, `invalid_body_base64`,
|
|
13
|
+
# `size_mismatch`, `integrity`, `tenant_mismatch`); `field` names the offending
|
|
14
|
+
# key when the rule is about one, and is nil otherwise.
|
|
15
|
+
class InvalidMessageError < Error
|
|
16
|
+
attr_reader :code, :field
|
|
17
|
+
|
|
18
|
+
def initialize(message, code:, field: nil)
|
|
19
|
+
super(message)
|
|
20
|
+
@code = code
|
|
21
|
+
@field = field
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def retryable? = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The one idempotency-key rule, shared by `Message` and `HookHeaders` so it
|
|
28
|
+
# lives in one place.
|
|
29
|
+
#
|
|
30
|
+
# The key Ankusa shipped (`shipped`) when it is a non-empty string; otherwise,
|
|
31
|
+
# for a hook from a node that predates the field, `tenant:source:dedupe_key`
|
|
32
|
+
# (tenant "default" when nil) for a non-empty `dedupe_key`, else `id`. With
|
|
33
|
+
# `include_replay` and a `replay_id`, `#replay:<replay_id>` is appended.
|
|
34
|
+
module IdempotencyKey
|
|
35
|
+
def self.build(shipped:, tenant:, source:, dedupe_key:, id:, replay_id:, include_replay:)
|
|
36
|
+
key =
|
|
37
|
+
if !shipped.nil? && !shipped.empty?
|
|
38
|
+
shipped
|
|
39
|
+
elsif !dedupe_key.nil? && !dedupe_key.empty?
|
|
40
|
+
"#{tenant || "default"}:#{source}:#{dedupe_key}"
|
|
41
|
+
else
|
|
42
|
+
id
|
|
43
|
+
end
|
|
44
|
+
key += "#replay:#{replay_id}" if include_replay && !replay_id.nil?
|
|
45
|
+
key
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
private_constant :IdempotencyKey
|
|
49
|
+
|
|
50
|
+
# The message versions `Ankusa.decode_message` understands, both ends
|
|
51
|
+
# inclusive. `v` is the wire format's major version: a `v: 2` message is a
|
|
52
|
+
# `v: 1` message plus additive fields, decoded exactly like it (the keys this
|
|
53
|
+
# decoder does not know are ignored) into the one `Message` type, with `v` as
|
|
54
|
+
# the wire said it. Raising `MAX_SUPPORTED_VERSION` is the whole change an
|
|
55
|
+
# SDK needs for a producer that starts writing the next additive version.
|
|
56
|
+
MIN_SUPPORTED_VERSION = 1
|
|
57
|
+
MAX_SUPPORTED_VERSION = 2
|
|
58
|
+
|
|
59
|
+
# One decoded queue message; `v` is the wire's. `body` holds the inline bytes (or nil for the
|
|
60
|
+
# claim form, where the bytes are fetched with `Ankusa::ClaimCheckClient`);
|
|
61
|
+
# `claim` holds the ref string in that case. `headers` is the forwarded
|
|
62
|
+
# provider request headers, `{}` when the message carried none. `traceparent`
|
|
63
|
+
# and `tracestate` are the hook's W3C trace context, verbatim (nil when the
|
|
64
|
+
# message carries none): continue the trace from them. `instance` and `region`
|
|
65
|
+
# are the name of the Ankusa instance that published the message and the
|
|
66
|
+
# region it is configured with (nil when the message says null or predates
|
|
67
|
+
# the fields).
|
|
68
|
+
#
|
|
69
|
+
# `idempotency_key` is the one value a consumer should dedupe on, as
|
|
70
|
+
# `Message#idempotency_key` (the field is nil only for a message from a node
|
|
71
|
+
# that predates it); see `Ankusa.decode_message` for the decode rules.
|
|
72
|
+
Message = Data.define(
|
|
73
|
+
:v, :id, :source_id, :tenant_id, :received_at, :content_type, :size,
|
|
74
|
+
:body, :claim, :sha256, :dedupe_key, :replay_id, :idempotency_key, :headers, :verification,
|
|
75
|
+
:traceparent, :tracestate, :instance, :region
|
|
76
|
+
) do
|
|
77
|
+
# `Data.define` made `idempotency_key` the reader of the decoded field; keep
|
|
78
|
+
# that value reachable here and let the helper below take over the name.
|
|
79
|
+
# The decoded field itself is still in `to_h[:idempotency_key]`.
|
|
80
|
+
alias_method :shipped_idempotency_key, :idempotency_key
|
|
81
|
+
private :shipped_idempotency_key
|
|
82
|
+
|
|
83
|
+
# The idempotency key for this message: the key Ankusa shipped in the
|
|
84
|
+
# `idempotency_key` field when it is non-empty; for a message that predates
|
|
85
|
+
# the field, `tenant:source_id:dedupe_key` (tenant "default" when there is
|
|
86
|
+
# none) when a non-empty `dedupe_key` is set, else `id`.
|
|
87
|
+
#
|
|
88
|
+
# A replay keeps the original `id` and `dedupe_key` and adds `replay_id`, so
|
|
89
|
+
# by default a replay produces the same key as the delivery it replays and a
|
|
90
|
+
# consumer that already processed it drops it. Pass `include_replay: true`
|
|
91
|
+
# to reprocess replays instead.
|
|
92
|
+
def idempotency_key(include_replay: false)
|
|
93
|
+
IdempotencyKey.build(
|
|
94
|
+
shipped: shipped_idempotency_key, tenant: tenant_id, source: source_id,
|
|
95
|
+
dedupe_key: dedupe_key, id: id, replay_id: replay_id, include_replay: include_replay
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Whether the node verified this message's hook at ingest and nothing flags
|
|
100
|
+
# it (see `Verification#trusted?`). A message with no `verification` (a node
|
|
101
|
+
# older than the field) is untrusted.
|
|
102
|
+
def trusted?
|
|
103
|
+
verification&.trusted? == true
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The optional string-keyed fields that may be a string, null, or absent.
|
|
108
|
+
# Checked in this order, so the first offending key is the one reported.
|
|
109
|
+
MESSAGE_STRING_FIELDS = %w[
|
|
110
|
+
tenant_id content_type dedupe_key replay_id idempotency_key traceparent tracestate instance region
|
|
111
|
+
].freeze
|
|
112
|
+
private_constant :MESSAGE_STRING_FIELDS
|
|
113
|
+
|
|
114
|
+
# Decodes one queue message of any supported `v` (`MIN_SUPPORTED_VERSION` to
|
|
115
|
+
# `MAX_SUPPORTED_VERSION`; the JSON body a sink or broker delivers) into
|
|
116
|
+
# a `Message`, applying every rule in "Consuming queue messages" of the SDK
|
|
117
|
+
# README in order; the first failure wins.
|
|
118
|
+
#
|
|
119
|
+
# Raises `InvalidMessageError` for anything malformed. The `sha256` is checked
|
|
120
|
+
# against the inline bytes here, but a claim's bytes are only checked by
|
|
121
|
+
# `ClaimCheckClient#redeem`, after it fetches them.
|
|
122
|
+
def self.decode_message(data)
|
|
123
|
+
parsed =
|
|
124
|
+
begin
|
|
125
|
+
JSON.parse(data)
|
|
126
|
+
rescue JSON::ParserError
|
|
127
|
+
raise InvalidMessageError.new("message is not JSON", code: "invalid_json")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
unless parsed.is_a?(Hash)
|
|
131
|
+
raise InvalidMessageError.new("message is not a JSON object", code: "not_an_object")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
version = parsed["v"]
|
|
135
|
+
unless version.is_a?(Integer) && version.between?(MIN_SUPPORTED_VERSION, MAX_SUPPORTED_VERSION)
|
|
136
|
+
raise InvalidMessageError.new("unsupported message version: #{version.inspect}", code: "unsupported_version")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
id = parsed["id"]
|
|
140
|
+
invalid_field("id") unless id.is_a?(String) && !id.empty?
|
|
141
|
+
|
|
142
|
+
source_id = parsed["source_id"]
|
|
143
|
+
invalid_field("source_id") unless source_id.is_a?(String)
|
|
144
|
+
|
|
145
|
+
received_at = parsed["received_at"]
|
|
146
|
+
invalid_field("received_at") unless received_at.is_a?(Integer)
|
|
147
|
+
|
|
148
|
+
size = parsed["size"]
|
|
149
|
+
invalid_field("size") unless size.is_a?(Integer) && size >= 0
|
|
150
|
+
|
|
151
|
+
MESSAGE_STRING_FIELDS.each do |field|
|
|
152
|
+
value = parsed[field]
|
|
153
|
+
invalid_field(field) unless value.nil? || value.is_a?(String)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
headers = parsed.key?("headers") ? parsed["headers"] : {}
|
|
157
|
+
invalid_field("headers") unless headers.is_a?(Hash) && headers.each_value.all? { |value| value.is_a?(String) }
|
|
158
|
+
|
|
159
|
+
sha256 = parsed["sha256"]
|
|
160
|
+
valid_sha256 = sha256.nil? || (sha256.is_a?(String) && SHA256_PATTERN.match?(sha256))
|
|
161
|
+
invalid_field("sha256") unless valid_sha256
|
|
162
|
+
|
|
163
|
+
verification = decode_verification(parsed["verification"])
|
|
164
|
+
|
|
165
|
+
body_base64 = parsed["body_base64"]
|
|
166
|
+
claim = parsed["claim"]
|
|
167
|
+
|
|
168
|
+
unless body_base64.nil? || claim.nil?
|
|
169
|
+
raise InvalidMessageError.new("message has both body_base64 and claim", code: "ambiguous_body")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
if body_base64.nil? && claim.nil?
|
|
173
|
+
raise InvalidMessageError.new("message has neither body_base64 nor claim", code: "missing_body")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
body = nil
|
|
177
|
+
parsed_claim = nil
|
|
178
|
+
|
|
179
|
+
if body_base64.nil?
|
|
180
|
+
parsed_claim = parse_claim(claim)
|
|
181
|
+
invalid_field("sha256") if sha256.nil?
|
|
182
|
+
else
|
|
183
|
+
body = decode_body(body_base64)
|
|
184
|
+
if body.bytesize != size
|
|
185
|
+
raise InvalidMessageError.new("body is #{body.bytesize} bytes, size says #{size}", code: "size_mismatch")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
if !sha256.nil? && Digest::SHA256.hexdigest(body) != sha256
|
|
189
|
+
raise InvalidMessageError.new("body sha256 does not match", code: "integrity")
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
tenant_id = parsed["tenant_id"]
|
|
194
|
+
if !parsed_claim.nil? && !tenant_id.nil? && parsed_claim.tenant_id != tenant_id
|
|
195
|
+
raise InvalidMessageError.new(
|
|
196
|
+
"claim tenant #{parsed_claim.tenant_id} does not match tenant_id #{tenant_id}",
|
|
197
|
+
code: "tenant_mismatch"
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
Message.new(
|
|
202
|
+
v: version,
|
|
203
|
+
id: id,
|
|
204
|
+
source_id: source_id,
|
|
205
|
+
tenant_id: tenant_id,
|
|
206
|
+
received_at: received_at,
|
|
207
|
+
content_type: parsed["content_type"],
|
|
208
|
+
size: size,
|
|
209
|
+
body: body,
|
|
210
|
+
claim: claim,
|
|
211
|
+
sha256: sha256,
|
|
212
|
+
dedupe_key: parsed["dedupe_key"],
|
|
213
|
+
replay_id: parsed["replay_id"],
|
|
214
|
+
idempotency_key: parsed["idempotency_key"],
|
|
215
|
+
headers: headers,
|
|
216
|
+
verification: verification,
|
|
217
|
+
traceparent: parsed["traceparent"],
|
|
218
|
+
tracestate: parsed["tracestate"],
|
|
219
|
+
instance: parsed["instance"],
|
|
220
|
+
region: parsed["region"]
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Absent or explicit `null` is `nil` (a node that predates the field).
|
|
225
|
+
# Anything else must be `{status: <non-empty string>, flagged: <boolean>}`;
|
|
226
|
+
# the status value itself is never validated, so unknown statuses pass
|
|
227
|
+
# through untouched.
|
|
228
|
+
def self.decode_verification(value)
|
|
229
|
+
return nil if value.nil?
|
|
230
|
+
invalid_field("verification") unless value.is_a?(Hash)
|
|
231
|
+
|
|
232
|
+
status = value["status"]
|
|
233
|
+
flagged = value["flagged"]
|
|
234
|
+
valid = status.is_a?(String) && !status.empty? && [true, false].include?(flagged)
|
|
235
|
+
invalid_field("verification") unless valid
|
|
236
|
+
|
|
237
|
+
Verification.new(status: status, flagged: flagged)
|
|
238
|
+
end
|
|
239
|
+
private_class_method :decode_verification
|
|
240
|
+
|
|
241
|
+
def self.invalid_field(field)
|
|
242
|
+
raise InvalidMessageError.new("invalid message field: #{field}", code: "invalid_field", field: field)
|
|
243
|
+
end
|
|
244
|
+
private_class_method :invalid_field
|
|
245
|
+
|
|
246
|
+
def self.decode_body(body_base64)
|
|
247
|
+
unless body_base64.is_a?(String)
|
|
248
|
+
raise InvalidMessageError.new("body_base64 is not a string", code: "invalid_body_base64")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
body_base64.unpack1("m0")
|
|
252
|
+
rescue ArgumentError
|
|
253
|
+
raise InvalidMessageError.new("body_base64 is not valid base64", code: "invalid_body_base64")
|
|
254
|
+
end
|
|
255
|
+
private_class_method :decode_body
|
|
256
|
+
|
|
257
|
+
# A claim that doesn't parse is an invalid *field*, not an invalid claim-ref
|
|
258
|
+
# error: the message itself is what a consumer rejects, so it carries the same
|
|
259
|
+
# `invalid_field` code as every other bad key.
|
|
260
|
+
def self.parse_claim(claim)
|
|
261
|
+
Ankusa.parse_claim_ref(claim)
|
|
262
|
+
rescue InvalidClaimRefError
|
|
263
|
+
invalid_field("claim")
|
|
264
|
+
end
|
|
265
|
+
private_class_method :parse_claim
|
|
266
|
+
end
|
data/lib/ankusa/routes.rb
CHANGED
|
@@ -37,18 +37,20 @@ module Ankusa
|
|
|
37
37
|
# The listener rejected the request (400 or any other non-404 4xx).
|
|
38
38
|
#
|
|
39
39
|
# `code` is the body's `error` field (`invalid_route`, `duplicate_route`,
|
|
40
|
-
# `too_many_routes`, ...); `field`, `message`, `conflicting_id
|
|
41
|
-
# `max_routes` are carried through when the body supplies them.
|
|
40
|
+
# `too_many_routes`, ...); `field`, `message`, `conflicting_id`,
|
|
41
|
+
# `max_routes` and `scope` are carried through when the body supplies them.
|
|
42
|
+
# `scope` is the scope a 403 forbidden says the credential lacks.
|
|
42
43
|
class RoutesRejectedError < RoutesError
|
|
43
|
-
attr_reader :status, :code, :field, :conflicting_id, :max_routes
|
|
44
|
+
attr_reader :status, :code, :field, :conflicting_id, :max_routes, :scope
|
|
44
45
|
|
|
45
|
-
def initialize(message, status:, code:, field: nil, conflicting_id: nil, max_routes: nil)
|
|
46
|
+
def initialize(message, status:, code:, field: nil, conflicting_id: nil, max_routes: nil, scope: nil)
|
|
46
47
|
super(message)
|
|
47
48
|
@status = status
|
|
48
49
|
@code = code
|
|
49
50
|
@field = field
|
|
50
51
|
@conflicting_id = conflicting_id
|
|
51
52
|
@max_routes = max_routes
|
|
53
|
+
@scope = scope
|
|
52
54
|
end
|
|
53
55
|
end
|
|
54
56
|
|
|
@@ -149,7 +151,8 @@ module Ankusa
|
|
|
149
151
|
code: field(body, "error"),
|
|
150
152
|
field: field(body, "field"),
|
|
151
153
|
conflicting_id: field(body, "conflicting_id"),
|
|
152
|
-
max_routes: field(body, "max_routes")
|
|
154
|
+
max_routes: field(body, "max_routes"),
|
|
155
|
+
scope: field(body, "scope")
|
|
153
156
|
)
|
|
154
157
|
end
|
|
155
158
|
|
data/lib/ankusa/sdk/version.rb
CHANGED
data/lib/ankusa/sdk.rb
CHANGED
|
@@ -5,7 +5,10 @@ require_relative "error"
|
|
|
5
5
|
require_relative "transport"
|
|
6
6
|
require_relative "connection"
|
|
7
7
|
require_relative "webhook"
|
|
8
|
+
require_relative "signature"
|
|
8
9
|
require_relative "claim_check"
|
|
10
|
+
require_relative "message"
|
|
11
|
+
require_relative "lifecycle"
|
|
9
12
|
require_relative "routes"
|
|
10
13
|
require_relative "admin"
|
|
11
14
|
require_relative "sources"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Ankusa
|
|
6
|
+
# A delivery whose Standard Webhooks signature does not verify. Never
|
|
7
|
+
# retryable: answer `401`; the sender retries with the same bytes.
|
|
8
|
+
#
|
|
9
|
+
# `code` is one of `invalid_secret`, `missing_header`, `invalid_timestamp`,
|
|
10
|
+
# `timestamp_out_of_tolerance`, `no_matching_signature`; `field` names the
|
|
11
|
+
# header at fault (nil for `invalid_secret`).
|
|
12
|
+
class InvalidSignatureError < Error
|
|
13
|
+
attr_reader :code, :field
|
|
14
|
+
|
|
15
|
+
def initialize(message, code:, field: nil)
|
|
16
|
+
super(message)
|
|
17
|
+
@code = code
|
|
18
|
+
@field = field
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def retryable? = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A verified delivery's `webhook-id` and `webhook-timestamp`.
|
|
25
|
+
VerifiedSignature = Data.define(:id, :timestamp)
|
|
26
|
+
|
|
27
|
+
BASE64_PATTERN = %r{\A(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\z}
|
|
28
|
+
private_constant :BASE64_PATTERN
|
|
29
|
+
|
|
30
|
+
# Verifies the Standard Webhooks signature an HTTP sink with a `secret` adds
|
|
31
|
+
# (https://www.standardwebhooks.com/).
|
|
32
|
+
#
|
|
33
|
+
# `webhook-signature` holds space-separated `v1,<base64>` entries, each an
|
|
34
|
+
# HMAC-SHA256 over `<webhook-id>.<webhook-timestamp>.<body>`. Any `v1` entry
|
|
35
|
+
# matching any secret (`whsec_` + base64, or any other string used as its own
|
|
36
|
+
# bytes) passes, compared in constant time, provided `webhook-timestamp` is
|
|
37
|
+
# within `tolerance_seconds` of `now` (the clock by default). `body` must be
|
|
38
|
+
# the raw bytes received. Raises `InvalidSignatureError`.
|
|
39
|
+
def self.verify_signature(headers, body, secrets, tolerance_seconds: 300, now: nil)
|
|
40
|
+
keys = signature_keys(Array(secrets))
|
|
41
|
+
lowered = headers.to_h { |key, value| [key.to_s.downcase, value] }
|
|
42
|
+
|
|
43
|
+
required = lambda do |name|
|
|
44
|
+
value = lowered[name]
|
|
45
|
+
raise InvalidSignatureError.new("missing #{name} header", code: "missing_header", field: name) if value.nil? || value.empty?
|
|
46
|
+
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
hook_id = required.call("webhook-id")
|
|
51
|
+
raw_timestamp = required.call("webhook-timestamp")
|
|
52
|
+
signature = required.call("webhook-signature")
|
|
53
|
+
|
|
54
|
+
unless raw_timestamp.match?(/\A[0-9]+\z/)
|
|
55
|
+
raise InvalidSignatureError.new("webhook-timestamp is not a unix time",
|
|
56
|
+
code: "invalid_timestamp", field: "webhook-timestamp")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
timestamp = Integer(raw_timestamp, 10)
|
|
60
|
+
current = now || Time.now.to_i
|
|
61
|
+
if (current - timestamp).abs > tolerance_seconds
|
|
62
|
+
raise InvalidSignatureError.new("webhook-timestamp is outside the tolerance window",
|
|
63
|
+
code: "timestamp_out_of_tolerance", field: "webhook-timestamp")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
signed = "#{hook_id}.#{raw_timestamp}.".b + body.to_s.b
|
|
67
|
+
candidates = signature.split(" ").filter_map { |entry| entry.delete_prefix("v1,") if entry.start_with?("v1,") }
|
|
68
|
+
|
|
69
|
+
keys.each do |key|
|
|
70
|
+
expected = [OpenSSL::HMAC.digest("SHA256", key, signed)].pack("m0")
|
|
71
|
+
candidates.each do |candidate|
|
|
72
|
+
next unless candidate.bytesize == expected.bytesize
|
|
73
|
+
|
|
74
|
+
return VerifiedSignature.new(id: hook_id, timestamp: timestamp) if OpenSSL.fixed_length_secure_compare(candidate, expected)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
raise InvalidSignatureError.new("no webhook-signature entry matches",
|
|
79
|
+
code: "no_matching_signature", field: "webhook-signature")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.signature_keys(secrets)
|
|
83
|
+
raise InvalidSignatureError.new("no secret configured", code: "invalid_secret") if secrets.empty?
|
|
84
|
+
|
|
85
|
+
secrets.map do |secret|
|
|
86
|
+
if secret.start_with?("whsec_")
|
|
87
|
+
encoded = secret.delete_prefix("whsec_")
|
|
88
|
+
unless !encoded.empty? && BASE64_PATTERN.match?(encoded)
|
|
89
|
+
raise InvalidSignatureError.new("a whsec_ secret is not valid base64", code: "invalid_secret")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
encoded.unpack1("m0")
|
|
93
|
+
elsif secret.empty?
|
|
94
|
+
raise InvalidSignatureError.new("an empty secret", code: "invalid_secret")
|
|
95
|
+
else
|
|
96
|
+
secret.b
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
private_class_method :signature_keys
|
|
101
|
+
end
|
data/lib/ankusa/sources.rb
CHANGED
|
@@ -21,7 +21,9 @@ module Ankusa
|
|
|
21
21
|
class SourceNotFoundError < SourcesError
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
# 409 source_exists: a source with that name already exists
|
|
24
|
+
# 409 source_exists: a source with that name already exists; or
|
|
25
|
+
# 409 source_has_deliveries: a delete found the source still has undelivered
|
|
26
|
+
# hooks. The body's "error" says which.
|
|
25
27
|
class SourceConflictError < SourcesError
|
|
26
28
|
end
|
|
27
29
|
|
|
@@ -169,7 +171,10 @@ module Ankusa
|
|
|
169
171
|
# Delete a source: DELETE /v1/tenants/<tenant>/sources/<name>.
|
|
170
172
|
#
|
|
171
173
|
# Succeeds with no return value (the server answers 204 with an empty body);
|
|
172
|
-
# a missing source raises `SourceNotFoundError`.
|
|
174
|
+
# a missing source raises `SourceNotFoundError`. A source that still has
|
|
175
|
+
# undelivered hooks on the server's node raises `SourceConflictError` whose
|
|
176
|
+
# body's "error" is "source_has_deliveries" (with "pending"/"inflight"
|
|
177
|
+
# counts); this method never sends the admin API's ?deliveries=dead_letter.
|
|
173
178
|
def delete_source(tenant, name)
|
|
174
179
|
validate_tenant(tenant)
|
|
175
180
|
validate_name(name)
|
data/lib/ankusa/webhook.rb
CHANGED
|
@@ -1,12 +1,65 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Ankusa
|
|
4
|
+
# How Ankusa verified the hook at ingest. `status` is `ok`, `failed` or
|
|
5
|
+
# `skipped`, but other values pass through untouched (forward-compatible).
|
|
6
|
+
# `flagged` is true when a failed check was accepted
|
|
7
|
+
# (`on_verify_failure: accept_flag`): treat the delivery as unverified.
|
|
8
|
+
Verification = Data.define(:status, :flagged) do
|
|
9
|
+
# Whether the node verified the hook and nothing flags it: true only for
|
|
10
|
+
# status "ok" with `flagged` false. A flagged hook, a "failed" or "skipped"
|
|
11
|
+
# one, and any other status are untrusted.
|
|
12
|
+
def trusted?
|
|
13
|
+
status == "ok" && flagged == false
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
4
17
|
# The identity of one HTTP-sink delivery: the headers Ankusa's HTTP sink
|
|
5
18
|
# attaches to every delivery.
|
|
6
19
|
#
|
|
7
|
-
# `content_type` and `
|
|
8
|
-
# (a tenant only travels when the
|
|
9
|
-
|
|
20
|
+
# `content_type`, `tenant`, `dedupe_key`, `replay_id` and `idempotency_key` are
|
|
21
|
+
# nil when the delivery had no such header (a tenant only travels when the
|
|
22
|
+
# source has one; `dedupe_key` only when the source declared a dedupe key;
|
|
23
|
+
# `replay_id` only when the delivery is a replay; `idempotency_key` is
|
|
24
|
+
# missing only from a sender that predates the header). `received_at` (when
|
|
25
|
+
# the edge accepted the hook, unix milliseconds, an Integer) and `body_sha256`
|
|
26
|
+
# (the lowercase hex SHA-256 of the body Ankusa sent) are advisory: nil when
|
|
27
|
+
# the header is absent, empty or malformed (not 1 to 15 decimal digits; not
|
|
28
|
+
# exactly 64 lowercase hex characters), never an error. `verification` is nil
|
|
29
|
+
# when `x-ankusa-verification` is absent or empty.
|
|
30
|
+
HookHeaders = Data.define(
|
|
31
|
+
:id, :source, :tenant, :content_type, :dedupe_key, :replay_id, :idempotency_key,
|
|
32
|
+
:received_at, :body_sha256, :verification
|
|
33
|
+
) do
|
|
34
|
+
# `Data.define` made `idempotency_key` the reader of the parsed header; keep
|
|
35
|
+
# that value reachable here and let the helper below take over the name.
|
|
36
|
+
# The parsed header itself is still in `to_h[:idempotency_key]`.
|
|
37
|
+
alias_method :shipped_idempotency_key, :idempotency_key
|
|
38
|
+
private :shipped_idempotency_key
|
|
39
|
+
|
|
40
|
+
# The idempotency key for this delivery: the key Ankusa shipped in
|
|
41
|
+
# `x-ankusa-idempotency-key` when it is non-empty; for a delivery that
|
|
42
|
+
# predates the header, `tenant:source:dedupe_key` (tenant "default" when
|
|
43
|
+
# there is none) when a non-empty `dedupe_key` is set, else `id`.
|
|
44
|
+
#
|
|
45
|
+
# A replay keeps the original `id` and `dedupe_key` and adds `replay_id`, so
|
|
46
|
+
# by default a replay produces the same key as the delivery it replays and a
|
|
47
|
+
# receiver that already processed it drops it. Pass `include_replay: true`
|
|
48
|
+
# to reprocess replays instead.
|
|
49
|
+
def idempotency_key(include_replay: false)
|
|
50
|
+
IdempotencyKey.build(
|
|
51
|
+
shipped: shipped_idempotency_key, tenant: tenant, source: source,
|
|
52
|
+
dedupe_key: dedupe_key, id: id, replay_id: replay_id, include_replay: include_replay
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Whether the node verified this delivery at ingest and nothing flags it
|
|
57
|
+
# (see `Verification#trusted?`). A delivery with no `x-ankusa-verification`
|
|
58
|
+
# header (a node older than the field) is untrusted.
|
|
59
|
+
def trusted?
|
|
60
|
+
verification&.trusted? == true
|
|
61
|
+
end
|
|
62
|
+
end
|
|
10
63
|
|
|
11
64
|
# Raised by `Ankusa.parse_headers` when `x-ankusa-id` is absent or empty.
|
|
12
65
|
#
|
|
@@ -32,7 +85,81 @@ module Ankusa
|
|
|
32
85
|
id: hook_id,
|
|
33
86
|
source: lowered.fetch("x-ankusa-source", ""),
|
|
34
87
|
tenant: lowered["x-ankusa-tenant"],
|
|
35
|
-
content_type: lowered["content-type"]
|
|
88
|
+
content_type: lowered["content-type"],
|
|
89
|
+
dedupe_key: presence(lowered["x-ankusa-dedupe-key"]),
|
|
90
|
+
replay_id: presence(lowered["x-ankusa-replay-id"]),
|
|
91
|
+
idempotency_key: presence(lowered["x-ankusa-idempotency-key"]),
|
|
92
|
+
received_at: parse_received_at(lowered["x-ankusa-received-at"]),
|
|
93
|
+
body_sha256: parse_body_sha256(lowered["x-ankusa-body-sha256"]),
|
|
94
|
+
verification: parse_verification(lowered)
|
|
36
95
|
)
|
|
37
96
|
end
|
|
97
|
+
|
|
98
|
+
# An empty header counts as absent: Ankusa only sends these when it has a
|
|
99
|
+
# value, and a proxy that rewrites a header to "" should not fabricate one.
|
|
100
|
+
def self.presence(value)
|
|
101
|
+
(value.nil? || value.empty?) ? nil : value
|
|
102
|
+
end
|
|
103
|
+
private_class_method :presence
|
|
104
|
+
|
|
105
|
+
# An Integer for 1 to 15 decimal digits and nothing else, else nil.
|
|
106
|
+
def self.parse_received_at(value)
|
|
107
|
+
value.to_i if value&.match?(/\A[0-9]{1,15}\z/)
|
|
108
|
+
end
|
|
109
|
+
private_class_method :parse_received_at
|
|
110
|
+
|
|
111
|
+
# The value when it is exactly 64 lowercase hex characters, else nil.
|
|
112
|
+
def self.parse_body_sha256(value)
|
|
113
|
+
value if value&.match?(/\A[0-9a-f]{64}\z/)
|
|
114
|
+
end
|
|
115
|
+
private_class_method :parse_body_sha256
|
|
116
|
+
|
|
117
|
+
# `nil` when `x-ankusa-verification` is absent or empty; otherwise the
|
|
118
|
+
# header's value as `status`, and `flagged` true exactly when
|
|
119
|
+
# `x-ankusa-verification-flagged` is the literal string "true".
|
|
120
|
+
def self.parse_verification(lowered)
|
|
121
|
+
status = presence(lowered["x-ankusa-verification"])
|
|
122
|
+
return nil if status.nil?
|
|
123
|
+
|
|
124
|
+
Verification.new(status: status, flagged: lowered["x-ankusa-verification-flagged"] == "true")
|
|
125
|
+
end
|
|
126
|
+
private_class_method :parse_verification
|
|
127
|
+
|
|
128
|
+
# An accepted delivery: its identity (`HookHeaders`), and whether its
|
|
129
|
+
# signature verified (`false` only for an unsigned delivery `allow_unsigned`
|
|
130
|
+
# let through).
|
|
131
|
+
ReceivedDelivery = Data.define(:headers, :verified)
|
|
132
|
+
|
|
133
|
+
# Judges one HTTP-sink delivery: is it signed, and what is its identity.
|
|
134
|
+
#
|
|
135
|
+
# Ankusa's HTTP sink signs every delivery unless it is configured
|
|
136
|
+
# `allow_unsigned: true`, so a receiver is closed by default: without the
|
|
137
|
+
# sink's `secrets` (or an explicit `allow_unsigned: true`) it refuses every
|
|
138
|
+
# delivery, because any host that can reach the receiver's URL could
|
|
139
|
+
# otherwise inject hooks. With `secrets` the delivery MUST verify
|
|
140
|
+
# (`Ankusa.verify_signature`); `allow_unsigned` does not weaken that.
|
|
141
|
+
#
|
|
142
|
+
# `body` must be the raw bytes received. Raises `InvalidSignatureError`
|
|
143
|
+
# (answer 401) when there is no valid signature -- `invalid_secret` when no
|
|
144
|
+
# secret is configured and `allow_unsigned` is not set -- and
|
|
145
|
+
# `MissingHookIdError` (400) when `x-ankusa-id` is absent. The signature is
|
|
146
|
+
# judged first.
|
|
147
|
+
def self.receive_delivery(headers, body, secrets: [], allow_unsigned: false, tolerance_seconds: 300, now: nil)
|
|
148
|
+
configured = Array(secrets)
|
|
149
|
+
verified =
|
|
150
|
+
if !configured.empty?
|
|
151
|
+
verify_signature(headers, body, configured, tolerance_seconds: tolerance_seconds, now: now)
|
|
152
|
+
true
|
|
153
|
+
elsif allow_unsigned
|
|
154
|
+
false
|
|
155
|
+
else
|
|
156
|
+
raise InvalidSignatureError.new(
|
|
157
|
+
"no secret configured: deliveries are signed, so the receiver needs the sink's secret " \
|
|
158
|
+
"(or allow_unsigned: true)",
|
|
159
|
+
code: "invalid_secret"
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
ReceivedDelivery.new(headers: parse_headers(headers), verified: verified)
|
|
164
|
+
end
|
|
38
165
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ankusa-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- James Carr
|
|
@@ -24,9 +24,12 @@ files:
|
|
|
24
24
|
- lib/ankusa/claim_check.rb
|
|
25
25
|
- lib/ankusa/connection.rb
|
|
26
26
|
- lib/ankusa/error.rb
|
|
27
|
+
- lib/ankusa/lifecycle.rb
|
|
28
|
+
- lib/ankusa/message.rb
|
|
27
29
|
- lib/ankusa/routes.rb
|
|
28
30
|
- lib/ankusa/sdk.rb
|
|
29
31
|
- lib/ankusa/sdk/version.rb
|
|
32
|
+
- lib/ankusa/signature.rb
|
|
30
33
|
- lib/ankusa/sources.rb
|
|
31
34
|
- lib/ankusa/transport.rb
|
|
32
35
|
- lib/ankusa/webhook.rb
|