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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f13494f2345ced22c261510fd65e89b855b263931a423e6abfb3acb9c3df605
4
- data.tar.gz: b88b5cad51b572acabe91453a322869d3167671854f37f8ec27cd80b2454d361
3
+ metadata.gz: 9eb3f4de06deb2a19c6a24b870e6f4d718350b67d733feb6d3ba423f27b6cdc9
4
+ data.tar.gz: a1a231373f6d9bab90e1e14ff372c3ab22c163783f3beafb9c4e23e0e46b3d18
5
5
  SHA512:
6
- metadata.gz: f3255855aee7d875af804e0984e3b1939b4018a3d4e51134e15ea61ad53fbcc549e6ec9464ffcc158d5b0a64a0e140a34806b7e37abd24f1b66fa5fbec88bec1
7
- data.tar.gz: 658e22032c03f6413ad6343d20a8d493c07158975fea4223cac3fcda3a3db71c119d17f4744f79e1c53982560019b8690be6b03dad7a1f7e3cfe1ca68672d414
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 missing (this one is a **400**) |
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
- **Status codes.** A missing root key renders **400** — the request is malformed; the envelope you asked for isn't there. Field-level violations render **422** well-formed, semantically wrong.
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`. Memoized per action. 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 |
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 || "").scan(PERMIT_CALL) do |root, args|
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 { |name| "array :#{name}, of: :string # TODO: confirm the element type" }
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
@@ -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 land in `x-permittable-controllers`
15
- # instead of being dropped silently.
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 / invalid, " \
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
- matched = routes_for(routes, key, action)
180
- if matched.empty?
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
- matched.each { |route| (paths[route[:path]] ||= {})[route[:verb].to_s.downcase] = operation }
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 (:id) become OpenAPI templates ({id}).
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.filter_map do |route|
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(/:(\w+)/) { "{#{Regexp.last_match(1)}}" }
205
- { controller: requirements[:controller], action: requirements[:action],
206
- verb: verb.split("|").first.downcase, path: path }
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
 
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.7.0".freeze
2
+ VERSION = "0.8.0".freeze
3
3
  end
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 [:ok, value.to_f]
461
- when String then [:ok, Float(value)]
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 [:ok, BigDecimal(value.to_s)]
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
- when ActiveSupport::TimeWithZone, Time, DateTime then [:ok, value.to_time.utc]
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). Memoized per action.
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
- return @permittable_validated[action] if @permittable_validated.key?(action)
1113
-
1114
- rule = self.class.permit_rule_for(action)
1115
- raise ArgumentError, "#{LABEL}: no params contract declared covering ##{action}" unless rule
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
- @permittable_validated[action] = validate_params_contract!(rule, action)
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.map { |v| v[:message] ? "#{v[:param]} #{v[:message]}" : "#{v[:param]} (#{v[:code]})" }.join(", ")
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
- value = raw[rule[:root].to_s]
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
- violations << permittable_violation({}, rule[:root].to_s, "missing")
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
- logger.warn("#{LABEL}: unknown parameter(s) ignored by the ##{permittable_action_name} contract: " \
1423
- "#{extra.map { |key| permittable_path(path, key) }.join(', ')}")
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.7.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-16 00:00:00.000000000 Z
11
+ date: 2026-09-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport