permittable 0.11.0 → 0.11.1

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: be27cc7405432910133e85a3fb187c0147f14a7a28d98f960c58bc7190fc0271
4
- data.tar.gz: e70282fd762f9a4c57d1b43e0c01b4306193997930b57015efc28b0af576564a
3
+ metadata.gz: be564cf1f5aa7d54ad207d4a32ef3327a7a03346bd3945bdcdcc41eb6f96cca4
4
+ data.tar.gz: 24c723ef714fc5c092ecd243c3391f8aecdc0399454c6af8cafeb0efff750e7c
5
5
  SHA512:
6
- metadata.gz: 61ec3dadad1f5fbc42c1c0403d228dff1d51c9ccaff13b8d455fb5d1ef795a01ddf7b64c0fa5e6dac76984bb22ad208b38ee50a1e6593326eb40704dbeecc8a6
7
- data.tar.gz: '0443649c00422967f30d26d3e5982735433756c2aa1a669e26c10eaeaec707d48c2b4f2992805c3544e0bf68d502e0d9879776e78da2d497a7beacb18949dcb9'
6
+ metadata.gz: da85ce6eb68d7b4a2225dd35b8b43d5e8e3f2bb5ccd9ea03a877b25bba8f7f18f51ecc3e720b0f8a3d68f6e882ef47e955ac293ad513c61ec232a5a1b82c505a
7
+ data.tar.gz: 8401cd45a001ddec34c642af47f2aa6cab961beba5632e952f5868e56f0add46635973934a83c7f385f05682fa5f9f2ad5ee1d5a14800c2f5085bbd6c894d14a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.11.1 (2026-10-18)
4
+ <!-- title: sensitive values stay out of logs and exports, a crash and a wrong label fixed, and generated documents that match what the server does -->
5
+
6
+ Three security fixes and twenty-seven fixes, no new API. A `sensitive:` field of any type is now redacted in ActiveRecord's SQL bind log, an array's `default:` and `example:` are withheld from the exported schema when the array holds a sensitive field, and a sensitive field's own `in:` list and hand-written `format:` are withheld too. A custom three-keyword `render_error` no longer turns every rejection into a 500, the JSON error envelope is labelled `application/json` under `respond_to`, and a `fields_for` form, which Rails itself accepts, is no longer refused. The OpenAPI export and the JSON Schema stop describing things the server does not do: verbs OpenAPI has no field for, optional-group paths Rails never dispatches, a rule's `finalize`, a required decimal, date or datetime that accepts `""`. The generator, the audit and the RSpec matchers lose a set of smaller gaps.
7
+
8
+ Patch rather than minor: nothing that works today stops working. Every entry is a security fix, a crash, a wrong label, input that was wrongly refused, documentation that did not match the code, an export correction that makes the document agree with what the server already does, or development tooling. Five things are visible from outside and worth knowing before you upgrade. In monitor mode a malformed JSON body no longer gets a 400 before the action runs, as monitor mode promised (#115). A sub-field `transform:` below an array that declares `validate:` now runs at boot on the authored `default:` or `example:`, as it already does in the general case since 0.10.0 (#124). Exported documents gain keywords (`minLength: 1` on a required decimal, date or datetime, and `x-permittable-finalize` on a rule with a `finalize` block) and lose content: paths Rails never dispatches (#135: `//{theme}`, `.{b}`, and `/` for `(:locale(/:region))`) and the `default` and `examples` of an array that holds a sensitive field (#132). A committed OpenAPI file diffs once, and a client generated from the old file loses those paths. A spec that only passed because of the bug now fails: `not_to with_default(...)` on a `:json` field (#118) and `not_to matching(...)` with a String preset (#119). And a sensitive field that has an `in:` list or a hand-written `format:` loses them in the export; see the **Security** entries.
9
+
10
+ ### Security
11
+ - **A `sensitive:` field whose value is not a String was logged in the clear in ActiveRecord's SQL bind log.** The Railtie registered each sensitive name only in `config.filter_parameters`, which ActiveRecord copies into `ActiveRecord::Base.filter_attributes` once, when it loads. In a real app that happens before any controller registers a name, so the copy held only the gem's own filter, which redacts Strings and never an Integer, a Date or a Hash: `optional :pin_code, :integer, sensitive: true` logged `4321` in an `INSERT`. The Railtie now adds every sensitive name to `ActiveRecord::Base.filter_attributes` directly, under one lock, and each registration restores any registered name that an app's own `filter_attributes = [...]` assignment dropped. Append to `filter_attributes` rather than assigning it; an aliased or virtual attribute whose name does not contain the param name must still be added to `filter_parameters` by the app, and the README says so. On Rails 6.1 the bind log is unchanged, since it does not filter bind values at all, but `#inspect` now redacts a non-String sensitive value too. (#149)
12
+ - **An array's `default:` and `example:` were exported with the value of a sensitive sub-field inside.** The field's own `default:` and `example:` were already withheld, but `array :cards, default: [{}] do optional :pan, :string, sensitive: true, default: "4111111111111111" end` published the number under the array's `default`. A field now publishes no `default` or `examples` when it is sensitive or holds a sensitive field at any depth, even an empty `default: []`. The whole value is withheld rather than redacted, since a placeholder could fail a sub-field's `format:` or `in:`. An array with nothing sensitive inside, or only sub-fields that opt out with `sensitive: false`, is unchanged. (#132)
13
+ - **A sensitive field's `in:` list and `format:` pattern were exported word for word.** For `optional :pin, :string, in: %w[PIN_SECRET1 PIN_SECRET2], sensitive: true`, the exported enum was the allowlist of the only accepted secrets, and a Range or a regexp leaked through `minimum`/`maximum`, `x-permittable-range` or `x-permittable-pattern`. For a sensitive field, including a child that inherited `sensitive:`, `in:` (a list or a Range) and a hand-written `format:` are now left out, and the field is flagged `x-permittable-custom-validation`, which is how the export already marks a rule that is real but not published. The schema is looser than the server there. A `format:` preset such as `:email` is public and keeps its `format` and `pattern`, `length:` is still published, and an explicit `sensitive: false` under a sensitive parent still publishes. A field marked `sensitive:` for privacy rather than secrecy, with a public set of values (`in: %w[male female other]`), also loses its enum and bounds in the export. (#142)
14
+
15
+ ### Fixed
16
+ - **A three-keyword `render_error` turned every rejection into a 500.** The README said `errors:` is passed only when details exist, but every rejection has details, so a public `render_error(message:, status:, code:)` was called with `errors:` as well and raised `ArgumentError: unknown keyword: :errors` inside `rescue_from`. `errors:` now goes only to a method that can take it: one that names `errors:`, takes `**kwargs`, or declares no keywords at all. A public `render_error` is still always delegated to. (#98)
17
+ - **The JSON error envelope was labelled `text/html` under `respond_to`.** An action that negotiates with `respond_to` and lists `html` first answered an invalid request with the JSON envelope but `Content-Type: text/html`, because `respond_to` sets the content type before the block runs and Rails' `render json:` only supplies one when none is set. The inline envelope is now sent as `application/json` unless the response already carries a JSON type (for example `application/vnd.api+json`), which is kept. A host's own `render_error` keeps its own content type, and `error_format = :problem` already passed its media type. (#147)
18
+ - **A `Parameters` object nested inside a `Contract`'s Hash input was refused.** `Contract#call` converts a top-level `ActionController::Parameters`, but a slice of one inside a plain Hash, such as `CreateUser.call(user: params[:user])`, was reported `invalid_type`. Nested values that answer `to_unsafe_h` are now converted, inside Hashes and Arrays. Input that holds none is passed through as the same object. (#102)
19
+ - **`unknown: :log` did nothing for a standalone `Contract`.** The warning needed a logger on the host, and the Contract's host has none, so `Contract.define(unknown: :log)` behaved as `:ignore`. It now logs through `Rails.logger` when Rails is loaded and has one, read on every call, and otherwise through a `Logger` on `$stderr`. (#131)
20
+ - **A rejected standalone `Contract#call` was instrumented with `action: nil`; the payload's `action` is now `"call"`.** The README documents `payload[:action]` as a String, so a subscriber that interpolated it or called `to_sym` on it got `"Permittable::Contract#"` or a `NoMethodError`. The instrumentation payload's `action` for a standalone Contract is now `"call"` instead of nil, and the `unknown: :log` warning names the contract instead of printing a bare `#`. (#130)
21
+ - **A form's `f.button` or `button_tag` was a 422 under `unknown: :error`, and so was `image_submit_tag`.** `button_tag` submits `button=`, and a browser submitting through an image input posts its click point as `x` and `y` (`save.x` and `save.y` when named). Both are now exempt from the unknown-key check on a rootless contract, as `f.submit`'s `commit` already was. The `x`/`y` pair is exempt only at the top level, only as a pair, and only when both are undeclared non-negative integer Strings. Monitor mode still hands them back untouched. (#129)
22
+ - **A `fields_for` form was refused by an `array ... do` field.** The form builder sends nested records as a Hash keyed by index (`user[addresses_attributes][0][city]`), which strong params permits and `accepts_nested_attributes_for` reads in order. An `array` field now reads a non-empty Hash whose keys are all integers and whose values are all Hashes as the records it encodes, in the order sent, checks them like any array's elements, and hands the action an Array. Any other Hash, an empty one, and an index-keyed Hash of scalars are still `invalid_type`. Violation paths count records in the order sent, which matches the client's keys for `0..n` and not for timestamp keys. `permittable:generate` drafts a `*_attributes` key over a collection association as `array ... do`; a `has_one` keeps its nested block. (#112)
23
+ - **A `:decimal` refused every `Rational`.** The cast built the BigDecimal from `Rational#to_s`, which is `"1/2"`, so even `3r` was `invalid_type` and `default: 1/2r` raised at class load. A Rational whose reduced denominator has no prime factor but 2 and 5 is converted exactly (`Rational(3, 40)` is `0.075`); any other (`1/3`) is still `invalid_type`, because a BigDecimal could hold it only rounded. This reaches a standalone `Contract` and authored `default:`/`example:` values; Rails params never produce a Rational. (#143)
24
+ - **An `example:` under `transform:` was published with the wrong type, and an array-of-hashes `example:` was published transformed.** `optional :age, :integer, example: "18", transform: ->(v) { v * 12 }` exported `"examples": ["18"]` on an integer schema, and a cents `transform:` on a sub-field was run over an array example before it was published. An example is now cast like any other value and never run through `transform:`; an array example is stored as sent, with no transform at any depth and no sub-field `default:` filled in. Its validation still reads it as a request would, so an array-level `validate:` sees the shape it gets from a request, and that read runs only when an array `validate:` consumes it. A class-load walk no longer suppresses every transform below an array that declares its own `transform:`: sub-field `transform:`s now run at class load below an array that declares `validate:`, even when the array has its own `transform:`, and are still skipped elsewhere, so a contract that is valid for every request no longer raises at load. (#124)
25
+ - **A live `in:` object that was empty at class load raised at boot.** An `in:` that is a registry filled by an initializer or `after_initialize` raised "no value can satisfy it" in production, where `eager_load!` runs first, and booted in development. The emptiness check now applies only to what the gem reads itself, a literal list or a Range. An authored `default:` or `example:` is no longer checked for inclusion against a live object; its type, format, length and `validate:` still are. (#125)
26
+ - **Monitor mode's eager check could stop the action.** The README promises that in monitor mode nothing raises and the action runs, but the `before_action` that validates monitor rules reads `params`, so a malformed JSON body answered 400 before the action ran and a `validate:` that raised was a 500. The check now rescues any `StandardError`, logs `[monitor] #create could not be checked: <exception class> — the action runs unchecked`, and lets the action run. Only the class is logged, because a parse error's message can quote the body. An action that then reads `permitted_params` meets the same exception as it would with no contract, and an enforced rule outside monitor mode is unchanged. (#115)
27
+ - **The audit's stale and unmatched-ignore sections were dropped when no action was routed.** `Audit.format` returned early on an empty entry list, which is when a contract left on a renamed route and a mistyped `audit_ignore` entry matter most. It now prints its one-line notice and then both sections; with nothing to add the output is unchanged. (#109)
28
+ - **The OpenAPI export wrote verbs OpenAPI does not define, which made the whole document invalid.** Rails accepts every verb in `ActionDispatch::Request::HTTP_METHODS`, so `via: %i[report mkcalendar search]` wrote `report` as a Path Item key, and OpenAPI 3.1 allows only `get`, `put`, `post`, `delete`, `options`, `head`, `patch` and `trace`. An operation is now placed only at those verbs. One reached only by others is listed under `x-permittable-controllers`, as an unrouted operation is, and one reached by both (`via: [:post, :report]`) is documented under `post`. (#114)
29
+ - **Optional-group variants Rails never dispatches were documented.** Rails adds a leading `/`, squeezes runs of slashes and strips a trailing one before matching, so `scope "(:locale)" { scope "(:theme)" { get "" } }` exported `//{theme}`, `get "(:a)(/:b)"` exported `//{b}`, `get "(:locale(/:region))"` exported `/` for the group absent, and `get "(:a)(.:b)"` exported `.{b}`. A path whose optional group leaves an empty leading segment is no longer documented. `/` is still documented wherever the router answers it. (#135)
30
+ - **A rule's `finalize` block left no trace in the export.** The README and the module comment say `validate:`, `transform:` and `finalize` are flagged with `x-permittable-*` extensions, but only the first two were. A rule that declares `finalize` now carries `x-permittable-finalize: true` on its object schema, inside `root:` when there is one, and on the OpenAPI operation, since an operation whose rule has only path-variable fields has no `requestBody` to carry it. Consumers that filter on `x-permittable-*` will see a new key. (#133)
31
+ - **The export let a required decimal, date or datetime be `""`.** `""` is absent for every type, so the server answers `missing`, but only `:string` told the schema so. A required `:decimal`, `:date` or `:datetime` now exports `minLength: 1`. A nullable field is left out, because it reads `""` as an explicit null. A whitespace-only string for a required `:decimal` still passes the schema and gets a 422, as `"abc"` does. (#134)
32
+ - **Editing an exported description rewrote the contract.** `default:`, `example:`, `in:` and `message:` are deep-copied and frozen when declared; `desc:`, on the rule and on each field, was stored and exported by reference, so a caller that appended "(deprecated)" to an exported document rewrote the contract, and every later export in the process carried it. The stored `desc:` is now a frozen copy, and every export hands out an unfrozen copy, so editing an exported document in place affects only that document. The host's own String is left unfrozen. (#113)
33
+ - **`permittable:generate` read `user_params.permit` as a rootless `params.permit`.** The call patterns had no boundary before `params`, so a helper that merely ends in it matched, and the rootless call beat the model's own `user` root: a `{user: {...}}` request would then be rejected as missing its fields. `params` must now not follow a word character, `@` or `$`; `self.params.permit` and `@_params.require(:user).permit(...)` are still read. (#138)
34
+ - **`permittable:generate` lost the root and the fields of a `%i[...]` list.** RuboCop's default `Style/SymbolArray` rewrites `params.expect(user: [:name, :email])` to `%i[name email]`, and the generator accepted only `[`, so the draft dropped `root: :user` and the fields of `permit(%i[name email])`. A word list of plain keys (`%i`, `%I`, `%w`, `%W`) is now read as the bracketed list it equals, a list among the filters is flattened as Rails flattens it, and an `expect` envelope whose value cannot be read keeps its root with the value as an unparsed TODO. A TODO still quotes the source as written. (#137)
35
+ - **`permittable:generate` skipped `permit` and `expect` calls written without parentheses, and String roots.** `params.require(:user).permit :name, :email` and `params.require("user").permit(:name)` matched nothing, so the draft fell back to columns alone and dropped virtual keys such as `password_confirmation` with no TODO. A paren-less call is now read to the end of its statement (Ruby's own continuation rule), a trailing `if`/`unless`/`rescue` modifier is dropped, a String root that is a plain key is read like a Symbol, and a dropped comment no longer joins its line to the next. A parenthesis in the arguments still skips the call. (#104)
36
+ - **`permittable:generate` drafted `:create, :update` for actions a controller does not define.** A controller with only `create` and `destroy` got an `:update` rule no action could reach, which `permittable:audit` then listed as stale, and an abstract base could be drafted with a rule every controller below it inherits. Each drafted action the controller does not define is now left out, reading actions through `Audit.missing_lookup` so `action_missing`, template-only and inherited actions count as defined. A controller that cannot list its actions is assumed to define both, and one with none left is skipped. (#139)
37
+ - **`permittable:generate` could not find a permitted primary key or timestamp.** The column lookup dropped them, so `permit(:id, :title, :created_at)` drafted `created_at` as a string with `virtual: true` and a "not a database column" TODO. A permitted column is now drafted with its real type, optional even when NOT NULL when the database assigns it. A primary key counts as assigned when the adapter reports `auto_increment?` (or, for an adapter without that flag, when it is a single integer key), or when it has a default or default function; a natural or composite key the client sends is required on create when NOT NULL. (#140)
38
+ - **The type guard refused the generator's own integer-enum recipe.** With `check_column_types` on, following the TODO that every integer-backed enum draft carries (add the stored integers to `in:` and map them back with `transform:`) failed the deploy with "declared :string with an in: listing values it would refuse". The guard now accepts an integer-backed enum's stored integers, as strings, in `in:` when the field declares a `transform:`; anything else in `in:` is still refused, and the error says a `transform:` is what admits them. The TODO now spells a transform that keeps names intact, `->(v) { Order.statuses.key(Integer(v, exception: false)) || v }`; the obvious `key(v.to_i)` read `"shipped"` as `0`. (#103)
39
+ - **The behaviour matchers instrumented a rejection with a nil controller and action.** A subscriber that relies on `payload[:controller]` being a String, as the README documents it (`"users"`), for example `payload[:controller].tr("/", ".")`, raised out of the matcher. The matcher now reports a controller's `controller_path` ("the controller" when anonymous), `Permittable::Contract` for a contract as `Contract#call` does, and the resolved action. (#96)
40
+ - **`with_default` on a `:json` field failed for one of the two key spellings.** A `:json` default is stored as authored but read String-keyed by the action, so `with_default("theme" => "dark")` failed for `default: { theme: "dark" }` and the reverse failed for a String-keyed one. A Hash expected against a `:json` field's Hash default is now compared as a `HashWithIndifferentAccess`, at every depth and inside arrays. A nil default on a nullable `:json` field still fails `with_default({})`. (#118)
41
+ - **`matching("email")` failed for a format preset spelled as a String.** A contract accepts `format: :email` and `format: "email"` alike, but the matcher compared the String against the resolved Regexp. A String is now read as the preset it names, and the failure message and description name it the same way for both spellings. (#119)
42
+ - **A matcher's failure message named the wrong kind of subject.** `permit_param` on a `Contract` said "the controller", and `accept_params` on an anonymous controller class said "the contract". The fallback is now "the contract" for a `Permittable::Contract` and "the controller" for anything else; a named class is still reported by name. (#120)
43
+
44
+ ### Changed
45
+ - **The README did not say a locale switch must wrap the enforce check.** With `enforce: true`, and for every monitor-mode rule, violations are resolved in the `before_action` that `include Permittable` registers, so a locale switch declared as an `around_action` after the include runs after it and the same request gets a different language depending on `enforce:`. The README's I18n section now has a "Per-request locales" paragraph: put the switch first with `prepend_around_action`, or declare it before the include. No behaviour changes. (#148)
46
+ - **The README has a complete example that uses every option.** Documentation only; no behaviour changes. (#92)
47
+
48
+ ### Test suite
49
+ - **The generator spec stubbed a frozen object on Rails main.** A model's `_default_attributes` is a frozen `ActiveModel::ReadOnlyAttributeSet` there, and rspec-mocks cannot proxy a frozen object, so four examples in "when the model raises while it is being read" failed in `before` and broke the `rails main, ruby 4.0` job on every branch. The spec now freezes the set itself and stubs the model's accessor to return a delegator that raises for the two targeted columns. The library needed no change. (#141)
50
+ - **The test matrix runs on every supported Ruby and Rails pair.** `gemfiles/activesupport_6.1.gemfile` now lists base64, benchmark, bigdecimal, drb, logger and mutex_m, because Rails 6.1 needs them on Ruby 3.4 and 4.0 (an app on that pair must list them too), and two non-blocking early-warning jobs run: Ruby head, and Rails main on Ruby 4.0. The gem is unchanged. (#93)
51
+
3
52
  ## 0.11.0 (2026-10-05)
4
53
  <!-- title: an audit ignore list, route-order-stable operationIds, and routes read the way Rails dispatches them -->
5
54