permittable 0.5.2 → 0.7.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 +44 -0
- data/README.md +96 -15
- data/lib/permittable/filter_parameter_registry.rb +11 -2
- data/lib/permittable/generator.rb +8 -5
- data/lib/permittable/json_schema.rb +44 -4
- data/lib/permittable/railtie.rb +17 -1
- data/lib/permittable/rspec.rb +7 -2
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +462 -36
- 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: 9f13494f2345ced22c261510fd65e89b855b263931a423e6abfb3acb9c3df605
|
|
4
|
+
data.tar.gz: b88b5cad51b572acabe91453a322869d3167671854f37f8ec27cd80b2454d361
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f3255855aee7d875af804e0984e3b1939b4018a3d4e51134e15ea61ad53fbcc549e6ec9464ffcc158d5b0a64a0e140a34806b7e37abd24f1b66fa5fbec88bec1
|
|
7
|
+
data.tar.gz: 658e22032c03f6413ad6343d20a8d493c07158975fea4223cac3fcda3a3db71c119d17f4744f79e1c53982560019b8690be6b03dad7a1f7e3cfe1ca68672d414
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 0.7.0 (2026-09-16)
|
|
4
|
+
<!-- title: sensitive: redaction, uncorruptible defaults, and stricter class load -->
|
|
5
|
+
|
|
6
|
+
Two silent disclosures and three silent corruptions, all in code that was doing its job wrongly rather than not at all: `sensitive:` redacted nothing unless the value was a String, a swapped filter registry stopped being consulted, a mutable `default:` was shared by every request in the process, `normalize:` could manufacture an empty value that walked past `required`, and `unknown: :error` rejected the keys Rails itself adds to a form POST. Contract mistakes that could only ever fail at request time now fail at class load instead.
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- **Swapping `Permittable.filter_parameter_registry` silently stopped `sensitive:` redaction.** `Permittable::Railtie` appended `filter_parameter_registry.to_proc` — a proc bound to whichever registry instance existed **at boot**. Rails runs railtie initializers *before* `config/initializers`, so a host gem or app that swaps the registry necessarily does so afterwards, leaving Rails filtering through the old instance: `sensitive:` fields registered themselves in the new registry, and the appended proc went on consulting an empty one. The parameter was logged in the clear, with nothing to indicate it. That swap is the reason the writer exists — the gem's own comment names `concerns_on_rails` as doing exactly this — so the broken ordering was the normal case rather than an exotic one. The Railtie now appends `Permittable.filter_parameter_proc`, which resolves the registry at **filter time**; it is one frozen object for the life of the process, so the Railtie's idempotence check still holds across repeated initializer runs.
|
|
10
|
+
- **`sensitive:` redacted nothing unless the value was a String.** The mechanism was a proc filter, and ActiveSupport's `ParameterFilter` dups the value before invoking one and expects in-place mutation — so `optional :pin, :integer, sensitive: true` logged `1234` in the clear, and `sensitive: true` on a nested block logged the whole hash, since `ParameterFilter` checks `value.is_a?(Hash)` *before* the proc filters and recurses into it instead of ever calling them. Both are silent disclosures of exactly the fields a contract marked as the ones not to print. Each registered name is now **also added to `config.filter_parameters` by name**, which redacts a value of any type, while the proc stays for what only it can reach (consumers that snapshot the array at boot). A real Rails boot covers an `:integer` field, a sensitive nested block, and `#inspect`, with `precompile_filter_parameters` on — the modern default, and the reason the mechanism has to be a name rather than a live matcher object: precompilation joins filters by pattern source and discards anything whose matching is decided at filter time. `Permittable.on_sensitive_parameter` is the seam the Railtie installs, so the registry itself stays free of Rails.
|
|
11
|
+
- **A mutable `default:` was shared by every request.** Field declarations are frozen data, but the *value* an author wrote for `default:` was not, and `HashWithIndifferentAccess` hands a non-frozen Array — and any String — to the result **by reference**. So `array :tags, of: :string, default: []` gave every request the same Array: one request appending to `permitted_params[:tags]` corrupted the default for every later request in the process, and `Model.new(tags: …)` assigns that same object, so `record.tags << x` was enough to trigger it. The corruption outlived the request and lasted for the life of the process. A `default:` (and a documentation `example:`) is now **deep-copied and frozen at class load**, so the contract cannot be corrupted, and each request is handed its own copy. The copy is the point: the object the host app passed in is never frozen, in case it is still using it.
|
|
12
|
+
- **`normalize:` could manufacture an empty value that walked past `required`.** `""` is documented as absent, so a required field must violate — but normalization ran *after* absence had already been decided. `required :name, :string, normalize: :squish` therefore rejected `""` as `missing` and **accepted `" "` as `""`**, writing an empty string into the column: exactly the silent corruption strict coercion exists to refuse, delivered by the gem's own preset. `normalize:` now runs **first**, before the absence rule, so a value that normalizes to empty is absent like any other empty value — it takes the `nullable:` / `default:` / `missing` branch. There is still exactly one reading of absence, and a `normalize:` Proc is still called exactly once per value. Relatedly, a `default:` is now **stored** in the form it was validated in: `default: " free "` with `normalize: :squish` was checked as `"free"` and used to be handed to requests as `" free "`.
|
|
13
|
+
- **`unknown: :error` rejected ordinary form submissions.** Only the router's `controller`/`action`/`format` were exempt from the top-level unknown-key check, but Rails also merges `authenticity_token`, `_method`, `utf8` and `commit` into a form POST — so the strictest setting was unusable outside a JSON API, and every browser form failed on four of the framework's own keys rather than on anything the client got wrong. Those four are now exempt at the top level too. The exemption covers the *check* only, and only at the top level: a form key smuggled inside a `root:` or a nested hash is still `unknown`, a standalone `Permittable::Contract` still exempts nothing (it has neither a router nor a form), and monitor mode still passes the form keys through in its raw hash, where behaving exactly like the pre-contract app is the whole promise and a legacy action may read `_method` itself.
|
|
14
|
+
- **An exported `pattern` could be stricter than the rule the server enforces.** Ruby's `^` and `$` anchor a **line**; ECMA-262's, without the `m` flag, anchor the whole string. So `format: /^\d{5}$/` accepts `"evil\n12345"` at runtime while the exported `"pattern": "^\\d{5}$"` rejects it — the documentation and the enforcement disagreeing, which is the one thing an export from contract data is meant to make impossible. Such a regexp now joins the constructs that stay visible as `x-permittable-pattern` instead of being mistranslated, alongside `\Z`, `\h` and the POSIX classes. Straight after `[` neither one is an anchor — `^` is class negation and `$` a literal — so `[^a]` and `[$]` still translate, as does an escaped `\$` or `\^`. `\A`/`\z` translate exactly and remain the anchors to reach for.
|
|
15
|
+
|
|
16
|
+
- **A block array's `default:` was checked against a different reading of absence than the request it stands in for.** `""` is absent, so a client sending `items: [{ "sku" => "" }]` is refused with `items[0].sku missing` — but the class-load check for a block array's `default:` treated only `nil` as absent, so `default: [{ "sku" => "" }]` was accepted, and a `default:` is applied **without revalidation**: every request that omitted the key was handed the exact value the contract refuses from a client. The same held for a value that normalizes to empty, because the check normalized *after* deciding absence rather than before. That check now reads absence the way the walker does — normalize first, then `nil`/`""`, with `nullable:` splitting the rule for an explicitly-empty value exactly as it does at request time — and the value half of that rule is now one shared predicate rather than two spellings of it.
|
|
17
|
+
|
|
18
|
+
Contracts that declare no `default:`, no `normalize:`, and no `unknown: :error` are unaffected.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- **Swapping the registry now carries the entries it already holds into the new one.** Late-binding the proc fixes redaction for contracts that load *after* a swap, but on its own it breaks the other half: nothing consults the outgoing registry again, so a `sensitive:` field registered by a contract that loaded *before* the swap would have stopped being redacted — the exact mirror image of the bug above, and the case a host gem pooling registrations is most likely to hit, since eager loading in production loads plenty of controllers before `config/initializers` runs. `Permittable.filter_parameter_registry=` now re-adds each name from the outgoing registry (read through a new duck-typed `#names`) to the incoming one, and a real Rails boot covers both halves.
|
|
22
|
+
- **`Permittable.filter_parameter_registry=` validates what it is given.** A registry with no `#to_proc` used to be accepted silently and simply never consulted; with the proc late-bound it would instead have raised `NoMethodError` inside `process_action` on every request. It now raises `ArgumentError` at the point of the swap, naming the class. The registry's callable may take Rails' two-argument (`key, value`) or three-argument (`key, value, original_params`) proc-filter shape; both are dispatched by arity.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- **A bound that no value could satisfy now fails at class load.** A reversed or empty `Range` excludes every value there is, so the field it bounds could never validate — and that surfaced as every request to the action failing on that field: a contract mistake reported to clients as their error, once per request, forever. `in: 65..18`, `length: 5..2`, `length: 3...3`, an empty `in: []`, a negative `length:`, and a `length:` of 0 on a `required` field (where `""` is absent and already violates as `missing`, so nothing is left to accept) are now `ArgumentError` at class load, naming the bound. Endless and beginless Ranges are legitimate and unaffected, as are endpoints that cannot be compared. **Breaking** for a contract that ships such a field, but only for one that was already failing 100% of the requests that reached it.
|
|
26
|
+
- **An array declared with a block now checks its `default:` too.** `validate_array_authored_value!` only checked elements against `of:`, which is nil for a block array — so `array :items, default: [{ "nonsense" => true }] do required :sku, :string end` was accepted at class load and handed to every request that omitted the key, bypassing the contract the block declares. Elements are now checked against the block's own fields (required sub-fields present, scalar ones satisfying their own contract), the same shallow check `of:` gets. **Breaking** for a contract whose block-array default was already wrong, which previously returned that value rather than rejecting it.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
## 0.6.0 (2026-09-08)
|
|
30
|
+
<!-- title: nullable fields, :json, and strict dates -->
|
|
31
|
+
|
|
32
|
+
Two gaps in the field vocabulary closed and one guess removed. A contract can now say *clear this column* (`nullable:`) and *this hash has no shape, but it has bounds* (`:json`), and a date string must name the whole date instead of borrowing the missing parts from today. Contracts that use neither new option see only the date-parsing change, which is called out below.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
- **`:json` field type — free-form hashes, the `jsonb` column case.** A `json`/`jsonb` column exists precisely so its contents need no schema, and every other field kind describes a shape. Until now a contract had only bad options for one: declare sub-keys you don't know, or leave the key undeclared — in which case the contract **silently dropped it** and the column never saw the data. Strong parameters has always had an answer (`params.permit(metadata: {})`); now so does a contract. `optional :metadata, :json` passes an arbitrary Hash through untouched — keys neither filtered nor cast, nested arrays and mixed scalars intact, `unknown:` deliberately not descending into it, `{}` a value rather than an absence, anything that is not a Hash an `invalid_type`. What it gives up is the shape; what it keeps is every bound worth having: **`length:`** caps the top-level key count, **`max_depth:`** caps container nesting with arrays counting as a level (violation code `depth`), `validate:`/`transform:` see the whole hash, and the field maps onto a column like a scalar does, so the schema-drift guard still catches a dropped `metadata` column. That matters more than it looks — an unbounded `jsonb` column is where clients put megabytes and 200-level-deep objects, and "opaque, but not unlimited" is strictly more than `permit(metadata: {})` can say. Values arrive as plain data, never `ActionController::Parameters`, so assigning straight to a `jsonb` attribute is safe. Exported as `{"type": "object"}` with `minProperties`/`maxProperties`, plus `x-permittable-max-depth` for the nesting bound JSON Schema has no keyword for.
|
|
36
|
+
- **`permittable:generate` now drafts `json`, `jsonb` and `hstore` columns as `:json`** instead of leaving a TODO comment. Columns with no faithful representation at all (`binary`, geometry types) still become TODOs rather than guesses.
|
|
37
|
+
|
|
38
|
+
- **`nullable:` field option — an explicit null is now part of a contract's vocabulary.** One absence rule (`nil` and `""` are both absent) is right for `PATCH` and wrong for the request that means *clear this*; `nullable: true` splits it in two for a single field. A key the client never sent stays **absent** — `default:` applies to it, a `required` field still violates `missing` — but a key sent **empty** (JSON `null`, or `""` from a form) is an explicit null and yields `nil` in the result, **ahead of the field's `default:`**, which is exactly what a `PATCH` clearing a column needs. Nothing is cast or checked for an explicit null: `in:`, `format:`, `length:`, `validate:`, and `transform:` never see a `nil` they didn't agree to handle. `required` + `nullable` reads as it does in SQL (the client must state the field; `null` is a legal statement), `default: nil` — legal only on a nullable field — gives the `PUT` reading where absence also means clear, and on arrays and nested blocks `nullable:` applies to the array or object itself, never its contents (a null *element* is still `invalid_type`). Exported JSON Schema / OpenAPI stays truthful: the field's `type` gains `"null"`, and a nullable `in:` set lists `null` in its `enum` (the one keyword that constrains the instance rather than a type). The RSpec matcher gains a `.nullable` chain, and `default: nil` / `example: nil` on a non-nullable field now fails at class load naming the fix, instead of the confusing `invalid_type`.
|
|
39
|
+
|
|
40
|
+
Contracts that don't opt in are byte-for-byte unaffected: absence keeps its single meaning and nothing new appears in an exported schema.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
- **`:date` and `:datetime` invented the parts a string left out, from today's date.** Coercion is documented as strict — "a value the type cannot faithfully represent is a violation, not a guess" — but it handed strings straight to `Date.parse`, which fills in what they omit from the current date: `"09/2026"` became the 1st of September, `"5th"` became the 5th of *this* month of *this* year, `"Sept"` became the 1st of September *this* year. The same request therefore meant different things on different days, which is a guess and a non-deterministic one. A `:date` or `:datetime` string must now name all three of year, month and day; which **format** it names them in is still `Date.parse`'s business, so every complete format it understands keeps working (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`, `"5 September 2026"`). A `:datetime` may still omit the **time** part, which reads as midnight UTC as documented, but a string with only a time (`"10:30"`, previously *today* at 10:30) is now `invalid_type`. `Date`, `Time`, `DateTime` and `ActiveSupport::TimeWithZone` objects are unaffected.
|
|
44
|
+
|
|
45
|
+
This is a **behaviour change** for any endpoint that was relying on the fill-in, but the values it produced were not the ones the client meant, and an exported `"format": "date"` already promised RFC 3339 rather than `"5th"`.
|
|
46
|
+
|
|
3
47
|
## 0.5.2 (2026-09-06)
|
|
4
48
|
<!-- title: Railtie coverage and a new README -->
|
|
5
49
|
|
data/README.md
CHANGED
|
@@ -179,7 +179,9 @@ Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live
|
|
|
179
179
|
- [The field DSL](#the-field-dsl)
|
|
180
180
|
- [Field options](#field-options)
|
|
181
181
|
- [Types and strict coercion](#types-and-strict-coercion)
|
|
182
|
+
- [Free-form hashes](#free-form-hashes-json)
|
|
182
183
|
- [Absence, defaults, and partial updates](#absence-defaults-and-partial-updates)
|
|
184
|
+
- [Explicit nulls](#explicit-nulls-nullable)
|
|
183
185
|
- [Violations and error responses](#violations-and-error-responses)
|
|
184
186
|
- [Custom error messages](#custom-error-messages-message) · [Localizing with I18n](#localizing-default-messages-i18n)
|
|
185
187
|
- [Unknown parameters](#unknown-parameters)
|
|
@@ -208,7 +210,7 @@ Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live
|
|
|
208
210
|
request params
|
|
209
211
|
│
|
|
210
212
|
├─ 1 unwrap root: params[:user] missing or not a hash → 400
|
|
211
|
-
├─ 2 each field normalize → cast → validate → transform
|
|
213
|
+
├─ 2 each field normalize → absent? → cast → validate → transform
|
|
212
214
|
├─ 3 unknown-key check at every nesting level (unknown: :ignore | :log | :error)
|
|
213
215
|
├─ 4 finalize only when nothing violated
|
|
214
216
|
│
|
|
@@ -272,6 +274,9 @@ array :line_items, required: true do
|
|
|
272
274
|
required :sku, :string
|
|
273
275
|
required :quantity, :integer, in: 1..99
|
|
274
276
|
end
|
|
277
|
+
|
|
278
|
+
# Free-form hashes — :json takes any hash, uncast and unfiltered, with bounds
|
|
279
|
+
optional :metadata, :json, max_depth: 3, length: 0..32
|
|
275
280
|
```
|
|
276
281
|
|
|
277
282
|
Arrays are **optional unless `required: true`**, and `length:` on an array constrains the element **count**.
|
|
@@ -285,17 +290,19 @@ Which options are legal depends on the field kind — anything else raises at cl
|
|
|
285
290
|
| `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`) or an `Array` |
|
|
286
291
|
| `format:` | ✅¹ | — | — | Regexp the value must match |
|
|
287
292
|
| `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays |
|
|
288
|
-
| `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **
|
|
289
|
-
| `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load |
|
|
293
|
+
| `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **first** — before the absence rule, so a value that normalizes to `""` is absent |
|
|
294
|
+
| `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) |
|
|
290
295
|
| `validate:` | ✅ | ✅ | — | Callable. Falsy fails as `"invalid"`; a returned `Symbol` becomes the violation code |
|
|
291
296
|
| `transform:` | ✅ | ✅ | — | Callable applied **after** cast and validation — see [output reshaping](#output-reshaping-transform-and-finalize) |
|
|
292
297
|
| `virtual:` | ✅ | ✅ | ✅ | Exempt this field from the schema-drift guard |
|
|
293
298
|
| `sensitive:` | ✅ | ✅ | ✅ | Register the field name for [log redaction](#sensitive-parameters-and-log-redaction) |
|
|
294
299
|
| `message:` | ✅ | ✅ | ✅ | Human-readable copy for violations on this field — a String, or a Hash of code → String. See [custom messages](#custom-error-messages-message) |
|
|
295
300
|
| `of:` | — | ✅ | — | Element type for an array of scalars (default `:string`) |
|
|
301
|
+
| `max_depth:` | — | — | — | `:json` fields only — maximum container nesting. See [free-form hashes](#free-form-hashes-json) |
|
|
296
302
|
| `required:` | — | ✅ | — | Arrays are optional unless this is `true` |
|
|
297
303
|
| `desc:` | ✅ | ✅ | ✅ | Documentation only — the field's `description` in [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) |
|
|
298
304
|
| `example:` | ✅ | ✅ | — | Documentation only, but **validated against the field's own contract at class load**, like `default:` |
|
|
305
|
+
| `nullable:` | ✅ | ✅ | ✅ | An explicitly-sent empty value yields `nil` instead of counting as absent — see [explicit nulls](#explicit-nulls-nullable) |
|
|
299
306
|
|
|
300
307
|
¹ `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.
|
|
301
308
|
|
|
@@ -316,17 +323,49 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
|
|
|
316
323
|
| `:float` | `Numeric`; any `Float()`-parseable string | `"abc"` |
|
|
317
324
|
| `:decimal` | `Numeric` or `String` → `BigDecimal` | Unparseable strings |
|
|
318
325
|
| `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
|
|
319
|
-
| `:date` | `Date`; any `Date.parse
|
|
320
|
-
| `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date
|
|
326
|
+
| `:date` | `Date`; a string naming a **complete** date, in any format `Date.parse` understands (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`) | Unparseable strings, and **incomplete** ones (`"09/2026"`, `"5th"`, `"Sept"`) |
|
|
327
|
+
| `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date`; a string naming a complete date, with or without a time | Unparseable strings, and any string without a complete date (`"10:30"`) |
|
|
328
|
+
| `:json` | Any `Hash` — passed through uncast, see [free-form hashes](#free-form-hashes-json) | Arrays, scalars |
|
|
329
|
+
|
|
330
|
+
**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.
|
|
321
331
|
|
|
322
|
-
Two behaviours worth committing to memory:
|
|
332
|
+
Two more behaviours worth committing to memory:
|
|
323
333
|
|
|
324
334
|
- **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.
|
|
325
335
|
- **Datetimes are normalised to UTC.** A zoneless string parses as UTC regardless of the host timezone, which keeps behaviour deterministic across machines; explicit offsets are honoured and converted.
|
|
326
336
|
|
|
337
|
+
### Free-form hashes (`:json`)
|
|
338
|
+
|
|
339
|
+
A `json`/`jsonb` column exists precisely so its contents need no schema. Every other field kind describes a shape, so until `:json` a contract had only bad options for one: declare sub-keys you don't know, or leave the key undeclared — in which case the contract **silently dropped it**, and the column never saw the data. Strong parameters has always had an answer here (`params.permit(metadata: {})`); now so does a contract.
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
permit_params :create, root: :user, model: User do
|
|
343
|
+
required :name, :string
|
|
344
|
+
optional :metadata, :json, max_depth: 3, length: 0..32
|
|
345
|
+
end
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
The hash passes through **untouched** — keys are neither filtered nor cast, nested arrays and mixed scalars survive, and `unknown:` does not descend into it. `{}` is a value, not an absence. Anything that is not a hash (an array, a string, a number) is `invalid_type`.
|
|
349
|
+
|
|
350
|
+
What you give up is the shape. What you keep:
|
|
351
|
+
|
|
352
|
+
| | |
|
|
353
|
+
|---|---|
|
|
354
|
+
| `length:` | Caps the **top-level key count** — same reading as an array's element count |
|
|
355
|
+
| `max_depth:` | Caps **container nesting**, counting arrays as a level: `{"a": 1}` is 1, `{"a": {"b": 1}}` and `{"a": [1, 2]}` are 2, `{"a": [{"b": 1}]}` is 3. Violation code `depth` |
|
|
356
|
+
| `validate:` / `transform:` | See the whole hash, so any check you can write in Ruby still applies |
|
|
357
|
+
| `model:` | The field maps onto a column like a scalar does, so the [drift guard](#the-schema-drift-guard) still catches a dropped `metadata` column |
|
|
358
|
+
| `sensitive:` / `nullable:` / `message:` / `desc:` / `default:` / `example:` | Behave as on any other field (`default:`/`example:` must be a hash, and are checked against the field's own bounds at class load) |
|
|
359
|
+
|
|
360
|
+
Bounding it matters more than it looks: an unbounded `jsonb` column is where clients put megabytes and 200-level-deep objects. `max_depth:` and `length:` are how a contract says "opaque, but not unlimited" — which is strictly more than `permit(metadata: {})` can say.
|
|
361
|
+
|
|
362
|
+
Values arrive as plain data (`HashWithIndifferentAccess`), never `ActionController::Parameters`, so assigning straight to a `jsonb` attribute is safe.
|
|
363
|
+
|
|
364
|
+
In [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) the field is `{"type": "object"}` plus `minProperties`/`maxProperties`; JSON Schema has no nesting-depth keyword, so `max_depth:` stays visible as `x-permittable-max-depth` rather than being dropped or mistranslated.
|
|
365
|
+
|
|
327
366
|
### Absence, defaults, and partial updates
|
|
328
367
|
|
|
329
|
-
`nil` and `""` are **both treated as absent** — the query-parameter convention, where an untouched form field arrives as an empty string. Boolean `false` is present.
|
|
368
|
+
`nil` and `""` are **both treated as absent** — the query-parameter convention, where an untouched form field arrives as an empty string. Boolean `false` is present. `normalize:` runs *before* this rule, so a field declared `normalize: :squish` treats `" "` as absent too: whitespace cannot satisfy a `required` field by becoming `""`.
|
|
330
369
|
|
|
331
370
|
That single rule produces the behaviour you want from a `PATCH`:
|
|
332
371
|
|
|
@@ -336,10 +375,39 @@ That single rule produces the behaviour you want from a `PATCH`:
|
|
|
336
375
|
| absent and **required** | a `missing` violation |
|
|
337
376
|
| absent with a **`default:`** | the default — a defaulted field can never report `missing` |
|
|
338
377
|
|
|
339
|
-
Declaring `required:` alongside `default:` is a class-load error, since a default implies optionality. And because absence and `nil` are the same thing here,
|
|
378
|
+
Declaring `required:` alongside `default:` is a class-load error, since a default implies optionality. And because absence and `nil` are the same thing here, a plain field cannot clear a column to NULL — declare it [`nullable:`](#explicit-nulls-nullable) when it should.
|
|
340
379
|
|
|
341
380
|
Defaults are checked against the field's own contract when the class loads, so `default: "gold"` on a field declared `in: %w[free pro]` fails at boot rather than on every request.
|
|
342
381
|
|
|
382
|
+
### Explicit nulls (`nullable:`)
|
|
383
|
+
|
|
384
|
+
One rule — `nil` and `""` are absent — is right for `PATCH` and wrong for the request that means *clear this*. `nullable: true` splits it in two for a single field:
|
|
385
|
+
|
|
386
|
+
```ruby
|
|
387
|
+
permit_params :update, root: :user, model: User do
|
|
388
|
+
optional :nickname, :string, nullable: true
|
|
389
|
+
optional :plan, :string, in: %w[free pro], default: "free", nullable: true
|
|
390
|
+
end
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
| Request | `nickname` in the result |
|
|
394
|
+
|---|---|
|
|
395
|
+
| `{ "user": {} }` | **omitted** — the column is untouched |
|
|
396
|
+
| `{ "user": { "nickname": null } }` | `nil` — the column is cleared |
|
|
397
|
+
| `{ "user": { "nickname": "" } }` | `nil` — the form-encoded spelling of the same intent |
|
|
398
|
+
|
|
399
|
+
A key the client never sent is still **absent**: `default:` applies to it and a `required` field still violates with `missing`. Only *present-but-empty* changes meaning, and it changes it decisively — an explicit null wins over the field's `default:`, which is the behaviour a `PATCH` needs (`{ "plan": null }` clears the plan instead of silently resetting it to `"free"`).
|
|
400
|
+
|
|
401
|
+
Nothing is cast or checked for an explicit null. `in:`, `format:`, `length:`, `validate:`, and `transform:` all see a value or nothing at all — never a `nil` they never agreed to handle.
|
|
402
|
+
|
|
403
|
+
Three more readings worth knowing:
|
|
404
|
+
|
|
405
|
+
- **`required` + `nullable`** is coherent, and means what it says in SQL: the client *must* state the field, and `null` is a legal statement. A missing key still violates.
|
|
406
|
+
- **`default: nil`** — legal only on a nullable field — gives the `PUT` reading, where absence *also* means clear.
|
|
407
|
+
- **On arrays and nested blocks**, `nullable:` applies to the array or object itself, never to its contents. `{ "tags": null }` yields `nil` (distinct from `[]`, which still gets length-checked); a null *element* inside `tags` is still `invalid_type`.
|
|
408
|
+
|
|
409
|
+
Exported [OpenAPI](#exporting-openapi-docs-that-cannot-drift) tells the truth about all of this: a nullable field's `type` gains `"null"`, and a nullable `in:` set lists `null` in its `enum`.
|
|
410
|
+
|
|
343
411
|
### Violations and error responses
|
|
344
412
|
|
|
345
413
|
Every failure raises `Permittable::InvalidParameters`, carrying `details` (an array of `{ param:, code: }`, plus a `message:` when the field [declares one](#custom-error-messages-message)) and a `status`. On a real controller it is auto-rescued into the error envelope shown at the [top of this README](#permittable).
|
|
@@ -419,7 +487,9 @@ Resolution order per violation: the field's own `message:` (String, or the Hash
|
|
|
419
487
|
| `:log` | Dropped, with a `logger.warn` naming the full paths |
|
|
420
488
|
| `:error` | Each undeclared key becomes an `unknown` violation |
|
|
421
489
|
|
|
422
|
-
Rails merges `controller`, `action`, and `format`
|
|
490
|
+
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
|
+
|
|
492
|
+
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.
|
|
423
493
|
|
|
424
494
|
### Output reshaping (`transform:` and `finalize`)
|
|
425
495
|
|
|
@@ -485,9 +555,15 @@ Mark a field `sensitive: true` and its name is registered with `Permittable.filt
|
|
|
485
555
|
optional :ssn, :string, sensitive: true
|
|
486
556
|
```
|
|
487
557
|
|
|
488
|
-
|
|
558
|
+
Two mechanisms, because neither covers the ground alone.
|
|
559
|
+
|
|
560
|
+
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`.
|
|
561
|
+
|
|
562
|
+
But a proc filter can only redact **String** values: ActiveSupport dups the value and expects in-place mutation, and for a `Hash` value it never calls the proc at all, recursing into it instead. So each name is **also registered as a name** in `config.filter_parameters`, which redacts a value of any type — an `:integer` field, or a whole sensitive nested block. Appending later still works: Rails' `precompile_filter_parameters` replaces that array *in place*, and `ActionDispatch` reads the same object on every request, so a name registered at class-load time is seen by the next request. (This is also why the mechanism is a name and not a live matcher object: precompilation joins patterns by source, which discards anything whose matching is decided at filter time.)
|
|
563
|
+
|
|
564
|
+
Matching mirrors Rails' own symbol-filter semantics: case-insensitive substring match on the parameter key. The registry is fully duck-typed (`#add`, `#include?`, `#to_proc`, `#names`, `#reset!`) and swappable via `Permittable.filter_parameter_registry=`, so a host gem can pool registrations into its own. `#to_proc` must return a callable of arity 2 (`key, value`) or 3 (`key, value, original_params`), matching what Rails' own parameter filtering accepts; anything that does not respond to `#to_proc` is refused at the point of the swap rather than on the next request.
|
|
489
565
|
|
|
490
|
-
|
|
566
|
+
**The swap works at any point**, including from `config/initializers` — which matters, because Rails runs railtie initializers *before* those, so a swap always happens after `Permittable::Railtie` has appended its filter. Two things make that safe. The appended proc (`Permittable.filter_parameter_proc`) resolves the registry at **filter time** rather than closing over whichever instance existed at boot, so whichever registry is current does the redacting. And the swap **carries the previous registry's names into the new one**, so a `sensitive:` field registered by a contract that loaded before the swap keeps being redacted afterwards. Without that, the two halves of an app would each redact only what the other did not.
|
|
491
567
|
|
|
492
568
|
### Instrumentation
|
|
493
569
|
|
|
@@ -660,7 +736,7 @@ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
|
|
|
660
736
|
|
|
661
737
|
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.
|
|
662
738
|
|
|
663
|
-
**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
|
|
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.
|
|
664
740
|
|
|
665
741
|
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.
|
|
666
742
|
|
|
@@ -714,7 +790,8 @@ Output is deterministic (fixed key order, declaration-order properties), so the
|
|
|
714
790
|
| Constant | Purpose |
|
|
715
791
|
|---|---|
|
|
716
792
|
| `Permittable.filter_parameter_registry` | The live registry of `sensitive:` field names |
|
|
717
|
-
| `Permittable.filter_parameter_registry=` | Swap in your own duck-typed registry |
|
|
793
|
+
| `Permittable.filter_parameter_registry=` | Swap in your own duck-typed registry; entries already registered are carried across |
|
|
794
|
+
| `Permittable.filter_parameter_proc` | The single proc `Permittable::Railtie` appends to `config.filter_parameters`; consults the current registry at filter time |
|
|
718
795
|
| `Permittable.mode` / `Permittable.mode=` | App-wide default (`:enforce`) for rules that don't declare their own `mode:` |
|
|
719
796
|
| `Permittable::InvalidParameters` | Raised on violation; carries `#details` and `#status` |
|
|
720
797
|
| `Permittable::JsonSchema` | Contract data → JSON Schema fragments (`.rule`, `.object`, `.field`) |
|
|
@@ -737,9 +814,13 @@ A bad contract is a programmer error, so it fails when the class loads — never
|
|
|
737
814
|
- An unknown type, listing the supported ones
|
|
738
815
|
- An unknown `normalize:` preset, listing the presets
|
|
739
816
|
- `format:`, `length:`, or `normalize:` on a non-`:string` field
|
|
740
|
-
- `length:` that isn't a `
|
|
817
|
+
- `length:` that isn't a non-negative `Integer` or a `Range`; `in:` that doesn't respond to `include?`
|
|
818
|
+
- A bound **no value could satisfy**: a reversed or empty `Range` (`in: 65..18`, `length: 5..2`, `length: 3...3`), an empty `in:` set, or a `length:` of 0 on a `required` field (where `""` already violates as `missing`)
|
|
741
819
|
- `validate:` or `transform:` that isn't callable
|
|
742
|
-
- A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:`
|
|
820
|
+
- A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:` — or, for an array declared with a **block**, an element that isn't a hash the block would accept
|
|
821
|
+
- A `default: nil` or `example: nil` on a field that isn't `nullable:`
|
|
822
|
+
- A `:json` field's `default:`/`example:` that isn't a Hash, or that its own `length:`/`max_depth:` would reject
|
|
823
|
+
- A `max_depth:` that isn't a positive Integer
|
|
743
824
|
- `required: true` combined with `default:`
|
|
744
825
|
- A field given both a type and a nested block; an array given both `of:` and a block
|
|
745
826
|
- An empty contract, or a nested block declaring no sub-fields
|
|
@@ -10,8 +10,9 @@ module Permittable
|
|
|
10
10
|
#
|
|
11
11
|
# Matching mirrors Rails symbol-filter semantics: case-insensitive substring
|
|
12
12
|
# match on the parameter key. The whole object is duck-typed (#add,
|
|
13
|
-
# #include?, #to_proc, #reset!) so a host can swap in its own
|
|
14
|
-
# `Permittable.filter_parameter_registry=` and pool
|
|
13
|
+
# #include?, #to_proc, #names, #reset!) so a host can swap in its own
|
|
14
|
+
# registry via `Permittable.filter_parameter_registry=` and pool
|
|
15
|
+
# registrations.
|
|
15
16
|
class FilterParameterRegistry
|
|
16
17
|
FILTERED = "[FILTERED]".freeze
|
|
17
18
|
|
|
@@ -55,6 +56,14 @@ module Permittable
|
|
|
55
56
|
@proc
|
|
56
57
|
end
|
|
57
58
|
|
|
59
|
+
# The names registered so far. Permittable.filter_parameter_registry=
|
|
60
|
+
# reads this off the outgoing registry and re-adds each name to the
|
|
61
|
+
# incoming one, so a swap never un-redacts a field that a contract
|
|
62
|
+
# loaded before it had already registered.
|
|
63
|
+
def names
|
|
64
|
+
@mutex.synchronize { @fields.to_a }
|
|
65
|
+
end
|
|
66
|
+
|
|
58
67
|
# Spec hygiene — the registry is process-global.
|
|
59
68
|
def reset!
|
|
60
69
|
@mutex.synchronize do
|
|
@@ -19,14 +19,17 @@ module Permittable
|
|
|
19
19
|
DEFAULT_ACTIONS = %i[create update].freeze
|
|
20
20
|
SKIPPED_COLUMNS = %w[created_at updated_at].freeze
|
|
21
21
|
|
|
22
|
-
# Column type => contract type.
|
|
23
|
-
#
|
|
24
|
-
#
|
|
22
|
+
# Column type => contract type. Document-shaped columns map onto the
|
|
23
|
+
# opaque `:json` field — the shape stays undeclared, which is what a
|
|
24
|
+
# jsonb column is for, and `max_depth:`/`length:` can bound it later.
|
|
25
|
+
# Anything absent here (binary, geometry, ...) has no faithful
|
|
26
|
+
# representation and becomes a TODO comment rather than a guess.
|
|
25
27
|
COLUMN_TYPES = {
|
|
26
28
|
string: :string, text: :string, citext: :string, uuid: :string,
|
|
27
29
|
integer: :integer, bigint: :integer, float: :float, decimal: :decimal,
|
|
28
30
|
boolean: :boolean, date: :date, datetime: :datetime,
|
|
29
|
-
timestamp: :datetime, timestamptz: :datetime
|
|
31
|
+
timestamp: :datetime, timestamptz: :datetime,
|
|
32
|
+
json: :json, jsonb: :json, hstore: :json
|
|
30
33
|
}.freeze
|
|
31
34
|
|
|
32
35
|
# What a source scan recovered from existing permit calls. `scalars` are
|
|
@@ -168,7 +171,7 @@ module Permittable
|
|
|
168
171
|
|
|
169
172
|
def column_line(column)
|
|
170
173
|
type = COLUMN_TYPES[column.type]
|
|
171
|
-
return "# TODO: #{column.name} (#{column.type}) has no
|
|
174
|
+
return "# TODO: #{column.name} (#{column.type}) has no contract type — declare it as a nested block or an array" unless type
|
|
172
175
|
|
|
173
176
|
line = "#{required_column?(column) ? 'required' : 'optional'} :#{column.name}, :#{type}"
|
|
174
177
|
line += " # database default: #{column.default.inspect}" unless column.default.nil?
|
|
@@ -33,16 +33,23 @@ module Permittable
|
|
|
33
33
|
|
|
34
34
|
# Ruby regexp constructs with no ECMA-262 equivalent (\Z, \h, \K, \R, \G,
|
|
35
35
|
# inline flag groups, absence operator, conditionals, POSIX classes,
|
|
36
|
-
# possessive quantifiers)
|
|
37
|
-
# the
|
|
38
|
-
#
|
|
36
|
+
# possessive quantifiers) — and Ruby's ^ and $, which anchor a LINE where
|
|
37
|
+
# ECMA-262 without the m flag anchors the whole string. /^\d{5}$/ accepts
|
|
38
|
+
# "evil\n12345" at runtime, so emitting its source as `pattern` would
|
|
39
|
+
# publish a rule stricter than the server enforces, and an export from
|
|
40
|
+
# contract data is supposed to make that impossible. Straight after a [
|
|
41
|
+
# neither is an anchor — ^ is class negation and $ is a literal — so both
|
|
42
|
+
# stay translatable there. Otherwise the scan is deliberately over-eager
|
|
43
|
+
# on escaped lookalikes, because a wrong pattern in published docs is
|
|
44
|
+
# worse than a missing one.
|
|
39
45
|
UNTRANSLATABLE = /
|
|
40
46
|
\\[ZhHKRG] |
|
|
41
47
|
\(\?[a-z-]+[:)] |
|
|
42
48
|
\(\?~ |
|
|
43
49
|
\(\?\( |
|
|
44
50
|
\[\[: |
|
|
45
|
-
[*+?]\+
|
|
51
|
+
[*+?]\+ |
|
|
52
|
+
(?<![\\\[])[\^$]
|
|
46
53
|
/x
|
|
47
54
|
|
|
48
55
|
# Request-body schema for one rule from `permittable_contracts` /
|
|
@@ -75,12 +82,29 @@ module Permittable
|
|
|
75
82
|
def field(field, unknown: :ignore)
|
|
76
83
|
schema = case field[:kind]
|
|
77
84
|
when :scalar then scalar_schema(field)
|
|
85
|
+
when :json then opaque_schema(field)
|
|
78
86
|
when :nested then object(field[:fields], unknown: unknown)
|
|
79
87
|
when :array then array_schema(field, unknown: unknown)
|
|
80
88
|
end
|
|
89
|
+
nullify!(schema, field)
|
|
81
90
|
annotate(schema, field)
|
|
82
91
|
end
|
|
83
92
|
|
|
93
|
+
# `nullable: true` means an explicitly-sent empty value yields null, so
|
|
94
|
+
# the type gains "null". Assigning over the existing key keeps its
|
|
95
|
+
# position, preserving deterministic emission. `enum` is the one keyword
|
|
96
|
+
# that constrains the instance rather than one type (minLength, pattern,
|
|
97
|
+
# minimum and friends only apply to instances of their own type), so a
|
|
98
|
+
# nullable enum has to list null itself or it would reject the very null
|
|
99
|
+
# the type now permits.
|
|
100
|
+
def nullify!(schema, field)
|
|
101
|
+
return schema unless field[:nullable]
|
|
102
|
+
|
|
103
|
+
schema["type"] = Array(schema["type"]) + ["null"] if schema["type"]
|
|
104
|
+
schema["enum"] += [nil] if schema.key?("enum")
|
|
105
|
+
schema
|
|
106
|
+
end
|
|
107
|
+
|
|
84
108
|
def scalar_schema(field)
|
|
85
109
|
schema = SCALAR_SCHEMAS.fetch(field[:type]).dup
|
|
86
110
|
apply_in!(schema, field[:in])
|
|
@@ -89,6 +113,19 @@ module Permittable
|
|
|
89
113
|
schema
|
|
90
114
|
end
|
|
91
115
|
|
|
116
|
+
# A `:json` field's shape is deliberately undeclared, so the schema says
|
|
117
|
+
# "an object" and carries only the bounds the field does declare. JSON
|
|
118
|
+
# Schema has no nesting-depth keyword, so `max_depth:` stays visible as an
|
|
119
|
+
# extension rather than being dropped or mistranslated.
|
|
120
|
+
def opaque_schema(field)
|
|
121
|
+
schema = { "type" => "object" }
|
|
122
|
+
min, max = length_bounds(field[:length])
|
|
123
|
+
schema["minProperties"] = min if min
|
|
124
|
+
schema["maxProperties"] = max if max
|
|
125
|
+
schema["x-permittable-max-depth"] = field[:max_depth] if field[:max_depth]
|
|
126
|
+
schema
|
|
127
|
+
end
|
|
128
|
+
|
|
92
129
|
def array_schema(field, unknown:)
|
|
93
130
|
schema = { "type" => "array" }
|
|
94
131
|
min, max = length_bounds(field[:length])
|
|
@@ -183,6 +220,9 @@ module Permittable
|
|
|
183
220
|
def json_value(value)
|
|
184
221
|
case value
|
|
185
222
|
when Array then value.map { |v| json_value(v) }
|
|
223
|
+
# An authored `:json` default/example is a whole hash; its values get
|
|
224
|
+
# the same re-encoding as any other authored scalar.
|
|
225
|
+
when Hash then value.to_h { |k, v| [k.to_s, json_value(v)] }
|
|
186
226
|
when BigDecimal then value.to_s("F")
|
|
187
227
|
when Time then value.utc.iso8601
|
|
188
228
|
# DateTime subclasses Date, so it must match first.
|
data/lib/permittable/railtie.rb
CHANGED
|
@@ -9,8 +9,24 @@ module Permittable
|
|
|
9
9
|
class Railtie < Rails::Railtie
|
|
10
10
|
initializer "permittable.filter_parameters",
|
|
11
11
|
before: "active_record.set_filter_attributes" do |app|
|
|
12
|
-
|
|
12
|
+
# Late-bound on purpose — see Permittable.filter_parameter_proc. This
|
|
13
|
+
# initializer runs before config/initializers, so a registry swapped
|
|
14
|
+
# there must still be the one consulted at filter time.
|
|
15
|
+
filter = ::Permittable.filter_parameter_proc
|
|
13
16
|
app.config.filter_parameters << filter unless app.config.filter_parameters.include?(filter)
|
|
17
|
+
|
|
18
|
+
# The proc above redacts Strings, live, and survives precompilation.
|
|
19
|
+
# What it cannot reach is a value that is not a String — ParameterFilter
|
|
20
|
+
# mutates values in place for proc filters, and skips them entirely for
|
|
21
|
+
# a Hash — so each registered name is ALSO added by name, which redacts
|
|
22
|
+
# any value type. Appending later still works: precompilation `replace`s
|
|
23
|
+
# this array in place and ActionDispatch reads the same object per
|
|
24
|
+
# request, so a name registered when a controller loads is seen by the
|
|
25
|
+
# next request. It is the array, not a snapshot, that has to be fed.
|
|
26
|
+
::Permittable.on_sensitive_parameter do |name|
|
|
27
|
+
filters = app.config.filter_parameters
|
|
28
|
+
filters << name unless filters.include?(name)
|
|
29
|
+
end
|
|
14
30
|
end
|
|
15
31
|
|
|
16
32
|
rake_tasks do
|
data/lib/permittable/rspec.rb
CHANGED
|
@@ -91,6 +91,11 @@ module Permittable
|
|
|
91
91
|
self
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
+
def nullable
|
|
95
|
+
@expected[:nullable] = true
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
94
99
|
# -- RSpec protocol ---------------------------------------------------
|
|
95
100
|
|
|
96
101
|
def matches?(subject)
|
|
@@ -191,7 +196,7 @@ module Permittable
|
|
|
191
196
|
when :array then "expected an array field, but it is declared with `#{field[:kind]}`" unless field[:kind] == :array
|
|
192
197
|
when :of then "expected an array of :#{value}, but it is of: :#{field[:of]}" unless field[:of] == value
|
|
193
198
|
when :required then required_mismatch(field, value)
|
|
194
|
-
when :virtual, :sensitive then "expected the field to be #{key}, but it is not" unless field[key]
|
|
199
|
+
when :virtual, :sensitive, :nullable then "expected the field to be #{key}, but it is not" unless field[key]
|
|
195
200
|
else option_mismatch(field, key, value)
|
|
196
201
|
end
|
|
197
202
|
end
|
|
@@ -226,7 +231,7 @@ module Permittable
|
|
|
226
231
|
when :array then "as an array"
|
|
227
232
|
when :of then "of :#{value}"
|
|
228
233
|
when :required then value ? "required" : "optional"
|
|
229
|
-
when :virtual, :sensitive then key.to_s
|
|
234
|
+
when :virtual, :sensitive, :nullable then key.to_s
|
|
230
235
|
else "#{OPTION_LABELS.fetch(key)} #{value.inspect}"
|
|
231
236
|
end
|
|
232
237
|
end
|
data/lib/permittable/version.rb
CHANGED
data/lib/permittable.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "active_support/notifications"
|
|
|
4
4
|
require "active_support/hash_with_indifferent_access"
|
|
5
5
|
require "active_support/core_ext/hash/indifferent_access" # nested plain Hashes inside HWIA.new
|
|
6
6
|
require "active_support/core_ext/class/attribute"
|
|
7
|
+
require "active_support/core_ext/object/deep_dup" # authored default:/example: values are copied before freezing
|
|
7
8
|
require "active_support/core_ext/string/inflections"
|
|
8
9
|
require "active_support/core_ext/string/filters"
|
|
9
10
|
require "bigdecimal"
|
|
@@ -34,6 +35,7 @@ require "permittable/filter_parameter_registry"
|
|
|
34
35
|
# optional :ssn, :string, sensitive: true
|
|
35
36
|
# optional :plan, :string, in: %w[free pro], default: "free"
|
|
36
37
|
# array :tag_names, of: :string, length: 0..10, virtual: true
|
|
38
|
+
# optional :metadata, :json, max_depth: 3, length: 0..32
|
|
37
39
|
# optional :address do
|
|
38
40
|
# required :city, :string
|
|
39
41
|
# optional :zip, :string, format: /\A\d{5}\z/
|
|
@@ -79,6 +81,17 @@ require "permittable/filter_parameter_registry"
|
|
|
79
81
|
# request. `permittable_violations` reads the recorded details ([] when
|
|
80
82
|
# the request was clean).
|
|
81
83
|
#
|
|
84
|
+
# THE :json FIELD — the deliberate hole. A json/jsonb column exists precisely
|
|
85
|
+
# so its contents need no schema, and until it was declarable a contract could
|
|
86
|
+
# only drop that key (strong parameters spells it `permit(metadata: {})`).
|
|
87
|
+
# `optional :metadata, :json` passes an arbitrary Hash through untouched —
|
|
88
|
+
# keys are neither filtered nor cast, and `unknown:` does not descend into it
|
|
89
|
+
# — while still letting the contract bound the shape it refuses to describe:
|
|
90
|
+
# `length:` caps the top-level key count, `max_depth:` caps container nesting
|
|
91
|
+
# (arrays count as a level), and `validate:`/`transform:` see the whole hash.
|
|
92
|
+
# Anything that is not a Hash is `invalid_type`, and the field still maps onto
|
|
93
|
+
# a column for the drift guard.
|
|
94
|
+
#
|
|
82
95
|
# Coercion is deliberately STRICT — ActiveModel::Type is not used, because its
|
|
83
96
|
# casts are lenient by design ("abc".to_i == 0, Boolean.cast("abc") == true)
|
|
84
97
|
# and silently corrupting untrusted input is exactly what a contract must not
|
|
@@ -86,8 +99,21 @@ require "permittable/filter_parameter_registry"
|
|
|
86
99
|
# guess. nil and "" are both treated as ABSENT (the query-param convention):
|
|
87
100
|
# absent optional fields are OMITTED from the result (so partial updates never
|
|
88
101
|
# nil-out columns), absent required fields violate, and `default:` fills
|
|
89
|
-
# absence.
|
|
90
|
-
#
|
|
102
|
+
# absence. `normalize:` runs BEFORE that rule rather than inside the cast, so
|
|
103
|
+
# there is exactly one reading of absence and a value that normalizes to empty
|
|
104
|
+
# (" " under :squish) cannot satisfy a required field by becoming "". An
|
|
105
|
+
# authored `default:`/`example:` is stored normalized — the form it was
|
|
106
|
+
# validated in — and deep-frozen on a copy, so no request can corrupt it for
|
|
107
|
+
# the next.
|
|
108
|
+
#
|
|
109
|
+
# `nullable: true` splits that rule in two for one field, which is how a PATCH
|
|
110
|
+
# clears a column: a key the client never sent stays absent (defaults apply,
|
|
111
|
+
# required violates), but a key sent EMPTY (JSON null, or "" from a form) is an
|
|
112
|
+
# explicit null and yields nil in the result — ahead of any `default:`, and
|
|
113
|
+
# without casting or checking a value that isn't there. It reads on arrays and
|
|
114
|
+
# nested blocks too (the array/object itself may be null, never its elements),
|
|
115
|
+
# and `default: nil` — legal only on a nullable field — gives the PUT reading
|
|
116
|
+
# where absence also means clear.
|
|
91
117
|
#
|
|
92
118
|
# Failures raise Permittable::InvalidParameters, rescued (on a real
|
|
93
119
|
# controller) into the shared ErrorEnvelope shape with `details:` entries of
|
|
@@ -107,7 +133,12 @@ require "permittable/filter_parameter_registry"
|
|
|
107
133
|
# `sensitive: true` registers the field name with
|
|
108
134
|
# Permittable.filter_parameter_registry (swappable — a host gem can point it
|
|
109
135
|
# at its own registry), consulted at filter time by the proc
|
|
110
|
-
# Permittable::Railtie appends to `config.filter_parameters`.
|
|
136
|
+
# Permittable::Railtie appends to `config.filter_parameters`. The name is
|
|
137
|
+
# ALSO published to that Railtie by name (see register_sensitive_parameter),
|
|
138
|
+
# because a proc filter can only redact String values — ActiveSupport dups
|
|
139
|
+
# the value and expects in-place mutation, and never calls the proc at all
|
|
140
|
+
# for a Hash — so a name in config.filter_parameters is what covers an
|
|
141
|
+
# :integer field or a sensitive nested block.
|
|
111
142
|
#
|
|
112
143
|
# OUTPUT RESHAPING — the safe replacement for params-mutating before_actions.
|
|
113
144
|
# Two layers, both operating on the validated COPY (the request's `params` is
|
|
@@ -134,11 +165,39 @@ module Permittable
|
|
|
134
165
|
|
|
135
166
|
LABEL = "Permittable".freeze
|
|
136
167
|
SCALAR_TYPES = %i[string integer float decimal boolean date datetime].freeze
|
|
168
|
+
# Not a scalar: an opaque hash whose shape is deliberately undeclared, for
|
|
169
|
+
# the json/jsonb column a contract has to be able to carry.
|
|
170
|
+
JSON_TYPE = :json
|
|
137
171
|
UNKNOWN_MODES = %i[ignore log error].freeze
|
|
138
172
|
MODES = %i[enforce monitor].freeze
|
|
139
173
|
# Rails merges routing bookkeeping into params; a top-level (root: false)
|
|
140
174
|
# unknown-keys check must not flag them.
|
|
141
175
|
ROUTING_KEYS = %w[controller action format].freeze
|
|
176
|
+
# Nor the keys an ordinary form POST carries — the CSRF token, the verb
|
|
177
|
+
# override, the encoding probe, and the submit button's name. Without this
|
|
178
|
+
# `unknown: :error` was unusable outside a JSON API: every browser form
|
|
179
|
+
# failed on the framework's own keys rather than on anything the client got
|
|
180
|
+
# wrong. Exempt from the CHECK only: unlike the routing keys these are NOT
|
|
181
|
+
# stripped from monitor mode's raw pass-through, where handing back an
|
|
182
|
+
# untouched params hash is the whole promise and a legacy action may well
|
|
183
|
+
# read `_method` itself.
|
|
184
|
+
FORM_KEYS = %w[authenticity_token _method utf8 commit].freeze
|
|
185
|
+
# ROUTING_KEYS/FORM_KEYS name where the keys come FROM; these two name what
|
|
186
|
+
# is DECIDED with them, which is what the call sites care about — and the
|
|
187
|
+
# asymmetry between them is the deliberate point, so spell it once here
|
|
188
|
+
# rather than leaving a bare ROUTING_KEYS to read like an oversight.
|
|
189
|
+
UNCHECKED_TOP_LEVEL_KEYS = (ROUTING_KEYS + FORM_KEYS).freeze
|
|
190
|
+
MONITOR_DROPPED_KEYS = ROUTING_KEYS
|
|
191
|
+
|
|
192
|
+
# The single proc Permittable::Railtie appends to config.filter_parameters.
|
|
193
|
+
# Declared with an optional third parameter so its own arity is -3 and Rails
|
|
194
|
+
# passes `original_params`; the registry's callable is then invoked by ITS
|
|
195
|
+
# arity, so both the 2- and 3-argument proc-filter shapes Rails accepts work
|
|
196
|
+
# as a swapped-in registry's #to_proc.
|
|
197
|
+
FILTER_PARAMETER_PROC = lambda do |key, value, original = nil|
|
|
198
|
+
inner = filter_parameter_registry.to_proc
|
|
199
|
+
inner.arity == 2 ? inner.call(key, value) : inner.call(key, value, original)
|
|
200
|
+
end.freeze
|
|
142
201
|
|
|
143
202
|
NORMALIZERS = {
|
|
144
203
|
squish: ->(v) { v.squish },
|
|
@@ -160,7 +219,84 @@ module Permittable
|
|
|
160
219
|
end
|
|
161
220
|
end
|
|
162
221
|
|
|
163
|
-
|
|
222
|
+
# Swapping registries must not un-redact anything. Contracts that loaded
|
|
223
|
+
# BEFORE the swap registered on the outgoing registry, and after the swap
|
|
224
|
+
# nothing consults it any more — so its entries are carried into the new
|
|
225
|
+
# one, which is the mirror image of the bug that made the proc late-bound
|
|
226
|
+
# in the first place. Validated here rather than at filter time: a
|
|
227
|
+
# registry with no #to_proc used to be silently never consulted, and
|
|
228
|
+
# late-binding it would instead raise NoMethodError on every request.
|
|
229
|
+
def filter_parameter_registry=(registry)
|
|
230
|
+
unless registry.nil? || registry.respond_to?(:to_proc)
|
|
231
|
+
raise ArgumentError,
|
|
232
|
+
"#{LABEL}: filter_parameter_registry must respond to #to_proc (got #{registry.class})"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
@registry_mutex.synchronize do
|
|
236
|
+
previous = @filter_parameter_registry
|
|
237
|
+
@filter_parameter_registry = registry
|
|
238
|
+
next unless registry && previous.respond_to?(:names) && registry.respond_to?(:add)
|
|
239
|
+
|
|
240
|
+
previous.names.each { |name| registry.add(name) }
|
|
241
|
+
end
|
|
242
|
+
registry
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# The proc Permittable::Railtie appends to config.filter_parameters.
|
|
246
|
+
#
|
|
247
|
+
# It resolves the registry at FILTER time rather than closing over
|
|
248
|
+
# whichever instance existed at boot. Rails runs railtie initializers
|
|
249
|
+
# BEFORE config/initializers, so an app or host gem that swaps the
|
|
250
|
+
# registry — the pooling the writer exists for — necessarily does so
|
|
251
|
+
# after the Railtie has already appended its proc. A proc bound to the old
|
|
252
|
+
# instance would go on consulting an empty registry and silently redact
|
|
253
|
+
# nothing, while `sensitive:` fields registered themselves in the new one.
|
|
254
|
+
#
|
|
255
|
+
# One frozen object for the life of the process, so the Railtie's
|
|
256
|
+
# idempotence check (include? before <<) holds across repeated initializer
|
|
257
|
+
# runs with no memo to synchronise.
|
|
258
|
+
def filter_parameter_proc
|
|
259
|
+
FILTER_PARAMETER_PROC
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Every `sensitive:` registration, published to whatever is listening.
|
|
263
|
+
#
|
|
264
|
+
# A proc filter cannot be the whole mechanism: ActiveSupport's
|
|
265
|
+
# ParameterFilter dups the value and expects in-place mutation, so a proc
|
|
266
|
+
# can only redact Strings — and it is never even CALLED for a Hash value,
|
|
267
|
+
# because ParameterFilter checks `value.is_a?(Hash)` first and recurses.
|
|
268
|
+
# So `optional :pin, :integer, sensitive: true` and `sensitive:` on a
|
|
269
|
+
# nested block both logged in the clear. What redacts any value type is a
|
|
270
|
+
# NAME in config.filter_parameters, which only Rails can be told about —
|
|
271
|
+
# hence a sink, installed by Permittable::Railtie, rather than Rails
|
|
272
|
+
# knowledge in this file or in the registry.
|
|
273
|
+
def register_sensitive_parameter(name)
|
|
274
|
+
filter_parameter_registry.add(name)
|
|
275
|
+
# Normalized the way the registry normalizes, so the name a sink sees is
|
|
276
|
+
# the same whether it arrives here or through on_sensitive_parameter's
|
|
277
|
+
# replay of #names — otherwise a sink deduplicating by value would hold
|
|
278
|
+
# both :ssn and "ssn".
|
|
279
|
+
name = name.to_s.downcase
|
|
280
|
+
sensitive_parameter_sinks.each { |sink| sink.call(name) } unless name.empty?
|
|
281
|
+
nil
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Install a sink. It is replayed over the names already registered, since
|
|
285
|
+
# a contract can be declared before the Railtie's initializer runs (a
|
|
286
|
+
# Permittable::Contract at require time, an eager-loaded controller) and
|
|
287
|
+
# would otherwise never reach it.
|
|
288
|
+
def on_sensitive_parameter(&sink)
|
|
289
|
+
@registry_mutex.synchronize { sensitive_parameter_sinks << sink }
|
|
290
|
+
registry = filter_parameter_registry
|
|
291
|
+
registry.names.each { |name| sink.call(name) } if registry.respond_to?(:names)
|
|
292
|
+
sink
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# The installed sinks. Process-global, like the registry — specs that
|
|
296
|
+
# install one `.clear` this afterwards.
|
|
297
|
+
def sensitive_parameter_sinks
|
|
298
|
+
@sensitive_parameter_sinks ||= []
|
|
299
|
+
end
|
|
164
300
|
|
|
165
301
|
# App-wide default for rules that don't declare their own mode:.
|
|
166
302
|
# :enforce (the default) rejects violating requests; :monitor reports
|
|
@@ -227,10 +363,12 @@ module Permittable
|
|
|
227
363
|
TRUE_VALUES = [true, "true", "1", 1].freeze
|
|
228
364
|
FALSE_VALUES = [false, "false", "0", 0].freeze
|
|
229
365
|
|
|
230
|
-
#
|
|
231
|
-
#
|
|
366
|
+
# Pipeline for one scalar field: cast → in / format / length / validate.
|
|
367
|
+
# `normalize:` is NOT applied here — it is its own stage, run by the
|
|
368
|
+
# caller before the absence rule (a value that normalizes to "" is absent
|
|
369
|
+
# like any other empty value), so normalizing again here would call a
|
|
370
|
+
# host's `normalize:` proc twice per value.
|
|
232
371
|
def check_scalar(field, value)
|
|
233
|
-
value = apply_normalize(field[:normalize], value)
|
|
234
372
|
status, value = cast(field[:type], value)
|
|
235
373
|
return [status, value] unless status == :ok
|
|
236
374
|
|
|
@@ -245,6 +383,31 @@ module Permittable
|
|
|
245
383
|
check_custom(field[:validate], value)
|
|
246
384
|
end
|
|
247
385
|
|
|
386
|
+
# Free-form hash. The shape is deliberately undeclared, so the only
|
|
387
|
+
# checks are the bounds the field asked for: breadth (`length:`, the
|
|
388
|
+
# top-level key count, same reading as an array's element count) and
|
|
389
|
+
# nesting (`max_depth:`). Shared with macro-time `default:`/`example:`
|
|
390
|
+
# checking, like check_scalar.
|
|
391
|
+
def check_json(field, value)
|
|
392
|
+
return [:error, "invalid_type"] unless value.is_a?(Hash)
|
|
393
|
+
return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
|
|
394
|
+
return [:error, "depth"] if field[:max_depth] && depth_exceeds?(value, field[:max_depth])
|
|
395
|
+
|
|
396
|
+
check_custom(field[:validate], value)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Container nesting, with the field's own hash as level 1. An Array counts
|
|
400
|
+
# as a level too — a deeply nested payload is a deeply nested payload
|
|
401
|
+
# whichever container carries it. Bails at the first breach instead of
|
|
402
|
+
# measuring the whole tree.
|
|
403
|
+
def depth_exceeds?(value, limit)
|
|
404
|
+
return false unless value.is_a?(Hash) || value.is_a?(Array)
|
|
405
|
+
return true if limit < 1
|
|
406
|
+
|
|
407
|
+
children = value.is_a?(Hash) ? value.each_value : value.each
|
|
408
|
+
children.any? { |child| depth_exceeds?(child, limit - 1) }
|
|
409
|
+
end
|
|
410
|
+
|
|
248
411
|
# A custom validator returning a Symbol fails with that symbol as the
|
|
249
412
|
# violation code; false/nil fails as "invalid"; any other truthy value
|
|
250
413
|
# passes.
|
|
@@ -318,16 +481,37 @@ module Permittable
|
|
|
318
481
|
[:error, "invalid_type"]
|
|
319
482
|
end
|
|
320
483
|
|
|
484
|
+
# Date.parse fills in what a string omits FROM TODAY: "09/2026" becomes
|
|
485
|
+
# the 1st, "5th" becomes this month of this year. That is a guess, and a
|
|
486
|
+
# non-deterministic one — the same request means different things on
|
|
487
|
+
# different days — which is exactly what this coercion exists to refuse.
|
|
488
|
+
# So the string must name all three parts; which format it names them in
|
|
489
|
+
# is Date.parse's business, and every complete format it understands
|
|
490
|
+
# ("2026-09-05", "2026/09/05", "Sep 5, 2026") still works.
|
|
321
491
|
def cast_date(value)
|
|
322
492
|
case value
|
|
323
493
|
when Date then [:ok, value]
|
|
324
|
-
when String
|
|
494
|
+
when String
|
|
495
|
+
found = Date._parse(value)
|
|
496
|
+
return [:error, "invalid_type"] unless complete_date?(found)
|
|
497
|
+
|
|
498
|
+
# Built from the components rather than re-running Date.parse, which
|
|
499
|
+
# would parse the same string a second time — and Date.parse is the
|
|
500
|
+
# expensive half. Date.new applies the same calendar validation, so
|
|
501
|
+
# "2026-02-30" still fails.
|
|
502
|
+
[:ok, Date.new(found[:year], found[:mon], found[:mday])]
|
|
325
503
|
else [:error, "invalid_type"]
|
|
326
504
|
end
|
|
327
505
|
rescue ArgumentError, RangeError
|
|
328
506
|
[:error, "invalid_type"]
|
|
329
507
|
end
|
|
330
508
|
|
|
509
|
+
# Date._parse is the layer under Date.parse, and reports which components
|
|
510
|
+
# it actually FOUND rather than the filled-in result.
|
|
511
|
+
def complete_date?(found)
|
|
512
|
+
found.key?(:year) && found.key?(:mon) && found.key?(:mday)
|
|
513
|
+
end
|
|
514
|
+
|
|
331
515
|
# A zoneless String parses as UTC regardless of the host timezone
|
|
332
516
|
# (deterministic); explicit offsets are honoured and normalised to UTC.
|
|
333
517
|
def cast_datetime(value)
|
|
@@ -335,7 +519,17 @@ module Permittable
|
|
|
335
519
|
# DateTime is listed here, ahead of Date, because it subclasses Date.
|
|
336
520
|
when ActiveSupport::TimeWithZone, Time, DateTime then [:ok, value.to_time.utc]
|
|
337
521
|
when Date then [:ok, Time.utc(value.year, value.month, value.day)]
|
|
338
|
-
when String
|
|
522
|
+
when String
|
|
523
|
+
# Same rule as :date — the DATE part must be named in full, or it is
|
|
524
|
+
# taken from today ("10:30" meant today at 10:30). An absent TIME part
|
|
525
|
+
# is fine and means midnight, which is the documented reading of a
|
|
526
|
+
# date given to a :datetime field.
|
|
527
|
+
#
|
|
528
|
+
# Unlike :date this still parses twice, deliberately: rebuilding a
|
|
529
|
+
# Time from components would have to reimplement DateTime.parse's
|
|
530
|
+
# handling of offsets, zone names and sub-second precision, and
|
|
531
|
+
# getting that subtly wrong costs more than the parse.
|
|
532
|
+
complete_date?(Date._parse(value)) ? [:ok, DateTime.parse(value).to_time.utc] : [:error, "invalid_type"]
|
|
339
533
|
else [:error, "invalid_type"]
|
|
340
534
|
end
|
|
341
535
|
rescue ArgumentError, RangeError
|
|
@@ -350,6 +544,14 @@ module Permittable
|
|
|
350
544
|
normalizer.call(value)
|
|
351
545
|
end
|
|
352
546
|
|
|
547
|
+
# nil and "" are both ABSENT — see the module comment. The VALUE half of
|
|
548
|
+
# that rule (the walker adds the key-presence half), shared with
|
|
549
|
+
# macro-time `default:`/`example:` checking so a default cannot be held
|
|
550
|
+
# to a different reading of absence than the request it stands in for.
|
|
551
|
+
def absent_value?(value)
|
|
552
|
+
value.nil? || (value.is_a?(String) && value.empty?)
|
|
553
|
+
end
|
|
554
|
+
|
|
353
555
|
# Range#include? walks discrete ranges; cover? is the O(1) bounds check
|
|
354
556
|
# and the right semantics for validation.
|
|
355
557
|
def included_in?(allowed, value)
|
|
@@ -365,9 +567,13 @@ module Permittable
|
|
|
365
567
|
# declaration is validated eagerly: a bad contract is a programmer error and
|
|
366
568
|
# should fail at class load, not at request time.
|
|
367
569
|
class ContractBuilder
|
|
368
|
-
SCALAR_OPTS = %i[in format length default normalize validate virtual sensitive transform message desc example
|
|
369
|
-
|
|
370
|
-
|
|
570
|
+
SCALAR_OPTS = %i[in format length default normalize validate virtual sensitive transform message desc example
|
|
571
|
+
nullable].freeze
|
|
572
|
+
NESTED_OPTS = %i[virtual sensitive message desc nullable].freeze
|
|
573
|
+
JSON_OPTS = %i[length max_depth default validate virtual sensitive transform message desc example
|
|
574
|
+
nullable].freeze
|
|
575
|
+
ARRAY_OPTS = %i[of length default validate virtual sensitive required transform message desc example
|
|
576
|
+
nullable].freeze
|
|
371
577
|
|
|
372
578
|
attr_reader :finalizer
|
|
373
579
|
|
|
@@ -437,6 +643,12 @@ module Permittable
|
|
|
437
643
|
field = { name: name, kind: :nested, required: required,
|
|
438
644
|
fields: nested_fields!(name, &block), **opts }
|
|
439
645
|
validate_message!(field)
|
|
646
|
+
elsif type&.to_sym == JSON_TYPE
|
|
647
|
+
assert_opts!(name, opts, JSON_OPTS)
|
|
648
|
+
# `type:` is carried alongside `kind:` so the same `as(:json)` matcher
|
|
649
|
+
# chain and the same error wording work as for a scalar.
|
|
650
|
+
field = { name: name, kind: :json, required: required, type: JSON_TYPE, **opts }
|
|
651
|
+
validate_json_opts!(field)
|
|
440
652
|
else
|
|
441
653
|
assert_opts!(name, opts, SCALAR_OPTS)
|
|
442
654
|
field = { name: name, kind: :scalar, required: required,
|
|
@@ -486,12 +698,18 @@ module Permittable
|
|
|
486
698
|
if field[:required] && field.key?(:default)
|
|
487
699
|
raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
|
|
488
700
|
end
|
|
489
|
-
|
|
490
|
-
|
|
701
|
+
|
|
702
|
+
if field.key?(:in)
|
|
703
|
+
unless field[:in].respond_to?(:include?)
|
|
704
|
+
raise ArgumentError, "#{LABEL}: :in for field :#{name} must respond to include? (Range or Array)"
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
assert_satisfiable!(name, :in, field[:in])
|
|
491
708
|
end
|
|
492
709
|
|
|
493
710
|
validate_string_only_opts!(field)
|
|
494
711
|
validate_length!(name, field[:length]) if field.key?(:length)
|
|
712
|
+
validate_required_length!(field)
|
|
495
713
|
validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
|
|
496
714
|
validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
|
|
497
715
|
resolve_normalizer!(field)
|
|
@@ -500,6 +718,40 @@ module Permittable
|
|
|
500
718
|
validate_message!(field)
|
|
501
719
|
end
|
|
502
720
|
|
|
721
|
+
def validate_json_opts!(field)
|
|
722
|
+
name = field[:name]
|
|
723
|
+
if field[:required] && field.key?(:default)
|
|
724
|
+
raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
validate_length!(name, field[:length]) if field.key?(:length)
|
|
728
|
+
validate_max_depth!(name, field[:max_depth]) if field.key?(:max_depth)
|
|
729
|
+
validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
|
|
730
|
+
validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
|
|
731
|
+
validate_json_authored_value!(field, :default)
|
|
732
|
+
validate_json_authored_value!(field, :example)
|
|
733
|
+
validate_message!(field)
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def validate_max_depth!(name, depth)
|
|
737
|
+
return if depth.is_a?(Integer) && depth.positive?
|
|
738
|
+
|
|
739
|
+
raise ArgumentError, "#{LABEL}: :max_depth for :#{name} must be a positive Integer"
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
# Same rule as a scalar's authored value, over check_json: a `default:` or
|
|
743
|
+
# `example:` that its own bounds would reject fails at class load.
|
|
744
|
+
def validate_json_authored_value!(field, opt)
|
|
745
|
+
return unless field.key?(opt)
|
|
746
|
+
return if authored_nil!(field, opt)
|
|
747
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for :#{field[:name]} must be a Hash" unless field[opt].is_a?(Hash)
|
|
748
|
+
|
|
749
|
+
status, code = Coercion.check_json(field, field[opt])
|
|
750
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})" unless status == :ok
|
|
751
|
+
|
|
752
|
+
field[opt] = freeze_authored(field[opt])
|
|
753
|
+
end
|
|
754
|
+
|
|
503
755
|
# format / length / normalize reason about characters; on any other
|
|
504
756
|
# type they would silently apply to a cast non-String and mislead.
|
|
505
757
|
def validate_string_only_opts!(field)
|
|
@@ -513,9 +765,49 @@ module Permittable
|
|
|
513
765
|
end
|
|
514
766
|
|
|
515
767
|
def validate_length!(name, length)
|
|
516
|
-
|
|
768
|
+
unless length.is_a?(Range) || (length.is_a?(Integer) && !length.negative?)
|
|
769
|
+
raise ArgumentError, "#{LABEL}: :length for :#{name} must be a non-negative Integer or a Range " \
|
|
770
|
+
"(got #{length.inspect})"
|
|
771
|
+
end
|
|
517
772
|
|
|
518
|
-
|
|
773
|
+
assert_satisfiable!(name, :length, length)
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
# A reversed Range (5..2), an exclusive Range with equal endpoints
|
|
777
|
+
# (3...3), or an empty set (in: []) excludes every value there is, so the
|
|
778
|
+
# field it bounds can never validate. That used to surface as every
|
|
779
|
+
# request to the action failing on that field — a contract mistake
|
|
780
|
+
# reported as a client error, once per request, forever. Endless and
|
|
781
|
+
# beginless Ranges are legitimate bounds, and endpoints that cannot be
|
|
782
|
+
# compared are left alone rather than guessed at.
|
|
783
|
+
def assert_satisfiable!(name, opt, bound)
|
|
784
|
+
return unless unsatisfiable?(bound)
|
|
785
|
+
|
|
786
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for :#{name} is empty (#{bound.inspect}) — no value can satisfy it"
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
def unsatisfiable?(bound)
|
|
790
|
+
return bound.empty? if bound.respond_to?(:empty?)
|
|
791
|
+
return false unless bound.is_a?(Range) && bound.begin && bound.end
|
|
792
|
+
|
|
793
|
+
comparison = bound.begin <=> bound.end
|
|
794
|
+
return false if comparison.nil?
|
|
795
|
+
|
|
796
|
+
bound.exclude_end? ? !comparison.negative? : comparison.positive?
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
# "" is ABSENT and an absent required field violates as missing, so a
|
|
800
|
+
# required string can never validly be empty: a maximum length of 0
|
|
801
|
+
# leaves it nothing at all to accept. The exported schema already said
|
|
802
|
+
# so — minLength 1 alongside maxLength 0 — while nothing refused the
|
|
803
|
+
# declaration that produced it.
|
|
804
|
+
def validate_required_length!(field)
|
|
805
|
+
spec = field[:length]
|
|
806
|
+
return unless field[:required] && spec
|
|
807
|
+
return unless Coercion.length_ok?(spec, 0) && !Coercion.length_ok?(spec, 1)
|
|
808
|
+
|
|
809
|
+
raise ArgumentError, "#{LABEL}: :length for :#{field[:name]} is 0 on a required field — an absent or " \
|
|
810
|
+
"empty value already violates as missing, so nothing could satisfy it"
|
|
519
811
|
end
|
|
520
812
|
|
|
521
813
|
def validate_callable!(name, opt, value)
|
|
@@ -538,20 +830,75 @@ module Permittable
|
|
|
538
830
|
# An authored value (`default:`, or a documentation `example:`) must
|
|
539
831
|
# satisfy the field's own contract — catching a lie at class load beats
|
|
540
832
|
# shipping it to every request (or publishing it in generated docs).
|
|
833
|
+
# The authored value is STORED normalized, because that is the form it was
|
|
834
|
+
# validated in: `default: " free "` with `normalize: :squish` was
|
|
835
|
+
# checked as "free" and used to be handed to requests as " free ".
|
|
541
836
|
def validate_authored_value!(field, opt)
|
|
542
837
|
return unless field.key?(opt)
|
|
838
|
+
return if authored_nil!(field, opt)
|
|
543
839
|
|
|
544
|
-
|
|
545
|
-
|
|
840
|
+
value = Coercion.apply_normalize(field[:normalize], field[opt])
|
|
841
|
+
status, code = Coercion.check_scalar(field, value)
|
|
842
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})" unless status == :ok
|
|
546
843
|
|
|
547
|
-
|
|
844
|
+
field[opt] = freeze_authored(value)
|
|
548
845
|
end
|
|
549
846
|
|
|
550
847
|
def validate_array_authored_value!(field, opt)
|
|
551
848
|
value = field[opt]
|
|
849
|
+
return if authored_nil!(field, opt)
|
|
552
850
|
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array)
|
|
553
|
-
return unless field[:of]
|
|
554
851
|
|
|
852
|
+
validate_array_elements!(field, opt, value) if field[:of]
|
|
853
|
+
validate_array_element_hashes!(field, opt, value) if field[:fields]
|
|
854
|
+
field[opt] = freeze_authored(value)
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
# The nested-block counterpart of the of: element check below. Without it
|
|
858
|
+
# `field[:of]` was nil for a block array, so its `default:` skipped
|
|
859
|
+
# validation entirely and whatever was authored went straight to every
|
|
860
|
+
# request that omitted the key. Shallow in the same way the of: check is:
|
|
861
|
+
# required sub-fields must be present and scalar ones must satisfy their
|
|
862
|
+
# own contract, which is what an authored value gets wrong.
|
|
863
|
+
def validate_array_element_hashes!(field, opt, value)
|
|
864
|
+
value.each do |element|
|
|
865
|
+
unless element.is_a?(Hash)
|
|
866
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} contains #{element.class} " \
|
|
867
|
+
"where the block declares a hash"
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
# Wrapped the way permittable_check_element wraps an element at
|
|
871
|
+
# request time, so class load reads keys exactly as a request does.
|
|
872
|
+
indifferent = ActiveSupport::HashWithIndifferentAccess.new(element)
|
|
873
|
+
field[:fields].each { |sub| validate_array_element_field!(field, opt, indifferent, sub) }
|
|
874
|
+
end
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
def validate_array_element_field!(field, opt, element, sub)
|
|
878
|
+
# Normalized before absence is read, and absence read with the runtime's
|
|
879
|
+
# own rule: a default: is applied WITHOUT revalidation, so anything this
|
|
880
|
+
# check waves through is handed to the app unexamined — and "" here used
|
|
881
|
+
# to mean a default could carry the very value a client is refused.
|
|
882
|
+
value = Coercion.apply_normalize(sub[:normalize], element[sub[:name]])
|
|
883
|
+
if Coercion.absent_value?(value)
|
|
884
|
+
# nullable: splits that rule exactly as permittable_explicit_null?
|
|
885
|
+
# does — a key present but empty is an explicit null, not an absence.
|
|
886
|
+
return if sub[:nullable] && element.key?(sub[:name])
|
|
887
|
+
return unless sub[:required]
|
|
888
|
+
|
|
889
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} is missing :#{sub[:name]}, " \
|
|
890
|
+
"which the block declares as required"
|
|
891
|
+
end
|
|
892
|
+
return unless sub[:kind] == :scalar
|
|
893
|
+
|
|
894
|
+
status, code = Coercion.check_scalar(sub, value)
|
|
895
|
+
return if status == :ok
|
|
896
|
+
|
|
897
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} has :#{sub[:name]} " \
|
|
898
|
+
"violating its own contract (#{code})"
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
def validate_array_elements!(field, opt, value)
|
|
555
902
|
value.each do |element|
|
|
556
903
|
status, code = Coercion.cast(field[:of], element)
|
|
557
904
|
next if status == :ok
|
|
@@ -560,6 +907,42 @@ module Permittable
|
|
|
560
907
|
end
|
|
561
908
|
end
|
|
562
909
|
|
|
910
|
+
# A contract is frozen data, but `@fields.map(&:freeze)` freezes only the
|
|
911
|
+
# field hashes — an authored `default:` or `example:` value stayed
|
|
912
|
+
# mutable, and HashWithIndifferentAccess hands a non-frozen Array (and
|
|
913
|
+
# any String) to the result BY REFERENCE. So one request appending to
|
|
914
|
+
# `permitted_params[:tags]` corrupted the default for every later request
|
|
915
|
+
# in the process. Freezing a COPY fixes that without freezing an object
|
|
916
|
+
# the host app passed in and may still be using.
|
|
917
|
+
def freeze_authored(value)
|
|
918
|
+
deep_freeze(value.deep_dup)
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
def deep_freeze(value)
|
|
922
|
+
case value
|
|
923
|
+
when Hash
|
|
924
|
+
value.each_pair do |key, element|
|
|
925
|
+
deep_freeze(key)
|
|
926
|
+
deep_freeze(element)
|
|
927
|
+
end
|
|
928
|
+
when Array then value.each { |element| deep_freeze(element) }
|
|
929
|
+
end
|
|
930
|
+
value.freeze
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
# An authored nil is only meaningful on a nullable field, where it says
|
|
934
|
+
# "absent means clear" (PUT semantics) rather than "no default". On any
|
|
935
|
+
# other field it is a value nil could never satisfy, so it fails at class
|
|
936
|
+
# load with the fix named.
|
|
937
|
+
def authored_nil!(field, opt)
|
|
938
|
+
return false unless field[opt].nil?
|
|
939
|
+
return true if field[:nullable]
|
|
940
|
+
|
|
941
|
+
raise ArgumentError,
|
|
942
|
+
"#{LABEL}: :#{opt} for field :#{field[:name]} is nil but the field is not nullable — " \
|
|
943
|
+
"declare nullable: true to make an explicit null part of the contract"
|
|
944
|
+
end
|
|
945
|
+
|
|
563
946
|
# `message:` customizes what the client reads for a violation on this
|
|
564
947
|
# field: one String covering every code, or a Hash of code => String
|
|
565
948
|
# (codes without an entry keep the default rendering). Keys are
|
|
@@ -694,9 +1077,10 @@ module Permittable
|
|
|
694
1077
|
end
|
|
695
1078
|
|
|
696
1079
|
# The drift guard. Nested/array fields are implicitly virtual — only
|
|
697
|
-
# scalar fields
|
|
1080
|
+
# scalar fields, and the opaque `:json` field standing in for a
|
|
1081
|
+
# json/jsonb column, map one-to-one onto columns.
|
|
698
1082
|
def guard_contract_columns!(model_class, fields)
|
|
699
|
-
checked = fields.select { |f| f[:kind]
|
|
1083
|
+
checked = fields.select { |f| %i[scalar json].include?(f[:kind]) && !f[:virtual] }
|
|
700
1084
|
return if checked.empty?
|
|
701
1085
|
|
|
702
1086
|
types = checked.to_h { |f| [f[:name], f[:type]] }
|
|
@@ -709,7 +1093,7 @@ module Permittable
|
|
|
709
1093
|
|
|
710
1094
|
def register_sensitive_params(fields)
|
|
711
1095
|
fields.each do |field|
|
|
712
|
-
Permittable.
|
|
1096
|
+
Permittable.register_sensitive_parameter(field[:name]) if field[:sensitive]
|
|
713
1097
|
register_sensitive_params(field[:fields]) if field[:fields]
|
|
714
1098
|
end
|
|
715
1099
|
end
|
|
@@ -818,7 +1202,7 @@ module Permittable
|
|
|
818
1202
|
return ActiveSupport::HashWithIndifferentAccess.new unless source
|
|
819
1203
|
|
|
820
1204
|
passed = ActiveSupport::HashWithIndifferentAccess.new(source)
|
|
821
|
-
rule[:root] ? passed : passed.except(*
|
|
1205
|
+
rule[:root] ? passed : passed.except(*MONITOR_DROPPED_KEYS)
|
|
822
1206
|
end
|
|
823
1207
|
|
|
824
1208
|
def raise_invalid_parameters!(violations, status:)
|
|
@@ -900,11 +1284,13 @@ module Permittable
|
|
|
900
1284
|
fields.each do |field|
|
|
901
1285
|
key = field[:name].to_s
|
|
902
1286
|
full = permittable_path(path, key)
|
|
903
|
-
value = hash[key]
|
|
1287
|
+
value = permittable_normalized(field, hash[key])
|
|
904
1288
|
|
|
905
1289
|
if permittable_absent?(value, hash, key)
|
|
906
|
-
if field
|
|
907
|
-
result[key] =
|
|
1290
|
+
if permittable_explicit_null?(field, hash, key)
|
|
1291
|
+
result[key] = nil
|
|
1292
|
+
elsif field.key?(:default)
|
|
1293
|
+
result[key] = permittable_default(field)
|
|
908
1294
|
elsif field[:required]
|
|
909
1295
|
violations << permittable_violation(field, full, "missing")
|
|
910
1296
|
end
|
|
@@ -921,13 +1307,10 @@ module Permittable
|
|
|
921
1307
|
key = field[:name].to_s
|
|
922
1308
|
case field[:kind]
|
|
923
1309
|
when :scalar
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
else
|
|
929
|
-
violations << permittable_violation(field, full, out)
|
|
930
|
-
end
|
|
1310
|
+
# Already normalized by permittable_normalized, before the absence rule.
|
|
1311
|
+
permittable_check_whole(field, Coercion.check_scalar(field, value), full, result, violations: violations)
|
|
1312
|
+
when :json
|
|
1313
|
+
permittable_check_whole(field, Coercion.check_json(field, value), full, result, violations: violations)
|
|
931
1314
|
when :nested
|
|
932
1315
|
if value.is_a?(Hash)
|
|
933
1316
|
result[key] = permittable_check_hash(field[:fields], ActiveSupport::HashWithIndifferentAccess.new(value),
|
|
@@ -944,6 +1327,19 @@ module Permittable
|
|
|
944
1327
|
end
|
|
945
1328
|
end
|
|
946
1329
|
|
|
1330
|
+
# The shared tail of the two kinds whose entire value is checked in one
|
|
1331
|
+
# call — a scalar, or an opaque hash. A clean value is transformed into the
|
|
1332
|
+
# result; anything else records its code.
|
|
1333
|
+
def permittable_check_whole(field, outcome, full, result, violations:)
|
|
1334
|
+
status, out = outcome
|
|
1335
|
+
if status == :ok
|
|
1336
|
+
out = field[:transform].call(out) if field[:transform]
|
|
1337
|
+
result[field[:name].to_s] = out
|
|
1338
|
+
else
|
|
1339
|
+
violations << permittable_violation(field, full, out)
|
|
1340
|
+
end
|
|
1341
|
+
end
|
|
1342
|
+
|
|
947
1343
|
def permittable_check_array(field, value, path:, unknown:, violations:)
|
|
948
1344
|
before = violations.length
|
|
949
1345
|
violations << permittable_violation(field, path, "length") if field[:length] && !Coercion.length_ok?(field[:length], value.length)
|
|
@@ -977,9 +1373,39 @@ module Permittable
|
|
|
977
1373
|
nil
|
|
978
1374
|
end
|
|
979
1375
|
|
|
1376
|
+
# `normalize:` runs BEFORE the absence rule, not inside the cast, so there
|
|
1377
|
+
# stays exactly ONE reading of absence. Otherwise a value that normalizes to
|
|
1378
|
+
# empty walked straight past it: `required :name, :string, normalize:
|
|
1379
|
+
# :squish` rejected "" as missing but accepted " " as "" — the silent
|
|
1380
|
+
# corruption strict coercion exists to refuse, delivered by the gem's own
|
|
1381
|
+
# preset. Only scalars take normalize:, and apply_normalize is itself a
|
|
1382
|
+
# no-op without one, so it owns that decision for every caller.
|
|
1383
|
+
def permittable_normalized(field, value)
|
|
1384
|
+
Coercion.apply_normalize(field[:normalize], value)
|
|
1385
|
+
end
|
|
1386
|
+
|
|
1387
|
+
# An authored default belongs to the contract, which is frozen data (see
|
|
1388
|
+
# ContractBuilder#freeze_authored). HashWithIndifferentAccess copies a
|
|
1389
|
+
# frozen Array or Hash as it assigns it, but stores a String as-is — so
|
|
1390
|
+
# that one is copied here, leaving every value in the result the app's own
|
|
1391
|
+
# to mutate.
|
|
1392
|
+
def permittable_default(field)
|
|
1393
|
+
value = field[:default]
|
|
1394
|
+
value.is_a?(String) ? value.dup : value
|
|
1395
|
+
end
|
|
1396
|
+
|
|
980
1397
|
# nil and "" are both ABSENT — see the module comment.
|
|
981
1398
|
def permittable_absent?(value, hash, key)
|
|
982
|
-
!hash.key?(key) ||
|
|
1399
|
+
!hash.key?(key) || Coercion.absent_value?(value)
|
|
1400
|
+
end
|
|
1401
|
+
|
|
1402
|
+
# `nullable: true` splits the one absence rule in two: a key the client
|
|
1403
|
+
# never sent is still absent (defaults apply, required violates), but a key
|
|
1404
|
+
# sent EMPTY is an explicit null — the field yields nil, so a PATCH can
|
|
1405
|
+
# clear a column. Nothing is cast or checked: there is no value to check,
|
|
1406
|
+
# and `transform:` never sees a nil it did not agree to.
|
|
1407
|
+
def permittable_explicit_null?(field, hash, key)
|
|
1408
|
+
field[:nullable] && hash.key?(key)
|
|
983
1409
|
end
|
|
984
1410
|
|
|
985
1411
|
def permittable_check_unknown(fields, hash, path:, unknown:, top_level:, violations:)
|
|
@@ -987,7 +1413,7 @@ module Permittable
|
|
|
987
1413
|
|
|
988
1414
|
declared = fields.map { |f| f[:name].to_s }
|
|
989
1415
|
extra = hash.keys.map(&:to_s) - declared
|
|
990
|
-
extra -=
|
|
1416
|
+
extra -= UNCHECKED_TOP_LEVEL_KEYS if top_level
|
|
991
1417
|
return if extra.empty?
|
|
992
1418
|
|
|
993
1419
|
if unknown == :error
|
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.7.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-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|