permittable 0.7.0 → 0.8.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 +36 -0
- data/README.md +45 -10
- data/lib/permittable/generator.rb +30 -2
- data/lib/permittable/open_api.rb +56 -13
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +213 -18
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9eb3f4de06deb2a19c6a24b870e6f4d718350b67d733feb6d3ba423f27b6cdc9
|
|
4
|
+
data.tar.gz: a1a231373f6d9bab90e1e14ff372c3ab22c163783f3beafb9c4e23e0e46b3d18
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f69d441339746c659b6dc64589f77671f22fc13c701116389bbd916ce533ea80a737387605314679b47363c0de784f805aed75c83d7b835b8857aeac305167d2
|
|
7
|
+
data.tar.gz: 9c64b6950b2149ce8aed60113c07bff435119c33090db0883a50464e31609fa52ceefe848ae52f0e1a3ea34a312669a7022e771f648f3e20ad12ef96fd4c9023
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 0.8.0 (2026-09-19)
|
|
4
|
+
<!-- title: a no-op sensitive: cascade, an invalid OpenAPI export, and megabyte-scale rejections -->
|
|
5
|
+
|
|
6
|
+
Thirteen fixes, every one of them the gem doing its job wrongly rather than not at all. `sensitive: true` on a nested block or array was a complete no-op, printing in the clear the very values it promised to redact. Every exported OpenAPI document containing a member route was invalid — the committed golden fixture included — because a templated `{id}` was never declared as a parameter. `:datetime` raised `NameError` in any host that had not loaded ActiveSupport's time extensions, and normalising a `Time` to UTC rewrote the caller's own object. `:decimal` accepted the literal string `"NaN"` where `:float` rejected it. A rejected request instrumented itself once per read, double-counting in every dashboard. And one request could write a megabyte of log line, or spend nine seconds and 9.5 MB rejecting an array its own `length:` bound had already refused.
|
|
7
|
+
|
|
8
|
+
Minor rather than patch: nothing changes shape and the API is untouched, but three fixes are visible from outside. `:float` and `:decimal` now reject values they used to accept, a malformed `root:` reports `invalid_type` where it reported `missing`, and the `sensitive:` cascade widens redaction app-wide. Read **Upgrading** before deploying.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **A wildcard route exported an invalid OpenAPI path.** `OpenAPI.rails_routes` templated the `:id` form of a Rails path parameter but not the `*rest` wildcard, so `get "files/*path"` produced the path `/files/*path` — which is not a valid OpenAPI path template, and makes the whole exported document fail validation. Both forms are now templated: `/files/{path}`, including mixed routes like `/files/:bucket/*path` → `/files/{bucket}/{path}`.
|
|
12
|
+
- **A templated path variable was never declared as a parameter.** OpenAPI 3.1 requires every `{variable}` in a path template to have a matching path parameter, and the exporter emitted none — so **every document containing a member route was invalid**, the committed golden fixture included. Each operation now carries one `{ in: "path", required: true }` parameter per variable in the path it was placed at, typed `string`: a route set does not say what an `:id` is, and the exporter documents what arrives rather than guessing. A new spec asserts the invariant over the whole document, so an operation added later cannot reintroduce it.
|
|
13
|
+
- **A route answering several verbs was documented for only one of them.** `rails_routes` kept `verb.split("|").first`, so the `PATCH|PUT` pair `resources` generates — and any `match via: [:patch, :put]` — exported the PATCH operation and silently dropped PUT. Every verb a route answers now gets its own descriptor.
|
|
14
|
+
- **The error-response schema had drifted from what the server renders.** `ERROR_SCHEMA` is the one hand-written part of the export, and it had fallen behind twice over: a violation on a field with `message:` (or with app I18n copy) carries a third key the schema didn't mention, so clients generating types from it dropped the human-readable copy; and the `code` enumeration never gained `depth`, which a `:json` field's `max_depth:` bound emits. `message` is now documented as an optional property — `required` stays `param` + `code`, since a violation without one keeps the bare shape — and `depth` is listed. A new spec renders a real violation and holds the schema to the envelope, so this half of the "docs cannot drift" claim is now guarded like the request-body half.
|
|
15
|
+
- **Two controllers on one path and verb silently overwrote each other.** A document cannot carry two operations in one slot, and the second claim replaced the first with no indication anything had been lost. The loser now lands in `x-permittable-controllers`, which is where the exporter already puts an operation it cannot place.
|
|
16
|
+
- **Every `:datetime` cast raised `NameError` in a host that had not loaded ActiveSupport's time extensions.** A standalone `Permittable::Contract` — validating a webhook payload or a job argument — got `uninitialized constant ActiveSupport::TimeWithZone` instead of a validated param, because activesupport does not load that class by default and the gem never asked for it. A Rails app gets it via `active_support/time` at boot, which is why the spec suite (`require "active_record"`) masked it, the same shape as the 0.5.1 nested-hash bug. Fixed by requiring `active_support/core_ext/time/calculations`, which loads `TimeWithZone` **and** the `Time` extensions it needs: the class alone is not self-sufficient, and converting a real zoned time calls `Time#sec_fraction`, so requiring only `time_with_zone` would have traded `NameError` for `NoMethodError` on activesupport 8.1. The bare-subprocess spec now casts every scalar type, including a real `TimeWithZone`, in a process with no Rails.
|
|
17
|
+
- **`:float` and `:decimal` accepted numbers the type cannot faithfully hold, including ones a client controls.** `Float("1e400")` is `Infinity` and `Float("1e-400")` is `0.0` — the first overflows, the second loses the entire value — and both were accepted silently, leaving a value no numeric column can store. `Float::INFINITY` and `Float::NAN` objects passed straight through for both types. Worst of the set: **`BigDecimal("NaN")` and `BigDecimal("Infinity")` succeed where `Float()` raises**, so a client could send the literal string `"NaN"` for a `:decimal` price and have it stored — and `:float` rejected exactly those strings, so the two types disagreed, which is what marks the behaviour as accidental rather than designed. Non-finite results are now `invalid_type` for both types.
|
|
18
|
+
A genuine zero is unaffected however it is spelled — `"0"`, `"0.0"`, `"0.0000"` and `"0e10"` all still cast to `0.0`. Underflow is only visible against the source text (the result is an ordinary `0.0`), so a zero result is rejected only when the string named a nonzero **significand**; the exponent's digits say nothing about the value, which is why `"0e10"` is fine. `:decimal` keeps accepting the large exponents `BigDecimal` genuinely represents (`"1e400"` → `0.1e401`), since it has no exponent limit to overflow.
|
|
19
|
+
- **A `root:` key sent with the wrong shape reported `missing`, which sent clients looking in the wrong place.** `{"user": "bob"}` against a `root: :user` contract answered `{ param: "user", code: "missing" }` — for a key the client had just sent. An absent root and a malformed one are different client mistakes, and now read differently: `missing` when the key really is absent (`{}`, `{"user": null}`, `{"user": ""}` — the gem's own definition of absence, so an empty string still counts), `invalid_type` when it was sent as something other than an object. Both remain **400**, since either way the envelope itself is malformed, so nothing changes at the HTTP level; only the diagnostic gets accurate.
|
|
20
|
+
- **A rejected request instrumented `invalid_parameters.permittable` more than once, double-counting itself in every dashboard.** `permitted_params` is documented as memoized per action, but it only memoized *successes* — on a violation it raised without storing anything, so a second read revalidated from scratch and fired the event again. Any action that reads the params twice hit this, and `permittable_violations` followed by `permitted_params` — the pattern the monitor-mode docs suggest for "would this request fail?" — hit it every time. The memo now remembers the **outcome**: a rejection is stored and re-raised (the same exception object, not an equal-looking new one), so a contract runs, and instruments, exactly once per action per request. `ArgumentError` is deliberately still raised fresh every time and never memoized — a contract that doesn't cover the action is a bug to fix, not a verdict on the request.
|
|
21
|
+
- **One request could write a megabyte of log line, or hand a megabyte of exception message to every error tracker.** The `unknown: :log` warn line joined **every** undeclared key, and the violation summary behind `InvalidParameters#message` (and the monitor-mode warn line) joined **every** violation. A request carrying 50,000 undeclared keys against `unknown: :log` produced a single **1 MB** `logger.warn`; the same request against `unknown: :error` produced a 1 MB exception message. `unknown: :log` is the natural mode for watching what a client really sends during a rollout, so this was on a normal path rather than an exotic corner.
|
|
22
|
+
Both are **prose, written for a person**: they now list at most ten names, each truncated past 120 characters, and count the rest (`…, and 49990 more`), taking that 1 MB line to 261 bytes. Truncating each name matters as much as capping the count — one 1 MB key name alone produced the same 1 MB line. The **machine-readable channels are untouched and complete** — `InvalidParameters#details` still names every offender, and so does the `invalid_parameters.permittable` instrumentation payload — because nothing should silently drop data a consumer might be reading. The only visible change is the `message` string when there are more than ten violations, or an offender's path is longer than 120 characters — neither of which an ordinary contract reaches.
|
|
23
|
+
One trade-off worth stating: under `unknown: :log` nothing else records an undeclared key, so beyond the tenth only the count survives. Where every name matters, `unknown: :error` in monitor mode records all of them in `details` and in the instrumentation payload without rejecting the request. The 422 body under `unknown: :error` is still proportional to the number of violations, because `details` is deliberately complete.
|
|
24
|
+
- **An array outside its `length:` bound was still fully examined, so an oversized payload cost far more to reject than to accept.** `length:` recorded its violation and then cast, checked and reported on every element anyway. A payload of 200,000 non-string elements against `array :tags, of: :string, length: 0..10` produced **200,001 violations and a ~9.5 MB error body after ~9.2 seconds of CPU** — for a request already refused by its first check, and against the very bound a developer declares to prevent exactly that. `length:` is now a bound rather than a report: an array outside it returns immediately, so the same payload costs **one violation, ~40 bytes and ~57 ms** of contract work (the rest of the wall time is the `HashWithIndifferentAccess` conversion of the payload, which happens before any field is examined). A consequence worth knowing: `validate:` and `transform:` are no longer handed an array the contract has already rejected, matching the rule `transform:` already followed for element violations. Arrays within their bounds, and arrays with no `length:` declared, behave exactly as before — note in particular that there is still **no default cap**, so an array with no `length:` remains unbounded and every element of it is cast and checked. `benchmark/oversized_array.rb` re-runs the measurement.
|
|
25
|
+
An authored `default:`/`example:` on an array is now also checked against that array's own `length:` at class load, instead of loading and handing the action an out-of-bounds default.
|
|
26
|
+
- **`sensitive: true` on a nested block or array was a complete no-op, and logged the values it promised to redact.** Rails' parameter filtering walks into hashes and arrays itself and asks a proc filter about the **leaf values only**, handing it the leaf's own key and never the path that led there — so registering only the container's name redacted nothing: the filter descended and asked about `"card_number"`, which the container's name does not match. A contract declaring `optional :payment, sensitive: true do required :card_number, :string end` printed the card number in the clear. `sensitive:` now **cascades** to every field inside a nested or array container, at any depth, and a spec proves it through `ActiveSupport::ParameterFilter` rather than only asserting on the registry. The cascade is resolved onto the field data at class load, so every reader of a contract agrees with the redaction: the exported JSON Schema marks a cascaded child `writeOnly`, and the RSpec matcher's `.sensitive` chain passes for it.
|
|
27
|
+
- **`permittable:generate` read commented-out code as if it ran.** A controller keeping a `# params.require(:admin).permit(:superuser)` line for reference had `:admin` drafted as the contract's `root:` and `:superuser` drafted as a permitted field — a wrong suggestion, and a security-flavoured one, from a line that does not execute. The same applied to `=begin`/`=end` blocks and to trailing comments on live lines. Comments are now removed before scanning, using `Ripper` (stdlib, no new dependency) rather than a regexp, because `#` is only sometimes a comment: a permit call inside `#{'#{...}'}` interpolation **is** live code and is still read, and string **content** is deliberately kept because `permit("name")` is a supported spelling whose keys live in string tokens. A file `Ripper` cannot lex falls back to the raw source, so a syntactically odd controller scans exactly as it did before rather than not at all.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- **`sensitive: false` opts a sub-field out of an inherited cascade.** Matching is a case-insensitive **substring** match, so cascading a generic name like `:id` or `:name` would redact every parameter in the app that happens to contain it — occasionally a worse outcome than the leak it prevents. An explicit `sensitive: false` on a field (or on a container, for its whole subtree) keeps it readable. Only `false` opts out — `sensitive: nil` reads as "not stated" and still inherits.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
- **A `Time` passed to a `:datetime` field is no longer converted in place.** Normalising to UTC went through `value.to_time.utc`; `Time#to_time` returns `self` and `Time#utc` converts its **receiver**, so validating a request quietly rewrote the caller's own object — after `call!(at: t)`, `t` had become UTC. The cast now returns a new instance and leaves the argument alone. An `ActiveSupport::TimeWithZone` is likewise no longer handed back by way of the UTC instance it caches internally.
|
|
34
|
+
- **`length:` is now checked before `in:` and `format:`, so a value the bound already excludes never pays for the expensive checks.** `length:` is an O(1) read of a String's size; `format:` runs a regexp over the whole value and `validate:` runs arbitrary app code. Checking the cheap bound *last* meant a 5 MB string against `length: 1..80` was scanned in full by the field's regexp before being rejected on its length — 121 ms where 38 ms would do, and a lever rather than mere waste when the app's regexp has poor worst-case behaviour. The documented order is now `normalize: → cast → length: → in: → format: → validate:`, with the first failure reported. The only observable change is which code a value violating **both** reports — `length` now, rather than `inclusion`/`format` — and that is the more useful answer anyway, since a client cannot act on "wrong format" for a value that is also far too long. A spec pins that the field's regexp is not consulted at all for an over-long value.
|
|
35
|
+
|
|
36
|
+
### Upgrading
|
|
37
|
+
- **A contract that already declares `sensitive: true` on a nested block or array will redact more than it did before.** That is the point of the fix, but the widening is app-wide and worth a look before deploying: every cascaded child's name is registered as a case-insensitive **substring** filter, so a child called `id`, `name`, `type`, `status` or `zip` starts redacting `user_id`, `company_name`, `content_type` and `gzip` in **every** controller's logs, not only in the contract that declared it. Run `grep -n "sensitive: true" app/controllers` and add `sensitive: false` to any child whose name is too generic to filter globally.
|
|
38
|
+
|
|
3
39
|
## 0.7.0 (2026-09-16)
|
|
4
40
|
<!-- title: sensitive: redaction, uncorruptible defaults, and stricter class load -->
|
|
5
41
|
|
data/README.md
CHANGED
|
@@ -270,7 +270,7 @@ end
|
|
|
270
270
|
|
|
271
271
|
# Arrays — of: for scalars, a block for hashes. Element failures carry their index: items[1].sku
|
|
272
272
|
array :tag_names, of: :string, length: 0..10
|
|
273
|
-
array :line_items, required: true do
|
|
273
|
+
array :line_items, required: true, length: 1..50 do
|
|
274
274
|
required :sku, :string
|
|
275
275
|
required :quantity, :integer, in: 1..99
|
|
276
276
|
end
|
|
@@ -281,6 +281,8 @@ optional :metadata, :json, max_depth: 3, length: 0..32
|
|
|
281
281
|
|
|
282
282
|
Arrays are **optional unless `required: true`**, and `length:` on an array constrains the element **count**.
|
|
283
283
|
|
|
284
|
+
`length:` is a **bound, not a report**: an array outside it is rejected without its elements being examined at all. A 200,000-element payload against `length: 0..10` is refused by its first check, so it costs one violation and a 40-byte body instead of 200,001 violations and several megabytes — milliseconds of contract work instead of seconds. There is **no default cap**: an array with no `length:` is unbounded, and every element of it is cast and checked however many arrive. Declare `length:` on every array you accept.
|
|
285
|
+
|
|
284
286
|
### Field options
|
|
285
287
|
|
|
286
288
|
Which options are legal depends on the field kind — anything else raises at class load.
|
|
@@ -289,7 +291,7 @@ Which options are legal depends on the field kind — anything else raises at cl
|
|
|
289
291
|
|---|:---:|:---:|:---:|---|
|
|
290
292
|
| `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`) or an `Array` |
|
|
291
293
|
| `format:` | ✅¹ | — | — | Regexp the value must match |
|
|
292
|
-
| `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays |
|
|
294
|
+
| `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays, where it short-circuits — see [the field DSL](#the-field-dsl) |
|
|
293
295
|
| `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **first** — before the absence rule, so a value that normalizes to `""` is absent |
|
|
294
296
|
| `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load, then stored normalized and frozen (each request gets its own copy) |
|
|
295
297
|
| `validate:` | ✅ | ✅ | — | Callable. Falsy fails as `"invalid"`; a returned `Symbol` becomes the violation code |
|
|
@@ -306,6 +308,16 @@ Which options are legal depends on the field kind — anything else raises at cl
|
|
|
306
308
|
|
|
307
309
|
¹ `format:`, `length:`, and `normalize:` reason about characters and are **only valid on `:string` fields**. On any other type they would silently apply to an already-cast value, so declaring them raises at class load.
|
|
308
310
|
|
|
311
|
+
**Checks run in a fixed order**, and the first failure is the one reported:
|
|
312
|
+
|
|
313
|
+
```
|
|
314
|
+
normalize: → cast → length: → in: → format: → validate:
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
`length:` comes before `in:` and `format:` on purpose. It is an O(1) read of a string's size, while `format:` runs a regexp over the whole value and `validate:` runs your own code — so a value the length bound already excludes never pays for the expensive checks. A 5 MB string against `length: 1..80` is rejected on its length without the regexp ever seeing it, which matters most when the regexp is one with poor worst-case behaviour.
|
|
318
|
+
|
|
319
|
+
The visible consequence: a value that violates *both* its length and its format reports `length`. That is the more useful answer anyway — a client can't act on "wrong format" for a value that is also far too long.
|
|
320
|
+
|
|
309
321
|
`validate:` is the escape hatch for anything the built-ins don't cover:
|
|
310
322
|
|
|
311
323
|
```ruby
|
|
@@ -329,6 +341,8 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
|
|
|
329
341
|
|
|
330
342
|
**Dates are parsed, never guessed.** `Date.parse` fills in what a string omits *from today* — `"09/2026"` becomes the 1st, `"5th"` becomes this month of this year — so the same request would mean different things on different days. A `:date` or `:datetime` string must therefore name all three of year, month and day; which **format** it names them in is `Date.parse`'s business, so every complete format it understands still works. A `:datetime` may omit the *time* part, which reads as midnight UTC.
|
|
331
343
|
|
|
344
|
+
**Numbers must be finite.** `Float("1e400")` is `Infinity` and `Float("1e-400")` is `0.0` — neither represents what was sent, and neither is a value a numeric column can store, so both are `invalid_type`. A genuine zero is unaffected however it is spelled (`"0"`, `"0.0"`, `"0e10"`). `:decimal` has no exponent limit, so `"1e400"` is fine there — but `BigDecimal("NaN")` and `BigDecimal("Infinity")` *succeed* where `Float()` raises, so those literal strings are rejected explicitly.
|
|
345
|
+
|
|
332
346
|
Two more behaviours worth committing to memory:
|
|
333
347
|
|
|
334
348
|
- **Type confusion is a violation, not a 500.** A request of `?age[]=1` against a scalar `:integer` field yields `invalid_type`. Arrays, hashes, and nested `ActionController::Parameters` can never satisfy a scalar type, so the classic "`NoMethodError` on `[]`" crash is impossible.
|
|
@@ -414,8 +428,8 @@ Every failure raises `Permittable::InvalidParameters`, carrying `details` (an ar
|
|
|
414
428
|
|
|
415
429
|
| Code | Raised when |
|
|
416
430
|
|---|---|
|
|
417
|
-
| `missing` | A required field is absent, or the `root:` key is
|
|
418
|
-
| `invalid_type` | The value cannot be faithfully cast to the declared type |
|
|
431
|
+
| `missing` | A required field is absent, or the `root:` key is absent (that one is a **400**) |
|
|
432
|
+
| `invalid_type` | The value cannot be faithfully cast to the declared type — including a `root:` key the client *did* send with the wrong shape (`{"user": "bob"}`), which is also a **400** |
|
|
419
433
|
| `inclusion` | The value is outside `in:` |
|
|
420
434
|
| `format` | The value doesn't match `format:` |
|
|
421
435
|
| `length` | A string's length, or an array's element count, is outside `length:` |
|
|
@@ -425,7 +439,9 @@ Every failure raises `Permittable::InvalidParameters`, carrying `details` (an ar
|
|
|
425
439
|
|
|
426
440
|
Paths are fully qualified: `user.address.zip`, `line_items[1].sku`.
|
|
427
441
|
|
|
428
|
-
|
|
442
|
+
**`details` is complete; `message` is prose.** The `details` array names **every** offender, however many there are — it is the machine-readable channel and nothing is dropped from it. The `message` string is a sentence for a person, and it also lands in your logs and in every exception tracker, so it is bounded: at most ten offenders, each truncated past 120 characters, then a count of the rest (`…, and 49990 more`). Before that bound, a request carrying 50,000 undeclared keys against `unknown: :error` produced a **1 MB** exception message and a 1 MB log line. The 422 body still carries the complete `details`, so it stays proportional to the number of violations; the field bounds are what keep that number down.
|
|
443
|
+
|
|
444
|
+
**Status codes.** A bad root key renders **400** — the request is malformed; the envelope you asked for isn't there, or isn't an object. Field-level violations render **422** — well-formed, semantically wrong. The two root failures are told apart by their code: `missing` when the key really is absent (`{}`, `{"user": null}`, `{"user": ""}`), `invalid_type` when the client sent it with the wrong shape.
|
|
429
445
|
|
|
430
446
|
**Custom rendering.** If your controller defines `render_error`, the envelope delegates to it as `render_error(message:, code:, status:, errors:)` — the `errors:` key is passed only when details exist, so hosts documenting a three-keyword contract keep working. Otherwise the inline JSON shape is rendered. Either way, `render_invalid_parameters` is a normal method you can override. For full control over the body (RFC 9457, a different envelope), `error.details` gives you the structured violations to build from.
|
|
431
447
|
|
|
@@ -484,9 +500,11 @@ Resolution order per violation: the field's own `message:` (String, or the Hash
|
|
|
484
500
|
| Mode | Behaviour |
|
|
485
501
|
|---|---|
|
|
486
502
|
| `:ignore` (default) | Silently dropped, exactly like strong parameters |
|
|
487
|
-
| `:log` | Dropped, with a `logger.warn` naming the full paths |
|
|
503
|
+
| `:log` | Dropped, with a `logger.warn` naming the full paths — at most ten of them, then a count, so one request cannot write a megabyte of log |
|
|
488
504
|
| `:error` | Each undeclared key becomes an `unknown` violation |
|
|
489
505
|
|
|
506
|
+
Under `:log` that bound is the whole record: nothing else names an undeclared key, so beyond the tenth only the count survives. Where you need every name — auditing what a client really sends during a rollout — use `unknown: :error` in monitor mode, which records all of them in `details` and in the instrumentation payload without rejecting the request.
|
|
507
|
+
|
|
490
508
|
Rails merges its own keys into `params`: `controller`, `action`, and `format` from the router, plus `authenticity_token`, `_method`, `utf8`, and `commit` from an ordinary form POST. All seven are exempt at the top level, so `unknown: :error` flags what the *client* got wrong rather than what the framework added. Inside a `root:` or a nested hash there is no such exemption, because nothing legitimately injects keys there — and a standalone `Contract` exempts nothing at all, having neither a router nor a form.
|
|
491
509
|
|
|
492
510
|
The exemption covers the *check* only. Monitor mode still hands back the form keys in its raw pass-through, where behaving exactly like the pre-contract app is the whole promise and a legacy action may read `_method` itself; only the router's three are dropped there.
|
|
@@ -555,6 +573,22 @@ Mark a field `sensitive: true` and its name is registered with `Permittable.filt
|
|
|
555
573
|
optional :ssn, :string, sensitive: true
|
|
556
574
|
```
|
|
557
575
|
|
|
576
|
+
**On a nested block or an array, `sensitive:` cascades to everything inside it:**
|
|
577
|
+
|
|
578
|
+
```ruby
|
|
579
|
+
optional :payment, sensitive: true do
|
|
580
|
+
required :card_number, :string # redacted
|
|
581
|
+
optional :cvv, :string # redacted
|
|
582
|
+
optional :id, :string, sensitive: false # NOT redacted — see below
|
|
583
|
+
end
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
It has to. Rails' parameter filtering walks into hashes and arrays itself and asks a proc filter about the **leaf values only**, handing it the leaf's own key and never the path that led there. So registering `payment` alone redacts nothing inside it: the filter descends and asks about `card_number`, which the container's name does not match.
|
|
587
|
+
|
|
588
|
+
A sub-field opts out with an explicit `sensitive: false`. That exists because matching is a case-insensitive **substring** match, so cascading a generic name like `:id` or `:name` would redact every parameter in the app that happens to contain it — occasionally a worse outcome than the leak it prevents. Only `false` opts out; `sensitive: nil` reads as "not stated" and still inherits.
|
|
589
|
+
|
|
590
|
+
The cascade is resolved onto the field when the contract loads, so everything that reads a contract agrees: the value is redacted from logs, the exported schema marks the child `writeOnly`, and `permit_param("payment.card_number").sensitive` passes.
|
|
591
|
+
|
|
558
592
|
Two mechanisms, because neither covers the ground alone.
|
|
559
593
|
|
|
560
594
|
A **single proc appended once at boot, consulting a live registry at filter time**, is what reaches consumers that snapshot `config.filter_parameters` at boot — ActiveRecord's `filter_attributes` copy, lograge-style initializers — so a field registered when a controller loads later (lazy loading in development) is still redacted there. The initializer runs before `active_record.set_filter_attributes`, so values are redacted from both request logs and `#inspect`.
|
|
@@ -567,7 +601,7 @@ Matching mirrors Rails' own symbol-filter semantics: case-insensitive substring
|
|
|
567
601
|
|
|
568
602
|
### Instrumentation
|
|
569
603
|
|
|
570
|
-
Every violation emits an `ActiveSupport::Notifications` event, so rejected requests can be dashboarded and alerted on:
|
|
604
|
+
Every violation emits an `ActiveSupport::Notifications` event, so rejected requests can be dashboarded and alerted on — **exactly once per action per request**, however many times the action reads the params (`permitted_params` memoizes the outcome, rejections included):
|
|
571
605
|
|
|
572
606
|
```ruby
|
|
573
607
|
ActiveSupport::Notifications.subscribe("invalid_parameters.permittable") do |*, payload|
|
|
@@ -646,7 +680,7 @@ permit_params :create, :update, root: :user, model: User, mode: :monitor do
|
|
|
646
680
|
optional :age, :integer
|
|
647
681
|
optional :status, :string # database default: "active"
|
|
648
682
|
optional :password_confirmation, :string, virtual: true # TODO: not a database column — confirm the type
|
|
649
|
-
array :tag_names, of: :string # TODO: confirm the element type
|
|
683
|
+
array :tag_names, of: :string # TODO: confirm the element type, and declare length: — an array without one is unbounded
|
|
650
684
|
end
|
|
651
685
|
```
|
|
652
686
|
|
|
@@ -654,6 +688,7 @@ The generator's one rule is **draft, don't guess** — everything it cannot know
|
|
|
654
688
|
|
|
655
689
|
- Drafts come out in **monitor mode**, so pasting one changes nothing until you flip it.
|
|
656
690
|
- A permitted key that isn't a column becomes `virtual: true` with a TODO; a column type with no scalar equivalent (`json`, `binary`) becomes a TODO comment; a permit argument the conservative parser can't read (`*dynamic_keys`) is kept verbatim in a TODO instead of dropped.
|
|
691
|
+
- **Comments are not code.** A commented-out `params.require(:admin).permit(:superuser)` kept for reference is skipped, so it can't contribute a root or a field to the draft. The source is tokenised with `Ripper` for this, because `#` is only sometimes a comment — a permit call inside `#{'#{...}'}` interpolation is live code and is still read, and quoted keys like `permit("name")` still work.
|
|
657
692
|
- A database default is noted in a comment but **not** copied into `default:` — a contract default is injected on every request that omits the field, which would overwrite columns on partial updates. The database already handles creation.
|
|
658
693
|
- `key: [:a, :b]` in a permit call drafts as a nested block, with a TODO noting it may be an array of hashes.
|
|
659
694
|
|
|
@@ -736,7 +771,7 @@ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
|
|
|
736
771
|
|
|
737
772
|
Every operation references shared components for the [error envelope](#violations-and-error-responses): a `422` response always, plus a `400` when the contract declares a `root:`. So consumers get typed *errors*, not just typed inputs.
|
|
738
773
|
|
|
739
|
-
**What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a Ruby-only construct (or flags) is exported as `x-permittable-pattern` rather than a mistranslated `pattern` — including one anchored with `^`/`$`, which in Ruby anchor a **line** and in ECMA-262 anchor the whole string, so `/^\d{5}$/` accepts `"evil\n12345"` at runtime and publishing that source would promise a stricter rule than the server enforces (use `\A`/`\z`, which translate exactly); `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`; actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; operations with no matching route land in `x-permittable-controllers` instead of being dropped. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
|
|
774
|
+
**What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a Ruby-only construct (or flags) is exported as `x-permittable-pattern` rather than a mistranslated `pattern` — including one anchored with `^`/`$`, which in Ruby anchor a **line** and in ECMA-262 anchor the whole string, so `/^\d{5}$/` accepts `"evil\n12345"` at runtime and publishing that source would promise a stricter rule than the server enforces (use `\A`/`\z`, which translate exactly); `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`; actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; operations with no matching route — or whose path-and-verb slot another controller already claimed, which one document cannot represent twice — land in `x-permittable-controllers` instead of being dropped. A templated path segment is declared as a path `parameter` of type `string`, because the route set doesn't say what an `:id` is and the exporter won't invent it. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
|
|
740
775
|
|
|
741
776
|
Output is deterministic (fixed key order, declaration-order properties), so the generated file can be committed and reviewed as a diff — a contract change shows up in the same PR as its documentation change.
|
|
742
777
|
|
|
@@ -772,7 +807,7 @@ Output is deterministic (fixed key order, declaration-order properties), so the
|
|
|
772
807
|
|
|
773
808
|
| Method | Purpose |
|
|
774
809
|
|---|---|
|
|
775
|
-
| `permitted_params(action = action_name)` | The cast, validated, defaulted `HashWithIndifferentAccess`.
|
|
810
|
+
| `permitted_params(action = action_name)` | The cast, validated, defaulted `HashWithIndifferentAccess`. Raises `InvalidParameters` on violation (in [monitor mode](#monitor-mode-roll-out-without-rejecting), returns the raw pass-through instead), or `ArgumentError` when no contract covers the action. **Memoized per action, outcome included** — a rejection is re-raised rather than revalidated, so a contract runs (and instruments) exactly once per action per request |
|
|
776
811
|
| `permittable_violations(action = action_name)` | The violation details recorded by validating `action` — `[]` when clean. Triggers the same memoized validation; under enforce it swallows the raise, making "would this request fail?" a one-liner |
|
|
777
812
|
| `enforce_params_contract` | The `before_action` entry point. Validates rules declared `enforce: true` and all [monitor-mode](#monitor-mode-roll-out-without-rejecting) rules. Public, so hosts can `skip_before_action` it |
|
|
778
813
|
| `render_invalid_parameters(error)` | The `rescue_from` target. Renders via the host's `render_error` when defined, the inline envelope otherwise |
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "ripper"
|
|
2
|
+
|
|
1
3
|
module Permittable
|
|
2
4
|
# Drafts a permit_params contract from what the app already knows: the
|
|
3
5
|
# model's columns (types, NOT NULL, database defaults) and, when the
|
|
@@ -54,14 +56,38 @@ module Permittable
|
|
|
54
56
|
ARRAY_ARG = /\A(\w+):\s*\[\s*\]\z/m
|
|
55
57
|
NESTED_ARG = /\A(\w+):\s*\[([^\[\]]*)\]\z/m
|
|
56
58
|
|
|
59
|
+
# Comment tokens. Ripper (stdlib) is used rather than a regexp because `#`
|
|
60
|
+
# is only a comment sometimes — it also appears inside string literals and
|
|
61
|
+
# `#{}` interpolation, and a permit call inside interpolation IS live code.
|
|
62
|
+
# String CONTENT is deliberately kept: `permit("name")` is a supported
|
|
63
|
+
# spelling, and its keys live in string tokens.
|
|
64
|
+
COMMENT_TOKENS = %i[on_comment on_embdoc on_embdoc_beg on_embdoc_end].freeze
|
|
65
|
+
|
|
57
66
|
module_function
|
|
58
67
|
|
|
68
|
+
# `source` with its comments removed. A controller keeping a commented-out
|
|
69
|
+
# `params.require(:admin).permit(:superuser)` for reference had :admin
|
|
70
|
+
# drafted as its root and :superuser as a permitted field — a wrong
|
|
71
|
+
# suggestion, and a security-flavoured one, from a line that does not run.
|
|
72
|
+
#
|
|
73
|
+
# Anything Ripper cannot lex falls back to the source unchanged, so a
|
|
74
|
+
# syntactically odd file scans exactly as it did before rather than not at
|
|
75
|
+
# all.
|
|
76
|
+
def executable_source(source)
|
|
77
|
+
tokens = Ripper.lex(source)
|
|
78
|
+
return source if tokens.nil? || tokens.empty?
|
|
79
|
+
|
|
80
|
+
tokens.reject { |token| COMMENT_TOKENS.include?(token[1]) }.map { |token| token[2] }.join
|
|
81
|
+
rescue StandardError
|
|
82
|
+
source
|
|
83
|
+
end
|
|
84
|
+
|
|
59
85
|
# Merge every permit call found in `source` into one Scan. The first
|
|
60
86
|
# `.require(:root)` seen wins, matching how a controller normally sticks
|
|
61
87
|
# to one envelope across actions.
|
|
62
88
|
def scan(source)
|
|
63
89
|
result = Scan.new(root: nil, scalars: [], arrays: [], nested: {}, unparsed: [], calls: 0)
|
|
64
|
-
(source
|
|
90
|
+
executable_source(source.to_s).scan(PERMIT_CALL) do |root, args|
|
|
65
91
|
result.calls += 1
|
|
66
92
|
result.root ||= root&.to_sym
|
|
67
93
|
split_args(args).each { |arg| classify_arg(result, arg) }
|
|
@@ -192,7 +218,9 @@ module Permittable
|
|
|
192
218
|
|
|
193
219
|
def scanned_lines(scan, columns)
|
|
194
220
|
lines = scan.scalars.map { |name| scanned_scalar_line(name, columns) }
|
|
195
|
-
lines += scan.arrays.map
|
|
221
|
+
lines += scan.arrays.map do |name|
|
|
222
|
+
"array :#{name}, of: :string # TODO: confirm the element type, and declare length: — an array without one is unbounded"
|
|
223
|
+
end
|
|
196
224
|
scan.nested.each { |name, keys| lines += nested_lines(name, keys) }
|
|
197
225
|
lines + scan.unparsed.map { |arg| "# TODO: could not parse from the permit call: #{arg}" }
|
|
198
226
|
end
|
data/lib/permittable/open_api.rb
CHANGED
|
@@ -11,8 +11,9 @@ module Permittable
|
|
|
11
11
|
# Everything the exporter cannot know is left visible rather than guessed:
|
|
12
12
|
# actions covered only by a catch-all rule on a host without
|
|
13
13
|
# `action_methods` appear under the "*" key with `x-permittable-catch-all`,
|
|
14
|
-
# and operations with no matching route
|
|
15
|
-
#
|
|
14
|
+
# and operations with no matching route — or whose path+verb slot another
|
|
15
|
+
# controller already claimed, which a document cannot represent twice —
|
|
16
|
+
# land in `x-permittable-controllers` instead of being dropped silently.
|
|
16
17
|
module OpenAPI
|
|
17
18
|
module_function
|
|
18
19
|
|
|
@@ -39,8 +40,15 @@ module Permittable
|
|
|
39
40
|
},
|
|
40
41
|
"code" => {
|
|
41
42
|
"type" => "string",
|
|
42
|
-
"description" => "missing / invalid_type / inclusion / format / length / unknown /
|
|
43
|
-
"or a contract-specific symbol"
|
|
43
|
+
"description" => "missing / invalid_type / inclusion / format / length / depth / unknown / " \
|
|
44
|
+
"invalid, or a contract-specific symbol"
|
|
45
|
+
},
|
|
46
|
+
# Present only when the field declares `message:` or the app
|
|
47
|
+
# has I18n copy for the code; a violation without one keeps
|
|
48
|
+
# the bare { param:, code: } shape, so this is not required.
|
|
49
|
+
"message" => {
|
|
50
|
+
"type" => "string",
|
|
51
|
+
"description" => "Human-readable copy for this violation, when the contract or I18n supplies it"
|
|
44
52
|
}
|
|
45
53
|
},
|
|
46
54
|
"required" => %w[param code]
|
|
@@ -176,15 +184,43 @@ module Permittable
|
|
|
176
184
|
def place_operations(controller, operations, routes, paths, unrouted)
|
|
177
185
|
key = controller_key(controller) || controller.inspect
|
|
178
186
|
operations.each do |action, operation|
|
|
179
|
-
|
|
180
|
-
|
|
187
|
+
# A path+verb pair carries exactly one operation, so a slot another
|
|
188
|
+
# controller already claimed is not written over: the loser stays
|
|
189
|
+
# visible under x-permittable-controllers, where an operation with no
|
|
190
|
+
# route at all lands, rather than disappearing from the document.
|
|
191
|
+
free = routes_for(routes, key, action).reject { |route| paths.dig(route[:path], verb_of(route)) }
|
|
192
|
+
if free.empty?
|
|
181
193
|
(unrouted[key] ||= {})[action] = operation
|
|
182
194
|
else
|
|
183
|
-
|
|
195
|
+
free.each { |route| (paths[route[:path]] ||= {})[verb_of(route)] = with_path_parameters(operation, route[:path]) }
|
|
184
196
|
end
|
|
185
197
|
end
|
|
186
198
|
end
|
|
187
199
|
|
|
200
|
+
def verb_of(route)
|
|
201
|
+
route[:verb].to_s.downcase
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# OpenAPI 3.1 requires every variable in a path template to be declared as
|
|
205
|
+
# a path parameter — a document templating {id} without declaring it is
|
|
206
|
+
# invalid, which every member route produced. The route set does not say
|
|
207
|
+
# what an :id is and the exporter does not guess: a path segment arrives as
|
|
208
|
+
# a string, so that is what it is documented as.
|
|
209
|
+
def with_path_parameters(operation, path)
|
|
210
|
+
variables = path.scan(/\{(\w+)\}/).flatten
|
|
211
|
+
return operation if variables.empty?
|
|
212
|
+
|
|
213
|
+
parameters = variables.map do |name|
|
|
214
|
+
{ "name" => name, "in" => "path", "required" => true, "schema" => { "type" => "string" } }
|
|
215
|
+
end
|
|
216
|
+
# Inserted ahead of requestBody, where a reader of the document expects
|
|
217
|
+
# it; emission stays deterministic either way.
|
|
218
|
+
operation.each_with_object({}) do |(key, value), out|
|
|
219
|
+
out["parameters"] = parameters if key == "requestBody"
|
|
220
|
+
out[key] = value
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
188
224
|
def routes_for(routes, controller_key, action)
|
|
189
225
|
return [] if routes.nil? || action == "*"
|
|
190
226
|
|
|
@@ -194,16 +230,23 @@ module Permittable
|
|
|
194
230
|
# { controller:, action:, verb:, path: } descriptors from a Rails
|
|
195
231
|
# application's route set. Duck-typed against Journey routes (each one
|
|
196
232
|
# responds to requirements / verb / path.spec) so it stays unit-testable
|
|
197
|
-
# without Rails; Rails path params
|
|
233
|
+
# without Rails; Rails path params become OpenAPI templates — both the
|
|
234
|
+
# `:id` form and the `*rest` wildcard, which is a real route shape
|
|
235
|
+
# (`get "files/*path"`) and is not a valid OpenAPI template left as-is.
|
|
198
236
|
def rails_routes(app)
|
|
199
|
-
app.routes.routes.
|
|
237
|
+
app.routes.routes.flat_map do |route|
|
|
200
238
|
requirements = route.requirements
|
|
201
239
|
verb = route.verb.to_s
|
|
202
|
-
next if requirements[:controller].nil? || requirements[:action].nil? || verb.empty?
|
|
240
|
+
next [] if requirements[:controller].nil? || requirements[:action].nil? || verb.empty?
|
|
203
241
|
|
|
204
|
-
path = route.path.spec.to_s.sub("(.:format)", "").gsub(
|
|
205
|
-
|
|
206
|
-
|
|
242
|
+
path = route.path.spec.to_s.sub("(.:format)", "").gsub(/[:*](\w+)/) { "{#{Regexp.last_match(1)}}" }
|
|
243
|
+
# One route can answer several verbs (`match via: [:patch, :put]`, and
|
|
244
|
+
# the PATCH|PUT pair resources generates); documenting only the first
|
|
245
|
+
# dropped the others from the export entirely.
|
|
246
|
+
verb.split("|").map do |single|
|
|
247
|
+
{ controller: requirements[:controller], action: requirements[:action],
|
|
248
|
+
verb: single.downcase, path: path }
|
|
249
|
+
end
|
|
207
250
|
end
|
|
208
251
|
end
|
|
209
252
|
|
data/lib/permittable/version.rb
CHANGED
data/lib/permittable.rb
CHANGED
|
@@ -7,6 +7,18 @@ require "active_support/core_ext/class/attribute"
|
|
|
7
7
|
require "active_support/core_ext/object/deep_dup" # authored default:/example: values are copied before freezing
|
|
8
8
|
require "active_support/core_ext/string/inflections"
|
|
9
9
|
require "active_support/core_ext/string/filters"
|
|
10
|
+
# cast_datetime names ActiveSupport::TimeWithZone, which activesupport does not
|
|
11
|
+
# load by default. A Rails app has it via active_support/time at boot; a
|
|
12
|
+
# standalone host (a Contract validating a webhook payload or a job argument)
|
|
13
|
+
# has nothing that loads it, and every :datetime cast raised NameError there.
|
|
14
|
+
#
|
|
15
|
+
# The Time core extensions come with it, and are not optional: TimeWithZone is
|
|
16
|
+
# present but not self-sufficient. Converting one goes through
|
|
17
|
+
# TimeZone#utc_to_local, which calls Time#sec_fraction — defined in
|
|
18
|
+
# core_ext/time/calculations, which time_with_zone.rb does not itself require.
|
|
19
|
+
# Without this line a real TimeWithZone raises NoMethodError in a bare host on
|
|
20
|
+
# activesupport 8.1, having merely traded one crash for another.
|
|
21
|
+
require "active_support/core_ext/time/calculations"
|
|
10
22
|
require "bigdecimal"
|
|
11
23
|
require "date"
|
|
12
24
|
require "time"
|
|
@@ -139,6 +151,13 @@ require "permittable/filter_parameter_registry"
|
|
|
139
151
|
# the value and expects in-place mutation, and never calls the proc at all
|
|
140
152
|
# for a Hash — so a name in config.filter_parameters is what covers an
|
|
141
153
|
# :integer field or a sensitive nested block.
|
|
154
|
+
# Permittable::Railtie appends to `config.filter_parameters`. On a nested or
|
|
155
|
+
# array field it CASCADES to every field inside, because Rails' filtering
|
|
156
|
+
# asks about the leaf key it is looking at rather than the path to it; a
|
|
157
|
+
# sub-field opts out with `sensitive: false`, since matching is a substring
|
|
158
|
+
# match and a generic cascaded name would redact half the app's logs. The
|
|
159
|
+
# cascade is resolved onto the field data at class load — see
|
|
160
|
+
# ContractBuilder#cascade_sensitive.
|
|
142
161
|
#
|
|
143
162
|
# OUTPUT RESHAPING — the safe replacement for params-mutating before_actions.
|
|
144
163
|
# Two layers, both operating on the validated COPY (the request's `params` is
|
|
@@ -188,6 +207,15 @@ module Permittable
|
|
|
188
207
|
# rather than leaving a bare ROUTING_KEYS to read like an oversight.
|
|
189
208
|
UNCHECKED_TOP_LEVEL_KEYS = (ROUTING_KEYS + FORM_KEYS).freeze
|
|
190
209
|
MONITOR_DROPPED_KEYS = ROUTING_KEYS
|
|
210
|
+
# A log line and an exception message are PROSE, written for a person. They
|
|
211
|
+
# list at most this many names and count the rest, so one request cannot
|
|
212
|
+
# write a megabyte of them. The machine-readable channels — a violation's
|
|
213
|
+
# `details` and the instrumentation payload — stay complete; only the
|
|
214
|
+
# sentence is bounded.
|
|
215
|
+
PROSE_LIST_LIMIT = 10
|
|
216
|
+
# ...and each name it does list is truncated. Capping the COUNT alone still
|
|
217
|
+
# let ONE 1 MB key name write the 1 MB log line the cap exists to prevent.
|
|
218
|
+
PROSE_ITEM_LIMIT = 120
|
|
191
219
|
|
|
192
220
|
# The single proc Permittable::Railtie appends to config.filter_parameters.
|
|
193
221
|
# Declared with an optional third parameter so its own arity is -3 and Rails
|
|
@@ -375,10 +403,22 @@ module Permittable
|
|
|
375
403
|
check_scalar_rules(field, value)
|
|
376
404
|
end
|
|
377
405
|
|
|
406
|
+
# `length:` first, deliberately. It is an O(1) read of a String's size,
|
|
407
|
+
# while `format:` runs a regexp over the whole value and `validate:` runs
|
|
408
|
+
# arbitrary app code — so checking the cheap bound last meant a value the
|
|
409
|
+
# bound already excluded still paid for the expensive ones. A 5 MB string
|
|
410
|
+
# against `length: 1..80` scanned all 5 MB with the field's regexp before
|
|
411
|
+
# being rejected on its length, and an app regexp with poor worst-case
|
|
412
|
+
# behaviour turns that from waste into a lever.
|
|
413
|
+
#
|
|
414
|
+
# The only observable change is which code a value violating BOTH reports:
|
|
415
|
+
# `length` now, rather than `inclusion`/`format`. Reporting the structural
|
|
416
|
+
# failure first is the better answer anyway — a client cannot act on
|
|
417
|
+
# "wrong format" for a value that is also far too long.
|
|
378
418
|
def check_scalar_rules(field, value)
|
|
419
|
+
return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
|
|
379
420
|
return [:error, "inclusion"] if field[:in] && !included_in?(field[:in], value)
|
|
380
421
|
return [:error, "format"] if field[:format] && !field[:format].match?(value)
|
|
381
|
-
return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
|
|
382
422
|
|
|
383
423
|
check_custom(field[:validate], value)
|
|
384
424
|
end
|
|
@@ -457,23 +497,54 @@ module Permittable
|
|
|
457
497
|
|
|
458
498
|
def cast_float(value)
|
|
459
499
|
case value
|
|
460
|
-
when Numeric then
|
|
461
|
-
when String then
|
|
500
|
+
when Numeric then finite_float(value.to_f)
|
|
501
|
+
when String then finite_float(Float(value), source: value)
|
|
462
502
|
else [:error, "invalid_type"]
|
|
463
503
|
end
|
|
464
504
|
rescue ArgumentError
|
|
465
505
|
[:error, "invalid_type"]
|
|
466
506
|
end
|
|
467
507
|
|
|
508
|
+
# A Float that is not finite does not represent what was sent. "1e400"
|
|
509
|
+
# overflows to Infinity and "1e-400" underflows to zero — both silently,
|
|
510
|
+
# and both leaving a value no column can faithfully store.
|
|
511
|
+
#
|
|
512
|
+
# Underflow is only visible against the source text, since the result is
|
|
513
|
+
# an ordinary 0.0: a zero result is rejected when the string it came from
|
|
514
|
+
# named a nonzero SIGNIFICAND. Only the significand, because "0e10" is a
|
|
515
|
+
# genuine zero whose exponent digits say nothing about the value — as are
|
|
516
|
+
# "0", "0.0" and "0.0000".
|
|
517
|
+
def finite_float(result, source: nil)
|
|
518
|
+
return [:error, "invalid_type"] unless result.finite?
|
|
519
|
+
return [:error, "invalid_type"] if result.zero? && nonzero_significand?(source)
|
|
520
|
+
|
|
521
|
+
[:ok, result]
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def nonzero_significand?(source)
|
|
525
|
+
return false unless source
|
|
526
|
+
|
|
527
|
+
source.split(/[eE]/, 2).first.match?(/[1-9]/)
|
|
528
|
+
end
|
|
529
|
+
|
|
468
530
|
def cast_decimal(value)
|
|
469
531
|
case value
|
|
470
|
-
when Numeric, String then
|
|
532
|
+
when Numeric, String then finite_decimal(BigDecimal(value.to_s))
|
|
471
533
|
else [:error, "invalid_type"]
|
|
472
534
|
end
|
|
473
535
|
rescue ArgumentError
|
|
474
536
|
[:error, "invalid_type"]
|
|
475
537
|
end
|
|
476
538
|
|
|
539
|
+
# BigDecimal has no exponent limit, so a :decimal cannot overflow — but
|
|
540
|
+
# BigDecimal("NaN") and BigDecimal("Infinity") SUCCEED where Float()
|
|
541
|
+
# raises, so a client could send the literal string "NaN" for a price and
|
|
542
|
+
# have it stored. Nothing else in the gem disagreed with itself this
|
|
543
|
+
# loudly: :float rejected those strings and :decimal did not.
|
|
544
|
+
def finite_decimal(result)
|
|
545
|
+
result.finite? ? [:ok, result] : [:error, "invalid_type"]
|
|
546
|
+
end
|
|
547
|
+
|
|
477
548
|
def cast_boolean(value)
|
|
478
549
|
return [:ok, true] if TRUE_VALUES.include?(value)
|
|
479
550
|
return [:ok, false] if FALSE_VALUES.include?(value)
|
|
@@ -517,7 +588,12 @@ module Permittable
|
|
|
517
588
|
def cast_datetime(value)
|
|
518
589
|
case value
|
|
519
590
|
# DateTime is listed here, ahead of Date, because it subclasses Date.
|
|
520
|
-
|
|
591
|
+
# `getutc` rather than `utc`: `Time#utc` converts the RECEIVER, and
|
|
592
|
+
# `Time#to_time` returns self, so `value.to_time.utc` silently rewrote
|
|
593
|
+
# the caller's own object. A TimeWithZone's `getutc` hands back the
|
|
594
|
+
# instance it caches internally, so that one is duped.
|
|
595
|
+
when ActiveSupport::TimeWithZone then [:ok, value.getutc.dup]
|
|
596
|
+
when Time, DateTime then [:ok, value.to_time.getutc]
|
|
521
597
|
when Date then [:ok, Time.utc(value.year, value.month, value.day)]
|
|
522
598
|
when String
|
|
523
599
|
# Same rule as :date — the DATE part must be named in full, or it is
|
|
@@ -618,7 +694,7 @@ module Permittable
|
|
|
618
694
|
if block
|
|
619
695
|
raise ArgumentError, "#{LABEL}: array :#{name} takes of: OR a block, not both" if opts.key?(:of)
|
|
620
696
|
|
|
621
|
-
field[:fields] = nested_fields!(name, &block)
|
|
697
|
+
field[:fields] = cascade_sensitive(nested_fields!(name, &block), field[:sensitive])
|
|
622
698
|
field.delete(:of)
|
|
623
699
|
else
|
|
624
700
|
field[:of] = scalar_type!(name, opts[:of] || :string)
|
|
@@ -642,6 +718,7 @@ module Permittable
|
|
|
642
718
|
assert_opts!(name, opts, NESTED_OPTS)
|
|
643
719
|
field = { name: name, kind: :nested, required: required,
|
|
644
720
|
fields: nested_fields!(name, &block), **opts }
|
|
721
|
+
field[:fields] = cascade_sensitive(field[:fields], field[:sensitive])
|
|
645
722
|
validate_message!(field)
|
|
646
723
|
elsif type&.to_sym == JSON_TYPE
|
|
647
724
|
assert_opts!(name, opts, JSON_OPTS)
|
|
@@ -693,6 +770,43 @@ module Permittable
|
|
|
693
770
|
fields
|
|
694
771
|
end
|
|
695
772
|
|
|
773
|
+
# `sensitive: true` on a nested or array field CASCADES to every field
|
|
774
|
+
# inside it, and the cascade is resolved HERE, at class load, so that
|
|
775
|
+
# `field[:sensitive]` stays the single source of truth every reader
|
|
776
|
+
# consults: the filter registry, the exported schema's `writeOnly`, and
|
|
777
|
+
# the RSpec matcher's `.sensitive` chain. Resolving it privately inside
|
|
778
|
+
# the registry walk would have redacted a cascaded child at runtime
|
|
779
|
+
# while the schema and the matcher went on calling it public.
|
|
780
|
+
#
|
|
781
|
+
# It has to cascade: ActiveSupport::ParameterFilter recurses into Hash
|
|
782
|
+
# and Array values itself and consults proc filters only for the LEAVES,
|
|
783
|
+
# handing each one the leaf's own key and never the path that led there.
|
|
784
|
+
# So registering only `payment` is asked about `card_number`, which it
|
|
785
|
+
# does not match, and redacts nothing inside the container.
|
|
786
|
+
#
|
|
787
|
+
# A sub-field opts out with an explicit `sensitive: false`, because
|
|
788
|
+
# matching is a case-insensitive SUBSTRING match and cascading a generic
|
|
789
|
+
# name (:id, :name) would redact every parameter app-wide that contains
|
|
790
|
+
# it. Only `false` opts out; `sensitive: nil` reads as "not stated" and
|
|
791
|
+
# still inherits.
|
|
792
|
+
def cascade_sensitive(fields, inherited)
|
|
793
|
+
updated = fields.map { |field| cascade_field_sensitive(field, inherited) }
|
|
794
|
+
updated.zip(fields).all? { |new_field, old| new_field.equal?(old) } ? fields : updated.freeze
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
def cascade_field_sensitive(field, inherited)
|
|
798
|
+
declared = field[:sensitive]
|
|
799
|
+
effective = declared.nil? ? inherited : declared
|
|
800
|
+
children = field[:fields] ? cascade_sensitive(field[:fields], effective) : nil
|
|
801
|
+
unchanged = (effective ? declared == true : declared == false || !field.key?(:sensitive)) &&
|
|
802
|
+
(children.nil? || children.equal?(field[:fields]))
|
|
803
|
+
return field if unchanged
|
|
804
|
+
|
|
805
|
+
updated = field.merge(sensitive: effective)
|
|
806
|
+
updated[:fields] = children if children
|
|
807
|
+
updated.freeze
|
|
808
|
+
end
|
|
809
|
+
|
|
696
810
|
def validate_scalar_opts!(field)
|
|
697
811
|
name = field[:name]
|
|
698
812
|
if field[:required] && field.key?(:default)
|
|
@@ -848,6 +962,9 @@ module Permittable
|
|
|
848
962
|
value = field[opt]
|
|
849
963
|
return if authored_nil!(field, opt)
|
|
850
964
|
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array)
|
|
965
|
+
if field[:length] && !Coercion.length_ok?(field[:length], value.length)
|
|
966
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} violates its own contract (length)"
|
|
967
|
+
end
|
|
851
968
|
|
|
852
969
|
validate_array_elements!(field, opt, value) if field[:of]
|
|
853
970
|
validate_array_element_hashes!(field, opt, value) if field[:fields]
|
|
@@ -1091,6 +1208,21 @@ module Permittable
|
|
|
1091
1208
|
end
|
|
1092
1209
|
end
|
|
1093
1210
|
|
|
1211
|
+
# `sensitive: true` on a nested or array field CASCADES to everything
|
|
1212
|
+
# inside it, because Rails' parameter filtering matches the leaf key it is
|
|
1213
|
+
# currently looking at — never the path that led there. Registering only
|
|
1214
|
+
# the container's own name therefore redacted nothing it promised: the
|
|
1215
|
+
# filter is handed ("payment", {...}), a Hash is not a String so nothing
|
|
1216
|
+
# is replaced, and it then recurses and asks about "card_number", which
|
|
1217
|
+
# was never registered.
|
|
1218
|
+
#
|
|
1219
|
+
# A sub-field opts out with an explicit `sensitive: false`. That escape
|
|
1220
|
+
# hatch exists because matching is a case-insensitive SUBSTRING match, so
|
|
1221
|
+
# cascading a generic name (:id, :name) would redact every parameter
|
|
1222
|
+
# app-wide that happens to contain it — occasionally a worse outcome than
|
|
1223
|
+
# the leak it prevents.
|
|
1224
|
+
# The cascade is already resolved on the field data (see
|
|
1225
|
+
# ContractBuilder#cascade_sensitive), so this only has to read it.
|
|
1094
1226
|
def register_sensitive_params(fields)
|
|
1095
1227
|
fields.each do |field|
|
|
1096
1228
|
Permittable.register_sensitive_parameter(field[:name]) if field[:sensitive]
|
|
@@ -1103,18 +1235,31 @@ module Permittable
|
|
|
1103
1235
|
# defaulted values for the given action (default: the current action).
|
|
1104
1236
|
# Absent optional fields are omitted. Raises InvalidParameters on
|
|
1105
1237
|
# violation; raises ArgumentError when no contract covers the action
|
|
1106
|
-
# (that is a programmer error, not a client error).
|
|
1238
|
+
# (that is a programmer error, not a client error).
|
|
1239
|
+
#
|
|
1240
|
+
# Memoized per action, and the memo remembers the OUTCOME rather than only
|
|
1241
|
+
# a success: a rejection is stored and re-raised. Validation is therefore
|
|
1242
|
+
# observable exactly once per action per request, which the
|
|
1243
|
+
# "invalid_parameters.permittable" event depends on — memoizing only
|
|
1244
|
+
# successes meant a rejected request that was read twice (an action calling
|
|
1245
|
+
# permittable_violations before permitted_params, say) instrumented twice
|
|
1246
|
+
# and double-counted itself in every dashboard.
|
|
1107
1247
|
def permitted_params(action = nil)
|
|
1108
1248
|
action = (action || permittable_action_name).to_s
|
|
1109
1249
|
raise ArgumentError, "#{LABEL}: no action given and action_name is not set" if action.empty?
|
|
1110
1250
|
|
|
1111
1251
|
@permittable_validated ||= {}
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1252
|
+
outcome = @permittable_validated.fetch(action) do
|
|
1253
|
+
@permittable_validated[action] = permittable_outcome_for(action)
|
|
1254
|
+
end
|
|
1255
|
+
# `cause: nil` because a memoized rejection is raised from wherever the
|
|
1256
|
+
# action happens to read the params next — possibly inside a `rescue` of
|
|
1257
|
+
# something unrelated, whose exception Ruby would otherwise adopt as this
|
|
1258
|
+
# error's cause for good. The object and its original backtrace (the
|
|
1259
|
+
# first raise site, where the violation was found) are preserved.
|
|
1260
|
+
raise outcome, cause: nil if outcome.is_a?(InvalidParameters)
|
|
1116
1261
|
|
|
1117
|
-
|
|
1262
|
+
outcome
|
|
1118
1263
|
end
|
|
1119
1264
|
|
|
1120
1265
|
# before_action entry point (public so hosts can `skip_before_action
|
|
@@ -1163,6 +1308,19 @@ module Permittable
|
|
|
1163
1308
|
|
|
1164
1309
|
private
|
|
1165
1310
|
|
|
1311
|
+
# The value permitted_params memoizes: the validated params, or the
|
|
1312
|
+
# InvalidParameters that rejected them. ArgumentError is deliberately NOT
|
|
1313
|
+
# memoized — a contract that does not cover the action is a bug to fix, not
|
|
1314
|
+
# a verdict on this request, so it raises fresh on every call.
|
|
1315
|
+
def permittable_outcome_for(action)
|
|
1316
|
+
rule = self.class.permit_rule_for(action)
|
|
1317
|
+
raise ArgumentError, "#{LABEL}: no params contract declared covering ##{action}" unless rule
|
|
1318
|
+
|
|
1319
|
+
validate_params_contract!(rule, action)
|
|
1320
|
+
rescue InvalidParameters => e
|
|
1321
|
+
e
|
|
1322
|
+
end
|
|
1323
|
+
|
|
1166
1324
|
def validate_params_contract!(rule, action)
|
|
1167
1325
|
violations = []
|
|
1168
1326
|
source = permittable_root_hash(rule, violations)
|
|
@@ -1219,7 +1377,25 @@ module Permittable
|
|
|
1219
1377
|
end
|
|
1220
1378
|
|
|
1221
1379
|
def permittable_violation_summary(violations)
|
|
1222
|
-
violations
|
|
1380
|
+
permittable_prose_list(violations) do |v|
|
|
1381
|
+
v[:message] ? "#{v[:param]} #{v[:message]}" : "#{v[:param]} (#{v[:code]})"
|
|
1382
|
+
end
|
|
1383
|
+
end
|
|
1384
|
+
|
|
1385
|
+
# See PROSE_LIST_LIMIT. `unknown: :error` on a request carrying 50,000
|
|
1386
|
+
# undeclared keys used to produce a 50,000-item sentence — a megabyte of
|
|
1387
|
+
# log line, or of exception message handed to every error tracker.
|
|
1388
|
+
# The block formats one item, and is called only for the items actually
|
|
1389
|
+
# shown — the rest are counted, never rendered.
|
|
1390
|
+
def permittable_prose_list(items)
|
|
1391
|
+
shown = items.first(PROSE_LIST_LIMIT).map { |item| permittable_prose_item(yield(item)) }.join(", ")
|
|
1392
|
+
return shown if items.length <= PROSE_LIST_LIMIT
|
|
1393
|
+
|
|
1394
|
+
"#{shown}, and #{items.length - PROSE_LIST_LIMIT} more"
|
|
1395
|
+
end
|
|
1396
|
+
|
|
1397
|
+
def permittable_prose_item(item)
|
|
1398
|
+
item.length <= PROSE_ITEM_LIMIT ? item : "#{item[0, PROSE_ITEM_LIMIT - 3]}..."
|
|
1223
1399
|
end
|
|
1224
1400
|
|
|
1225
1401
|
# One violation detail entry. A field's `message:` (String, or Hash keyed
|
|
@@ -1261,12 +1437,21 @@ module Permittable
|
|
|
1261
1437
|
raw = permittable_plain_params
|
|
1262
1438
|
return raw unless rule[:root]
|
|
1263
1439
|
|
|
1264
|
-
|
|
1440
|
+
key = rule[:root].to_s
|
|
1441
|
+
value = raw[key]
|
|
1265
1442
|
return value if value.is_a?(Hash)
|
|
1266
1443
|
|
|
1444
|
+
# A root that is absent and a root sent with the wrong shape
|
|
1445
|
+
# ({"user": "bob"}) are different client mistakes, and telling a client
|
|
1446
|
+
# that the key it just sent is "missing" sends it looking in the wrong
|
|
1447
|
+
# place. Absence is the gem's own definition of it, so `{"user": ""}`
|
|
1448
|
+
# still reads as missing. Either way the envelope is malformed, so both
|
|
1449
|
+
# remain a 400.
|
|
1450
|
+
#
|
|
1267
1451
|
# No field declares the root, so message resolution can only come from
|
|
1268
1452
|
# I18n ({} has no :message).
|
|
1269
|
-
|
|
1453
|
+
code = permittable_absent?(value, raw, key) ? "missing" : "invalid_type"
|
|
1454
|
+
violations << permittable_violation({}, key, code)
|
|
1270
1455
|
nil
|
|
1271
1456
|
end
|
|
1272
1457
|
|
|
@@ -1341,8 +1526,18 @@ module Permittable
|
|
|
1341
1526
|
end
|
|
1342
1527
|
|
|
1343
1528
|
def permittable_check_array(field, value, path:, unknown:, violations:)
|
|
1529
|
+
# `length:` is a BOUND, not a report. An array outside it is rejected
|
|
1530
|
+
# whatever its contents, so checking those contents can only add work and
|
|
1531
|
+
# noise: a 200k-element payload against `length: 0..10` used to cast every
|
|
1532
|
+
# element, collect 200k more violations, and answer with a multi-megabyte
|
|
1533
|
+
# 422 — for a request already refused by its first check. Stopping here
|
|
1534
|
+
# keeps the cost of an oversized array proportional to rejecting it.
|
|
1535
|
+
if field[:length] && !Coercion.length_ok?(field[:length], value.length)
|
|
1536
|
+
violations << permittable_violation(field, path, "length")
|
|
1537
|
+
return nil
|
|
1538
|
+
end
|
|
1539
|
+
|
|
1344
1540
|
before = violations.length
|
|
1345
|
-
violations << permittable_violation(field, path, "length") if field[:length] && !Coercion.length_ok?(field[:length], value.length)
|
|
1346
1541
|
out = value.each_with_index.map do |element, index|
|
|
1347
1542
|
permittable_check_element(field, element, "#{path}[#{index}]", unknown: unknown, violations: violations)
|
|
1348
1543
|
end
|
|
@@ -1419,8 +1614,8 @@ module Permittable
|
|
|
1419
1614
|
if unknown == :error
|
|
1420
1615
|
extra.each { |key| violations << permittable_violation({}, permittable_path(path, key), "unknown") }
|
|
1421
1616
|
elsif respond_to?(:logger) && logger
|
|
1422
|
-
|
|
1423
|
-
|
|
1617
|
+
listed = permittable_prose_list(extra) { |key| permittable_path(path, key) }
|
|
1618
|
+
logger.warn("#{LABEL}: unknown parameter(s) ignored by the ##{permittable_action_name} contract: #{listed}")
|
|
1424
1619
|
end
|
|
1425
1620
|
end
|
|
1426
1621
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: permittable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Nguyen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|