basecradle 0.10.1 → 0.10.3
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 +117 -0
- data/README.md +79 -3
- data/lib/basecradle/client.rb +20 -6
- data/lib/basecradle/errors.rb +9 -4
- data/lib/basecradle/serialization.rb +52 -8
- data/lib/basecradle/version.rb +1 -1
- data/lib/basecradle/webhooks.rb +45 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b1fc584abdde2edfd9233ea937bf054ba6fb672708b6c815014d46a1dc4d59c2
|
|
4
|
+
data.tar.gz: bc53a3e49ad9eb40fd21e5fb2fe424d054738359a30eb2c137a26c8a5f634d27
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c603f01c320e465db82dc376ad78cfca4d52c63db107580be7366d2a2c3b79f99e1d5b3f356917bd1526b9f63bb294747cc218a1e7cf71e8fc22caa41ccc4b34
|
|
7
|
+
data.tar.gz: 401d5b9b58f5dc726fb3bc457180954b582e077b8b0aa224c0f92513a39c4122bac16ea56dfff4746e44e2b252cc88ab8a6cf12b178d989ee1eed5b2caf1d02a
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,121 @@ section**. The newest heading is always the version `lib/basecradle/version.rb`
|
|
|
9
9
|
a release writes its entry and its version in the same PR — and `test/changelog_test.rb`
|
|
10
10
|
fails CI if the two ever disagree.
|
|
11
11
|
|
|
12
|
+
## [0.10.3] - 2026-09-30
|
|
13
|
+
|
|
14
|
+
### Security
|
|
15
|
+
|
|
16
|
+
- **`Marshal.dump` and `to_yaml` no longer emit a `Client`'s token**
|
|
17
|
+
([#205](https://github.com/basecradle/basecradle-ruby/issues/205)). 0.10.1 shut the
|
|
18
|
+
JSON door on a client; `Marshal` and Psych walk instance variables directly, consult no
|
|
19
|
+
`as_json`, and need no ActiveSupport to do it — so both still wrote the raw `bc_uat_`
|
|
20
|
+
credential into their output. They are the worse pair, because they are what puts a
|
|
21
|
+
token *at rest*: ActiveSupport's cache stores marshal what you write, so
|
|
22
|
+
`Rails.cache.write("bc", bc)` landed it in Redis, memcached or a file store; a
|
|
23
|
+
Marshal-backed session landed it in the session; Delayed::Job YAMLs its handler into
|
|
24
|
+
the database. (Queue backends that serialize arguments as **JSON** — ActiveJob and
|
|
25
|
+
Sidekiq among them — go through `to_json`, and so were already closed in 0.10.1.)
|
|
26
|
+
`Marshal.dump(bc)`, `bc.to_yaml`,
|
|
27
|
+
`YAML.dump(conn: bc)` and the `Marshal.load(Marshal.dump(bc))` deep-copy idiom now all
|
|
28
|
+
raise `BaseCradle::NotSerializableError`, the same typed error with the same message
|
|
29
|
+
the JSON door already raised. Long-standing and latent; no token is known to have been
|
|
30
|
+
emitted.
|
|
31
|
+
- **The collection door was the wider one, not the narrower one.** Through JSON, a
|
|
32
|
+
collection never reached the token — ActiveSupport's `Enumerable#as_json` shadows
|
|
33
|
+
`Object#as_json`, so `bc.messages.to_json` serialized fetched records, never the client
|
|
34
|
+
held in an ivar. `Marshal` and Psych have no such shadow, so
|
|
35
|
+
**`Marshal.dump(bc.messages)` reached the credential where `bc.messages.to_json` never
|
|
36
|
+
did** — and a query is the likelier of the two to be handed to a cache. Every lazy
|
|
37
|
+
collection (`bc.timelines`, `bc.messages`, `timeline.tasks`, any `.filter(...)`, the
|
|
38
|
+
`Paginator` behind them) now refuses through all four doors; the refusals are on the
|
|
39
|
+
shared `NotSerializable` mixin, so a resource added later is covered by construction.
|
|
40
|
+
**`.to_a` is only half the remedy for a dumper** — the array it hands back is full of
|
|
41
|
+
models that each hold the client, so `Marshal.dump(bc.messages.to_a)` lands back on the
|
|
42
|
+
same error. Dump `bc.messages.to_a.map(&:to_h)`, which the refusal now says.
|
|
43
|
+
- **Anything holding a client refuses too — models included, and that is the fix.** Both
|
|
44
|
+
walkers recurse, so `Marshal.dump(message)` and `message.to_yaml` reached the client's
|
|
45
|
+
token where `message.to_json` (which serializes the wire record) never did. They now
|
|
46
|
+
raise, naming `BaseCradle::Client` as what was reached. **If you cache or enqueue
|
|
47
|
+
models, cache the record instead** — `message.to_h` is the wire `Hash`, holds no
|
|
48
|
+
client, and marshals exactly as it always did; rebuild with
|
|
49
|
+
`BaseCradle::Message.new(record, client: bc)` when you need the verbs back. Nothing
|
|
50
|
+
else changed: `to_json`, `as_json`, `to_h`, `inspect`, every field reader and
|
|
51
|
+
`dup`/`clone` (which do not go through `Marshal`) are untouched. This matches the
|
|
52
|
+
Python SDK, whose `__reduce__` refusal says the same thing — *“Nothing holding a client
|
|
53
|
+
can be serialized either … copy the record's data instead.”*
|
|
54
|
+
|
|
55
|
+
### Known limitation
|
|
56
|
+
|
|
57
|
+
- **YAML reaches the `Enumerator` escape that 0.10.1 documented for JSON, on plain
|
|
58
|
+
Ruby.** Every refusal here is on this SDK's own objects; an `Enumerator` you build from
|
|
59
|
+
a collection (`bc.messages.each`, `bc.messages.lazy`) is a plain Ruby object this SDK
|
|
60
|
+
does not own and may not patch. Psych iterates one to dump it — with **no
|
|
61
|
+
ActiveSupport required**, where the `to_json` version is inert without it — so
|
|
62
|
+
`bc.messages.each.to_yaml` fires the page-by-page GET loop and *then* raises
|
|
63
|
+
`NotSerializableError` on the first record, which holds the client: the requests are
|
|
64
|
+
spent and no document comes out. `Marshal` is the exception — Ruby itself refuses to
|
|
65
|
+
dump an `Enumerator` at all. No token is exposed through any of them. Call `.first(n)`
|
|
66
|
+
or `.to_a` before a renderer, and `.map(&:to_h)` too before a dumper.
|
|
67
|
+
- **Four doors is every hook Ruby gives a library**, not every way bytes can be made. A
|
|
68
|
+
serializer that reads instance variables directly rather than through `as_json`,
|
|
69
|
+
`to_json`, `marshal_dump` or `encode_with` is outside what this SDK can intercept.
|
|
70
|
+
Keep a client out of one.
|
|
71
|
+
|
|
72
|
+
### Migrating
|
|
73
|
+
|
|
74
|
+
Nothing to change unless you `Marshal.dump` or `to_yaml` something that holds a client.
|
|
75
|
+
If you do:
|
|
76
|
+
|
|
77
|
+
- **Caching or enqueuing a model** — `Rails.cache.write(key, message)`,
|
|
78
|
+
`Rails.cache.fetch(key) { bc.messages.get(id) }` — now raises
|
|
79
|
+
`BaseCradle::NotSerializableError` where it used to write your token to the store.
|
|
80
|
+
Cache `message.to_h`, the wire `Hash`, and rebuild with
|
|
81
|
+
`BaseCradle::Message.new(record, client: bc)` if you need the verbs back.
|
|
82
|
+
- **Caching or enqueuing a client or a collection** — serialize nothing; build a client
|
|
83
|
+
where you need one (`BaseCradle::Client.new(token)`), moving the token only through
|
|
84
|
+
whatever you already trust with secrets. For a collection, `.to_a` / `.first(n)` is
|
|
85
|
+
enough for JSON but **not** for a dumper — use `bc.messages.to_a.map(&:to_h)`, since
|
|
86
|
+
the models in that array each hold the client.
|
|
87
|
+
- **Deep-copying with `Marshal.load(Marshal.dump(x))`** — refuses at the dump for the
|
|
88
|
+
same objects. `dup` and `clone` do not go through `Marshal` and are unchanged.
|
|
89
|
+
|
|
90
|
+
If any of these ran in production against a real store, treat the token as disclosed and
|
|
91
|
+
rotate it: `bc.sessions` lists your credentials and `session.revoke` retires one.
|
|
92
|
+
|
|
93
|
+
## [0.10.2] - 2026-09-30
|
|
94
|
+
|
|
95
|
+
### Security
|
|
96
|
+
|
|
97
|
+
- **`WebhookEventHeaders` no longer prints header values when inspected**
|
|
98
|
+
([#206](https://github.com/basecradle/basecradle-ruby/issues/206)). An inbound
|
|
99
|
+
delivery's headers are the *sender's*, stored verbatim by the platform, so one of them
|
|
100
|
+
may be the sender's own credential — a POST authenticated to an ingest URL carries its
|
|
101
|
+
`Authorization` or `X-Api-Key` right there. `WebhookEventHeaders` is a `Hash`
|
|
102
|
+
descendant and inherited `Hash`'s render, which prints every pair, so
|
|
103
|
+
`logger.debug(event.content.headers)` wrote **another party's secret** into your logs.
|
|
104
|
+
`inspect`, `to_s` and `pp` now render the header names alone, sorted, matching the rule
|
|
105
|
+
every other object in this SDK already followed (`ApiObject#inspect` prints field
|
|
106
|
+
names, never values):
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
#<BaseCradle::WebhookEventHeaders Authorization, Content-Type, X-Api-Key>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Only the human-facing render changed.** Every read is untouched and still wire-exact:
|
|
113
|
+
the case-folded lookup, `fetch`, `each`, `keys`, `to_h`, `to_json`, `==`. If you were
|
|
114
|
+
relying on `inspect` to see values, use `to_h` — which is what it was always for. No
|
|
115
|
+
credential of yours was exposed; the exposure was of whoever sent you the webhook, so
|
|
116
|
+
if you have logged inbound deliveries at debug level, treat those senders' headers as
|
|
117
|
+
disclosed.
|
|
118
|
+
- **Two ways a header value still reaches a log, both by design.** `to_json`/`as_json`
|
|
119
|
+
emit the delivery verbatim — that is what a webhook record *is* — so
|
|
120
|
+
`render json: event` and `logger.info(event.to_json)` still write the sender's headers
|
|
121
|
+
in full. And converting away from the type gives up the redaction exactly as it gives up
|
|
122
|
+
the case-folding: `slice`, `except`, `select`, `transform_values` and `to_h` each hand
|
|
123
|
+
back a plain `Hash`, whose `inspect` prints pairs. Only `merge` and `dup` keep the type,
|
|
124
|
+
and so the redaction. Changing what a webhook record *serializes* as is a separate
|
|
125
|
+
decision and is not made here.
|
|
126
|
+
|
|
12
127
|
## [0.10.1] - 2026-09-30
|
|
13
128
|
|
|
14
129
|
### Security
|
|
@@ -450,6 +565,8 @@ the Python SDK's behavior in idiomatic Ruby. Zero runtime dependencies.
|
|
|
450
565
|
- **Quality bars** — a README-as-tested-doc harness (every example runs against a mocked
|
|
451
566
|
API) and a spec drift-guard (CI fails if the live API grows beyond the SDK).
|
|
452
567
|
|
|
568
|
+
[0.10.3]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.3
|
|
569
|
+
[0.10.2]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.2
|
|
453
570
|
[0.10.1]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.1
|
|
454
571
|
[0.10.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.0
|
|
455
572
|
[0.9.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.9.0
|
data/README.md
CHANGED
|
@@ -220,6 +220,19 @@ fallback-less `fetch` raise `KeyError` naming the headers that did arrive, while
|
|
|
220
220
|
and the methods that reshape it (`slice`, `except`, `select`) work on those stored names,
|
|
221
221
|
case-sensitively.
|
|
222
222
|
|
|
223
|
+
What it does **not** show you is the values. `inspect`, `to_s` and `pp` print the header
|
|
224
|
+
names alone — `#<BaseCradle::WebhookEventHeaders Authorization, Content-Type>` — because
|
|
225
|
+
these headers are the **sender's**, and a sender that authenticates its POST to your
|
|
226
|
+
ingest URL carries its credential in one of them. So `logger.debug(event.content.headers)`
|
|
227
|
+
cannot write another party's secret into your logs. Every read is unaffected; `to_h` is
|
|
228
|
+
how you look at values.
|
|
229
|
+
|
|
230
|
+
Two things do still show them, deliberately. `to_json` emits the delivery verbatim — that
|
|
231
|
+
is what the record is — so `render json: event` and `logger.info(event.to_json)` write the
|
|
232
|
+
headers in full. And converting away from the type gives up the redaction exactly as it
|
|
233
|
+
gives up the case-folding: `slice`, `except`, `select`, `transform_values` and `to_h` hand
|
|
234
|
+
back a plain `Hash`, which prints pairs. `merge` and `dup` keep the type, and keep both.
|
|
235
|
+
|
|
223
236
|
This holds however you reach the record. A timeline item's `content` is typed by the
|
|
224
237
|
item's own `type`, so a `webhook_event` row of `timeline.items` is a `WebhookEventContent`
|
|
225
238
|
just like one from `bc.webhook_events` — `item.content.headers["X-GitHub-Delivery"]` folds
|
|
@@ -362,10 +375,15 @@ something Rails renders comes out right too. Three caveats worth knowing:
|
|
|
362
375
|
## What does not serialize: clients and collections
|
|
363
376
|
|
|
364
377
|
Two things in this SDK are *not* records, and serializing either raises
|
|
365
|
-
`BaseCradle::NotSerializableError` rather than emitting something
|
|
378
|
+
`BaseCradle::NotSerializableError` rather than emitting something — and under `Marshal`
|
|
379
|
+
and YAML, so does anything *holding* one, models included (see below). Four doors refuse:
|
|
380
|
+
`to_json`, `as_json`, `Marshal.dump` and `to_yaml` — every hook Ruby gives a library to
|
|
381
|
+
intercept. A serializer that reads instance variables directly instead of through those
|
|
382
|
+
hooks is outside what any library can stop, so keep a client out of one.
|
|
366
383
|
|
|
367
384
|
```ruby
|
|
368
385
|
require "basecradle"
|
|
386
|
+
require "yaml"
|
|
369
387
|
|
|
370
388
|
bc = BaseCradle::Client.new
|
|
371
389
|
|
|
@@ -381,6 +399,18 @@ rescue BaseCradle::NotSerializableError => e
|
|
|
381
399
|
puts e.message.include?(".to_a") # => true
|
|
382
400
|
end
|
|
383
401
|
|
|
402
|
+
begin
|
|
403
|
+
Marshal.dump(bc) # and Rails.cache.write("bc", bc), a job argument
|
|
404
|
+
rescue BaseCradle::NotSerializableError => e
|
|
405
|
+
puts e.message.include?("bc_uat_") # => true
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
begin
|
|
409
|
+
bc.messages.to_yaml # YAML walks the ivars, and reaches the client
|
|
410
|
+
rescue BaseCradle::NotSerializableError => e
|
|
411
|
+
puts e.message.include?(".to_a") # => true
|
|
412
|
+
end
|
|
413
|
+
|
|
384
414
|
puts bc.messages.first(20).to_json # serialize the page you actually asked for
|
|
385
415
|
```
|
|
386
416
|
|
|
@@ -397,10 +427,50 @@ walk — but a client's instance variables are eight collections, so serializing
|
|
|
397
427
|
ran those loops too, on its way to the credential.) Call `.to_a` or `.first(n)` yourself
|
|
398
428
|
and serialize that, so how much you fetch is a visible act in your code.
|
|
399
429
|
|
|
400
|
-
|
|
430
|
+
**`Marshal` and YAML are the same refusal for a different reason.** Both walk instance
|
|
431
|
+
variables directly, so neither needed ActiveSupport to reach the token — and neither is
|
|
432
|
+
shadowed the way `Enumerable#as_json` shadowed the ivar walk, so `Marshal.dump(bc.messages)`
|
|
433
|
+
reached the credential where `bc.messages.to_json` never did. They are also where a leaked
|
|
434
|
+
token stops being a log line and becomes a token *at rest*: ActiveSupport's cache stores
|
|
435
|
+
marshal what you write, so `Rails.cache.write("bc", bc)` landed it in Redis, memcached or
|
|
436
|
+
a file; a Marshal-backed session landed it in the session; Delayed::Job YAMLs its handler
|
|
437
|
+
into the database. `Marshal.load(Marshal.dump(x))`, the deep-copy idiom, refuses at the
|
|
438
|
+
dump. (Queue backends that serialize arguments as **JSON** — ActiveJob and Sidekiq among
|
|
439
|
+
them — go through `to_json`, and so were already closed in 0.10.1.)
|
|
440
|
+
|
|
441
|
+
**Anything *holding* a client refuses too, models included** — both walkers recurse, so
|
|
442
|
+
`Marshal.dump(message)` reaches the client and raises naming `BaseCradle::Client`. That is
|
|
443
|
+
the fix, not a limitation: before, it emitted the token. It is also why **`.to_a` is only
|
|
444
|
+
half the remedy for a dumper**: the array it hands back is full of models that each hold
|
|
445
|
+
the client, so `Marshal.dump(bc.messages.to_a)` lands straight back on the same error.
|
|
446
|
+
Dump the wire records — which is what you wanted in a cache anyway:
|
|
447
|
+
|
|
448
|
+
```ruby
|
|
449
|
+
require "basecradle"
|
|
450
|
+
|
|
451
|
+
bc = BaseCradle::Client.new
|
|
452
|
+
message = bc.messages.first
|
|
453
|
+
|
|
454
|
+
record = message.to_h # the wire Hash, no client in it
|
|
455
|
+
puts Marshal.load(Marshal.dump(record)) == record # => true
|
|
456
|
+
|
|
457
|
+
page = bc.messages.first(20).map(&:to_h) # a whole page, no client in it
|
|
458
|
+
puts Marshal.load(Marshal.dump(page)) == page # => true
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
Rebuild a model from a cached record with `BaseCradle::Message.new(record, client: bc)`
|
|
462
|
+
when you need its verbs back.
|
|
463
|
+
|
|
464
|
+
Every refusal happens before any HTTP — but they are on the SDK's own objects. An
|
|
401
465
|
`Enumerator` you built from one is a plain Ruby object this SDK does not own, so
|
|
402
466
|
`render json: { recent: bc.messages.lazy }` and `bc.messages.each.to_json` still page the
|
|
403
|
-
whole resource.
|
|
467
|
+
whole resource. **YAML reaches that escape on plain Ruby**, where the `to_json` version
|
|
468
|
+
needs ActiveSupport: Psych iterates an `Enumerator` to dump it, so
|
|
469
|
+
`bc.messages.each.to_yaml` fires the page-by-page GET loop and *then* raises
|
|
470
|
+
`NotSerializableError` on the first record (which holds the client) — the requests are
|
|
471
|
+
spent, and no document comes out. (`Marshal` is the exception: Ruby itself refuses to
|
|
472
|
+
dump an `Enumerator` at all.) No token is exposed through any of them. Call `.first(n)`
|
|
473
|
+
or `.to_a` before handing a query to a renderer, and `.map(&:to_h)` too before a dumper.
|
|
404
474
|
|
|
405
475
|
`bc.inspect` and `"#{bc}"` are redacted for the same reason — Ruby's default `inspect`
|
|
406
476
|
dumps every instance variable, which would print the token into every exception message
|
|
@@ -426,6 +496,12 @@ CI additionally lints the workflow files with [actionlint](https://github.com/rh
|
|
|
426
496
|
both and run `actionlint` from the repo root — CI pins actionlint by version and SHA256 in
|
|
427
497
|
`.github/workflows/ci.yml`; match that version to see what CI sees.
|
|
428
498
|
|
|
499
|
+
Bare `actionlint` covers slightly more than CI blocks on. It lints everything under
|
|
500
|
+
`.github/workflows`, including `needs-human-alert.yml` and `dependabot-auto-merge.yml`,
|
|
501
|
+
which are shared verbatim from the BaseCradle core repo and are not this repo's to edit;
|
|
502
|
+
CI lints those two advisory-only, so a finding in them is reported upstream rather than
|
|
503
|
+
failing the build. A finding in any other workflow does fail it.
|
|
504
|
+
|
|
429
505
|
## Contributing
|
|
430
506
|
|
|
431
507
|
Human and AI contributors work under identical rules here: branch → PR → green CI → merge. See [`CLAUDE.md`](CLAUDE.md) for the project conventions and the issues for the roadmap.
|
data/lib/basecradle/client.rb
CHANGED
|
@@ -243,14 +243,28 @@ module BaseCradle
|
|
|
243
243
|
|
|
244
244
|
private
|
|
245
245
|
|
|
246
|
-
# Why +to_json+ / +as_json+
|
|
247
|
-
# a record, and the thing it would
|
|
246
|
+
# Why every serializer refuses — +to_json+ / +as_json+, +Marshal.dump+, +to_yaml+
|
|
247
|
+
# (see BaseCradle::NotSerializable). A client is not a record, and the thing it would
|
|
248
|
+
# emit is a live credential.
|
|
249
|
+
#
|
|
250
|
+
# The last sentence is for the caller who never named a client: Marshal and Psych
|
|
251
|
+
# recurse, so dumping a model or a resource reaches the client it holds and lands
|
|
252
|
+
# here. Telling that caller only about clients would be telling them about an object
|
|
253
|
+
# they did not pass — the Python SDK's +__reduce__+ is worded for the same reason
|
|
254
|
+
# ("copying a resource or a record reaches this too — copy the record's data").
|
|
255
|
+
#
|
|
256
|
+
# And it has to name +to_h+, because the first remedy does not hold through those two
|
|
257
|
+
# doors: a model serializes as its record through JSON, but <tt>Marshal.dump(bc.me)</tt>
|
|
258
|
+
# walks to the client again. Advice that fails through the door the reader just used
|
|
259
|
+
# is worse than none.
|
|
248
260
|
def serialization_refusal
|
|
249
261
|
"#{self.class} is a connection holding your bc_uat_ token, not a record. " \
|
|
250
|
-
"Serializing it writes the raw credential into whatever you were rendering
|
|
251
|
-
"logging, and a token in a log is a token to rotate.
|
|
252
|
-
"meant instead (bc.me, a timeline, a message), or
|
|
253
|
-
"connection itself."
|
|
262
|
+
"Serializing it writes the raw credential into whatever you were rendering, " \
|
|
263
|
+
"logging or storing, and a token in a log or a cache is a token to rotate. " \
|
|
264
|
+
"Serialize the record you meant instead (bc.me, a timeline, a message), or " \
|
|
265
|
+
"bc.base_url to name the connection itself. Marshal and YAML reach a client " \
|
|
266
|
+
"through anything holding one, models included, so dump the wire record rather " \
|
|
267
|
+
"than the object: model.to_h holds no client."
|
|
254
268
|
end
|
|
255
269
|
|
|
256
270
|
# Record what the mint response said about the credential just issued. Private: only
|
data/lib/basecradle/errors.rb
CHANGED
|
@@ -30,10 +30,15 @@ module BaseCradle
|
|
|
30
30
|
# this response form). The SDK raises rather than return an ambiguous nil.
|
|
31
31
|
class MissingFieldError < Error; end
|
|
32
32
|
|
|
33
|
-
# A Client or a collection resource was serialized
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
33
|
+
# A Client or a collection resource was serialized — +to_json+ / +as_json+,
|
|
34
|
+
# +Marshal.dump+, or +to_yaml+ / +YAML.dump+. Neither is a record: a Client holds your
|
|
35
|
+
# bearer token, and a collection is a lazy query that holds the client and would page
|
|
36
|
+
# the whole API from inside your renderer. The SDK refuses loudly rather than emit a
|
|
37
|
+
# credential or a heap address — see BaseCradle::NotSerializable.
|
|
38
|
+
#
|
|
39
|
+
# It is also how a caller who serialized *neither* gets here: +Marshal+ and Psych
|
|
40
|
+
# recurse, so dumping a model reaches the client it holds and raises this, naming
|
|
41
|
+
# BaseCradle::Client. Dump the record's own data, +model.to_h+, which holds no client.
|
|
37
42
|
class NotSerializableError < Error; end
|
|
38
43
|
|
|
39
44
|
# The request never got an API response (DNS failure, refused connection, timeout).
|
|
@@ -18,6 +18,17 @@ module BaseCradle
|
|
|
18
18
|
# Without ActiveSupport the same calls were merely useless: +"#<BaseCradle::Client:0x…>"+,
|
|
19
19
|
# a heap address that differs every run.
|
|
20
20
|
#
|
|
21
|
+
# +Marshal+ and Psych are that same act through two doors ActiveSupport never touched.
|
|
22
|
+
# Both walk instance variables directly and neither consults +as_json+, so closing the
|
|
23
|
+
# JSON door left them open, needing no ActiveSupport to be reachable. They are also the
|
|
24
|
+
# worse pair, because they are what puts a credential *at rest*: ActiveSupport's cache
|
|
25
|
+
# stores marshal what you write, so <tt>Rails.cache.write("bc", bc)</tt> landed the
|
|
26
|
+
# token in Redis, memcached or a file; a Marshal-backed session landed it in the
|
|
27
|
+
# session; Delayed::Job YAMLs its handler into the database. And nothing shadows the
|
|
28
|
+
# ivar walk the way +Enumerable#as_json+ shadows +Object#as_json+ — so
|
|
29
|
+
# <tt>Marshal.dump(bc.messages)</tt> reached the token where <tt>bc.messages.to_json</tt>
|
|
30
|
+
# never did. Both doors now raise, on the same mixin, so every includer is covered.
|
|
31
|
+
#
|
|
21
32
|
# Includers supply +serialization_refusal+; the default below is the honest fallback
|
|
22
33
|
# for one that does not, so the failure is still a rescuable BaseCradle::Error rather
|
|
23
34
|
# than a NoMethodError out of a renderer.
|
|
@@ -34,6 +45,24 @@ module BaseCradle
|
|
|
34
45
|
raise NotSerializableError, serialization_refusal
|
|
35
46
|
end
|
|
36
47
|
|
|
48
|
+
# +Marshal.dump+, and so every store built on it: ActiveSupport's cache backends, a
|
|
49
|
+
# Marshal-backed session, and the <tt>Marshal.load(Marshal.dump(x))</tt> deep-copy
|
|
50
|
+
# idiom. Marshal reaches for this hook before it walks ivars, so defining it is what
|
|
51
|
+
# stops the walk.
|
|
52
|
+
#
|
|
53
|
+
# Deliberately unpaired with a +marshal_load+: the pair only matters to something
|
|
54
|
+
# being loaded, and nothing can be loaded because nothing is ever dumped.
|
|
55
|
+
def marshal_dump
|
|
56
|
+
raise NotSerializableError, serialization_refusal
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Psych's hook — +to_yaml+, +YAML.dump+, and anything either one nests. Psych prefers
|
|
60
|
+
# +encode_with+ over its own ivar walk for any object that defines it. The coder is
|
|
61
|
+
# unused: it is the thing we decline to fill.
|
|
62
|
+
def encode_with(_coder)
|
|
63
|
+
raise NotSerializableError, serialization_refusal
|
|
64
|
+
end
|
|
65
|
+
|
|
37
66
|
private
|
|
38
67
|
|
|
39
68
|
def serialization_refusal
|
|
@@ -59,16 +88,31 @@ module BaseCradle
|
|
|
59
88
|
|
|
60
89
|
private
|
|
61
90
|
|
|
62
|
-
#
|
|
91
|
+
# One message, four doors — and it names both harms because which one you get depends
|
|
92
|
+
# on the door, measured on this tree. Through JSON, ActiveSupport's
|
|
63
93
|
# +Enumerable#as_json+ shadows +Object#as_json+, so a collection serialized as its
|
|
64
|
-
# fetched records
|
|
65
|
-
#
|
|
94
|
+
# fetched records and the client in its ivars was never walked: the harm is the
|
|
95
|
+
# unbounded fetch. +Marshal+ and Psych have no such shadow and walked the ivars
|
|
96
|
+
# instead, reaching the token and writing it wherever the dump went.
|
|
97
|
+
#
|
|
98
|
+
# Naming only the fetch would have told a +Rails.cache.write+ caller their credential
|
|
99
|
+
# was slow to write rather than at rest in Redis — a wrong diagnosis being worse than
|
|
100
|
+
# a missing one. Naming both keeps one refusal per resource, which is what stops the
|
|
101
|
+
# doors drifting apart.
|
|
102
|
+
#
|
|
103
|
+
# The remedy is two-part for the same reason. <tt>.to_a</tt> alone is enough for JSON,
|
|
104
|
+
# where a model serializes as its record — but the array it hands back is full of
|
|
105
|
+
# models that each hold the client, so <tt>Marshal.dump(bc.messages.to_a)</tt> lands
|
|
106
|
+
# straight back here. Measured, not assumed.
|
|
66
107
|
def serialization_refusal
|
|
67
|
-
"#{self.class} is a lazy, auto-paginating query
|
|
68
|
-
"record to serialize. Serializing it runs a
|
|
69
|
-
"resource from inside your renderer
|
|
70
|
-
"
|
|
71
|
-
"
|
|
108
|
+
"#{self.class} is a lazy, auto-paginating query holding your connection, not a " \
|
|
109
|
+
"record, so there is no one record to serialize. Serializing it either runs a " \
|
|
110
|
+
"page-by-page GET loop over the whole resource from inside your renderer and " \
|
|
111
|
+
"emits every record it fetched, or writes out the connection it holds — your " \
|
|
112
|
+
"bc_uat_ token with it. Call .to_a (or .first(n), or .filter(...).to_a) and " \
|
|
113
|
+
"serialize that — then how much you fetch is a visible act in your own code. " \
|
|
114
|
+
"Marshal and YAML reach the client through the records too, so dump the wire " \
|
|
115
|
+
"records rather than the models: .to_a.map(&:to_h)."
|
|
72
116
|
end
|
|
73
117
|
end
|
|
74
118
|
end
|
data/lib/basecradle/version.rb
CHANGED
data/lib/basecradle/webhooks.rb
CHANGED
|
@@ -99,6 +99,14 @@ module BaseCradle
|
|
|
99
99
|
# that already happened. Converting away from this type gives up the folding too
|
|
100
100
|
# (+to_h+, <tt>{**headers}</tt>, and anything else that hands back a plain +Hash+);
|
|
101
101
|
# +merge+ and +dup+ keep it.
|
|
102
|
+
#
|
|
103
|
+
# What it does *not* print is the values: +inspect+, +to_s+ and +pp+ render the header
|
|
104
|
+
# names alone, because these are the sender's headers and one of them may be the
|
|
105
|
+
# sender's credential. Every read is unaffected — and so is +to_json+, which still
|
|
106
|
+
# emits the delivery verbatim. Converting away from this type gives the redaction up
|
|
107
|
+
# exactly as it gives up the folding, and for the same reason: +slice+, +except+,
|
|
108
|
+
# +select+, +transform_values+ and +to_h+ hand back a plain +Hash+, whose +inspect+
|
|
109
|
+
# prints pairs.
|
|
102
110
|
class WebhookEventHeaders < Hash
|
|
103
111
|
# Hash's own exact-match membership, kept under a private name before the case-folding
|
|
104
112
|
# +key?+ below takes the name: a caller who wrote the wire's own spelling is answered
|
|
@@ -167,6 +175,43 @@ module BaseCradle
|
|
|
167
175
|
->(name) { dig(name) }
|
|
168
176
|
end
|
|
169
177
|
|
|
178
|
+
# The header *names* this delivery carried, and none of their values.
|
|
179
|
+
#
|
|
180
|
+
# +Hash+'s own +inspect+ prints every pair, and these headers are the *sender's*,
|
|
181
|
+
# stored verbatim by the platform: a sender that authenticates its POST to an ingest
|
|
182
|
+
# URL puts its credential in one of them, so +logger.debug(event.content.headers)+
|
|
183
|
+
# would write another party's secret into our logs. Every other renderable object in
|
|
184
|
+
# this SDK is already safe by one rule — +ApiObject#inspect+ prints field names, never
|
|
185
|
+
# values — and this is the one class that sat outside it, because +Hash+ brought its
|
|
186
|
+
# own render.
|
|
187
|
+
#
|
|
188
|
+
# Only the human-facing render changes. Every *read* is untouched and still
|
|
189
|
+
# wire-exact: lookups, +fetch+, +each+, +keys+, +to_h+, +to_json+, +==+.
|
|
190
|
+
#
|
|
191
|
+
# Names are sorted, as +ApiObject#inspect+ sorts its fields and as the +KeyError+ a
|
|
192
|
+
# missing header raises already lists the ones that did arrive.
|
|
193
|
+
def inspect
|
|
194
|
+
return "#<#{self.class}>" if empty?
|
|
195
|
+
|
|
196
|
+
"#<#{self.class} #{keys.sort_by(&:to_s).join(', ')}>"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# +Hash+ aliases +to_s+ to its *own* +inspect+ — one shared method entry — so
|
|
200
|
+
# overriding +inspect+ alone would leave <tt>"#{headers}"</tt> and +puts headers+
|
|
201
|
+
# printing every value. Late-bound rather than +alias to_s inspect+, so a subclass
|
|
202
|
+
# that redacts more is not bypassed by interpolation (as +Client#to_s+ is).
|
|
203
|
+
def to_s
|
|
204
|
+
inspect
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# +pp+ reaches for +pretty_print+, and +Hash+ brings one that prints the pairs — the
|
|
208
|
+
# same leak through the one door +inspect+ does not cover. Delegating puts +pp+ back
|
|
209
|
+
# on the SDK-wide rule: +pp+ uses an object's own +inspect+ when it defines one, which
|
|
210
|
+
# is why every +ApiObject+ is already safe here and only a +Hash+ descendant was not.
|
|
211
|
+
def pretty_print(printer)
|
|
212
|
+
printer.text(inspect)
|
|
213
|
+
end
|
|
214
|
+
|
|
170
215
|
private
|
|
171
216
|
|
|
172
217
|
# The wire's own spelling of +name+, or nil if no casing of it was delivered. Anything
|