ankusa-sdk 0.5.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 +49 -1
- data/README.md +54 -12
- data/lib/ankusa/admin.rb +17 -4
- data/lib/ankusa/claim_check.rb +14 -7
- data/lib/ankusa/lifecycle.rb +112 -0
- data/lib/ankusa/message.rb +57 -8
- data/lib/ankusa/routes.rb +8 -5
- data/lib/ankusa/sdk/version.rb +1 -1
- data/lib/ankusa/sdk.rb +1 -0
- data/lib/ankusa/webhook.rb +93 -3
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 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,53 @@ 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
|
+
|
|
9
56
|
## [0.5.0] - 2026-10-14
|
|
10
57
|
|
|
11
58
|
### Added
|
|
@@ -74,6 +121,7 @@ this project follows [Semantic Versioning](https://semver.org/).
|
|
|
74
121
|
family, which carries `.status` and `.body` instead), so a consumer needs
|
|
75
122
|
exactly one bit to route a failure to dead-letter or retry.
|
|
76
123
|
|
|
77
|
-
[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
|
|
78
126
|
[0.5.0]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.3.0...sdk-ruby-v0.5.0
|
|
79
127
|
[0.3.0]: https://github.com/jamescarr/ankusa/releases/tag/sdk-ruby-v0.3.0
|
data/README.md
CHANGED
|
@@ -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
|
|
@@ -156,8 +148,54 @@ pass several during a rotation. `tolerance_seconds:` (default 300) bounds the
|
|
|
156
148
|
`webhook-timestamp` window. Failures are `Ankusa::InvalidSignatureError` with
|
|
157
149
|
`code` and `field`, never retryable.
|
|
158
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
|
+
|
|
159
186
|
## Consuming queue messages
|
|
160
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
|
+
|
|
161
199
|
Every message sink — RabbitMQ, Kafka, NATS, Redis — delivers one JSON message
|
|
162
200
|
per hook (the HTTP sink sends the original body plus `x-ankusa-*` headers
|
|
163
201
|
instead):
|
|
@@ -254,8 +292,9 @@ unfollowed redirect, a non-JSON success body, or unreachable; retryable).
|
|
|
254
292
|
## Admin client
|
|
255
293
|
|
|
256
294
|
The operator API on `admin.port` (default 4002): health, Prometheus metrics, the
|
|
257
|
-
redacted config, the DLQ, replay jobs, and the quarantine
|
|
258
|
-
`operations`, `dlq`, `replays`, and `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`.
|
|
259
298
|
|
|
260
299
|
```ruby
|
|
261
300
|
require "ankusa/sdk"
|
|
@@ -265,6 +304,7 @@ admin = Ankusa::AdminClient.new(ENV.fetch("ADMIN_URL", "http://localhost:4002"))
|
|
|
265
304
|
admin.health # {"status" => "ok", "instance" => ..., "roles" => [...]}
|
|
266
305
|
admin.list_dead_letters({"limit" => 10}) # {"total" => ..., "entries" => [...]}
|
|
267
306
|
admin.list_quarantined
|
|
307
|
+
admin.list_audit({"tenant" => "acme", "limit" => 50}) # {"entries" => [...], "next_cursor" => ...}
|
|
268
308
|
|
|
269
309
|
job = admin.create_replay({"kind" => "dlq", "source_id" => "stripe", "rate" => 500})
|
|
270
310
|
admin.get_replay(job["id"])
|
|
@@ -275,10 +315,12 @@ admin.update_replay(job["id"], {"state" => "paused"}) # resume, pause or cance
|
|
|
275
315
|
`create_replay` is idempotent for retries: a second POST of the same filter
|
|
276
316
|
while the job is `running`/`paused` returns the existing job (`200`) instead of
|
|
277
317
|
starting another (`202`). `list_replays` returns `{"replays" => [...]}` exactly
|
|
278
|
-
as the API sends it.
|
|
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`.
|
|
279
321
|
|
|
280
322
|
Methods: `health`, `metrics` (Prometheus text), `config`, `list_dead_letters`,
|
|
281
|
-
`create_replay`, `get_replay`, `list_replays`, `update_replay`,
|
|
323
|
+
`list_audit`, `create_replay`, `get_replay`, `list_replays`, `update_replay`,
|
|
282
324
|
`list_quarantined`. Failures are `AdminError` subclasses: `RoleNotEnabledError`
|
|
283
325
|
(409 `role_not_enabled`, carrying `role`), `AdminRejectedError` (any other 4xx —
|
|
284
326
|
a missing replay is a 404 with `code` `replay_not_found`), and
|
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,6 +83,15 @@ module Ankusa
|
|
|
81
83
|
json(request("GET", "/v1/dlq", query: params))
|
|
82
84
|
end
|
|
83
85
|
|
|
86
|
+
# GET /v1/audit -> a page of the audit trail, newest first.
|
|
87
|
+
#
|
|
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
|
+
|
|
84
95
|
# POST /v1/replays -> the created Replay, as a Hash.
|
|
85
96
|
#
|
|
86
97
|
# `spec` is the replay spec: `{"kind" => "dlq", ...}`,
|
|
@@ -153,10 +164,12 @@ module Ankusa
|
|
|
153
164
|
end
|
|
154
165
|
|
|
155
166
|
if status >= 400 && status < 500
|
|
167
|
+
scope = body.is_a?(Hash) ? body["scope"] : nil
|
|
156
168
|
raise AdminRejectedError.new(
|
|
157
169
|
"admin listener rejected the request (#{status}): #{body.inspect}",
|
|
158
170
|
status: status,
|
|
159
|
-
code: code
|
|
171
|
+
code: code,
|
|
172
|
+
scope: scope
|
|
160
173
|
)
|
|
161
174
|
end
|
|
162
175
|
|
data/lib/ankusa/claim_check.rb
CHANGED
|
@@ -58,24 +58,31 @@ module Ankusa
|
|
|
58
58
|
# Mirrors `#/components/schemas/Ref` in priv/openapi/claim_check.v1.yaml --
|
|
59
59
|
# keep the two in sync. A ref is one string:
|
|
60
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.
|
|
61
65
|
#
|
|
62
66
|
# \A...\z, never ^...$: Ruby's ^ and $ are line anchors, so a ref ending in a
|
|
63
67
|
# newline would otherwise parse.
|
|
64
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/
|
|
65
71
|
|
|
66
72
|
SHA256_PATTERN = /\A[0-9a-f]{64}\z/
|
|
67
73
|
|
|
68
74
|
# Parses a claim-check ref (the `claim` field of a queue message) into the
|
|
69
|
-
# tenant id, claim id, and GET /v1/claims/{tenant_id}/{claim_id} path
|
|
70
|
-
#
|
|
71
|
-
# 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.
|
|
72
78
|
def self.parse_claim_ref(ref)
|
|
73
|
-
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
|
|
74
80
|
raise InvalidClaimRefError, "invalid claim-check ref: #{ref}" if match.nil?
|
|
75
81
|
|
|
76
|
-
tenant_id = match
|
|
77
|
-
|
|
78
|
-
|
|
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)
|
|
79
86
|
end
|
|
80
87
|
|
|
81
88
|
# Redeem claim-check refs against a deployment's claim-check gateway.
|
|
@@ -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
|
data/lib/ankusa/message.rb
CHANGED
|
@@ -47,17 +47,32 @@ module Ankusa
|
|
|
47
47
|
end
|
|
48
48
|
private_constant :IdempotencyKey
|
|
49
49
|
|
|
50
|
-
#
|
|
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
|
|
51
60
|
# claim form, where the bytes are fetched with `Ankusa::ClaimCheckClient`);
|
|
52
61
|
# `claim` holds the ref string in that case. `headers` is the forwarded
|
|
53
|
-
# provider request headers, `{}` when the message carried none.
|
|
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).
|
|
54
68
|
#
|
|
55
69
|
# `idempotency_key` is the one value a consumer should dedupe on, as
|
|
56
70
|
# `Message#idempotency_key` (the field is nil only for a message from a node
|
|
57
71
|
# that predates it); see `Ankusa.decode_message` for the decode rules.
|
|
58
72
|
Message = Data.define(
|
|
59
73
|
:v, :id, :source_id, :tenant_id, :received_at, :content_type, :size,
|
|
60
|
-
:body, :claim, :sha256, :dedupe_key, :replay_id, :idempotency_key, :headers
|
|
74
|
+
:body, :claim, :sha256, :dedupe_key, :replay_id, :idempotency_key, :headers, :verification,
|
|
75
|
+
:traceparent, :tracestate, :instance, :region
|
|
61
76
|
) do
|
|
62
77
|
# `Data.define` made `idempotency_key` the reader of the decoded field; keep
|
|
63
78
|
# that value reachable here and let the helper below take over the name.
|
|
@@ -80,14 +95,24 @@ module Ankusa
|
|
|
80
95
|
dedupe_key: dedupe_key, id: id, replay_id: replay_id, include_replay: include_replay
|
|
81
96
|
)
|
|
82
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
|
|
83
105
|
end
|
|
84
106
|
|
|
85
107
|
# The optional string-keyed fields that may be a string, null, or absent.
|
|
86
108
|
# Checked in this order, so the first offending key is the one reported.
|
|
87
|
-
MESSAGE_STRING_FIELDS = %w[
|
|
109
|
+
MESSAGE_STRING_FIELDS = %w[
|
|
110
|
+
tenant_id content_type dedupe_key replay_id idempotency_key traceparent tracestate instance region
|
|
111
|
+
].freeze
|
|
88
112
|
private_constant :MESSAGE_STRING_FIELDS
|
|
89
113
|
|
|
90
|
-
# Decodes one
|
|
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
|
|
91
116
|
# a `Message`, applying every rule in "Consuming queue messages" of the SDK
|
|
92
117
|
# README in order; the first failure wins.
|
|
93
118
|
#
|
|
@@ -107,7 +132,7 @@ module Ankusa
|
|
|
107
132
|
end
|
|
108
133
|
|
|
109
134
|
version = parsed["v"]
|
|
110
|
-
unless version.is_a?(Integer) && version
|
|
135
|
+
unless version.is_a?(Integer) && version.between?(MIN_SUPPORTED_VERSION, MAX_SUPPORTED_VERSION)
|
|
111
136
|
raise InvalidMessageError.new("unsupported message version: #{version.inspect}", code: "unsupported_version")
|
|
112
137
|
end
|
|
113
138
|
|
|
@@ -135,6 +160,8 @@ module Ankusa
|
|
|
135
160
|
valid_sha256 = sha256.nil? || (sha256.is_a?(String) && SHA256_PATTERN.match?(sha256))
|
|
136
161
|
invalid_field("sha256") unless valid_sha256
|
|
137
162
|
|
|
163
|
+
verification = decode_verification(parsed["verification"])
|
|
164
|
+
|
|
138
165
|
body_base64 = parsed["body_base64"]
|
|
139
166
|
claim = parsed["claim"]
|
|
140
167
|
|
|
@@ -172,7 +199,7 @@ module Ankusa
|
|
|
172
199
|
end
|
|
173
200
|
|
|
174
201
|
Message.new(
|
|
175
|
-
v:
|
|
202
|
+
v: version,
|
|
176
203
|
id: id,
|
|
177
204
|
source_id: source_id,
|
|
178
205
|
tenant_id: tenant_id,
|
|
@@ -185,10 +212,32 @@ module Ankusa
|
|
|
185
212
|
dedupe_key: parsed["dedupe_key"],
|
|
186
213
|
replay_id: parsed["replay_id"],
|
|
187
214
|
idempotency_key: parsed["idempotency_key"],
|
|
188
|
-
headers: headers
|
|
215
|
+
headers: headers,
|
|
216
|
+
verification: verification,
|
|
217
|
+
traceparent: parsed["traceparent"],
|
|
218
|
+
tracestate: parsed["tracestate"],
|
|
219
|
+
instance: parsed["instance"],
|
|
220
|
+
region: parsed["region"]
|
|
189
221
|
)
|
|
190
222
|
end
|
|
191
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
|
+
|
|
192
241
|
def self.invalid_field(field)
|
|
193
242
|
raise InvalidMessageError.new("invalid message field: #{field}", code: "invalid_field", field: field)
|
|
194
243
|
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
data/lib/ankusa/webhook.rb
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
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
|
#
|
|
@@ -8,9 +21,15 @@ module Ankusa
|
|
|
8
21
|
# nil when the delivery had no such header (a tenant only travels when the
|
|
9
22
|
# source has one; `dedupe_key` only when the source declared a dedupe key;
|
|
10
23
|
# `replay_id` only when the delivery is a replay; `idempotency_key` is
|
|
11
|
-
# missing only from a sender that predates the header).
|
|
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.
|
|
12
30
|
HookHeaders = Data.define(
|
|
13
|
-
:id, :source, :tenant, :content_type, :dedupe_key, :replay_id, :idempotency_key
|
|
31
|
+
:id, :source, :tenant, :content_type, :dedupe_key, :replay_id, :idempotency_key,
|
|
32
|
+
:received_at, :body_sha256, :verification
|
|
14
33
|
) do
|
|
15
34
|
# `Data.define` made `idempotency_key` the reader of the parsed header; keep
|
|
16
35
|
# that value reachable here and let the helper below take over the name.
|
|
@@ -33,6 +52,13 @@ module Ankusa
|
|
|
33
52
|
dedupe_key: dedupe_key, id: id, replay_id: replay_id, include_replay: include_replay
|
|
34
53
|
)
|
|
35
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
|
|
36
62
|
end
|
|
37
63
|
|
|
38
64
|
# Raised by `Ankusa.parse_headers` when `x-ankusa-id` is absent or empty.
|
|
@@ -62,7 +88,10 @@ module Ankusa
|
|
|
62
88
|
content_type: lowered["content-type"],
|
|
63
89
|
dedupe_key: presence(lowered["x-ankusa-dedupe-key"]),
|
|
64
90
|
replay_id: presence(lowered["x-ankusa-replay-id"]),
|
|
65
|
-
idempotency_key: presence(lowered["x-ankusa-idempotency-key"])
|
|
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)
|
|
66
95
|
)
|
|
67
96
|
end
|
|
68
97
|
|
|
@@ -72,4 +101,65 @@ module Ankusa
|
|
|
72
101
|
(value.nil? || value.empty?) ? nil : value
|
|
73
102
|
end
|
|
74
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
|
|
75
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,6 +24,7 @@ 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
|
|
27
28
|
- lib/ankusa/message.rb
|
|
28
29
|
- lib/ankusa/routes.rb
|
|
29
30
|
- lib/ankusa/sdk.rb
|