permittable 0.14.0 → 0.15.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 +20 -0
- data/README.md +9 -7
- data/lib/permittable/audit.rb +64 -7
- data/lib/permittable/authored_values.rb +23 -11
- data/lib/permittable/column_guard.rb +133 -12
- data/lib/permittable/contract.rb +4 -1
- data/lib/permittable/json_schema.rb +1 -1
- data/lib/permittable/open_api.rb +15 -0
- data/lib/permittable/rspec.rb +154 -23
- data/lib/permittable/tasks/audit.rake +3 -2
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +131 -27
- 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: 7e1419e11eb73f0b94274aa8f6a868e81913f64a754bfecde44885e83a825942
|
|
4
|
+
data.tar.gz: 74a084502a3328e692fd522c719d16e8242fb2a12a9602e3da76c3e6076be426
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: de34efb031486908abdf6b26293108f83505dac300ce9a1977b88439c5677d058950a567bd79ead0aefe6f03605d7d312eede2215844393855beb098c99d2819
|
|
7
|
+
data.tar.gz: 95420ccea0d5842aaea3877ed3343ab444c7bc9a505efdad76b1b0b23fae1ab7bb636ba367cb9e4ad69151ae35e9a2491c05d6746d6cd31b87bb41cb5744c1be
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 0.15.0 (2026-11-12)
|
|
4
|
+
<!-- title: zone names and hours that Date._parse drops honoured or refused, an export that matches what the server stores, an audit that stops reporting rules that cannot run, matchers that redact what they print, and a column guard that knows aliases and store accessors -->
|
|
5
|
+
|
|
6
|
+
Five changes to what a string, an export, an audit, a matcher or a model is held to, and one to what a model may declare. A `:date` or `:datetime` string that carries a zone name, an hour or an offset that `Date._parse` reads only in some positions is read as the instant it names, or refused, where the part was dropped (#174). The export refuses a non-finite `BigDecimal` and a sub-nanosecond `Time` in an authored `:json` value, publishes a `:date` member as `YYYY-MM-DD` (#175), and lists Rails' form keys in a rootless `unknown: :error` JSON body (#178). `permittable:audit` reports a bare `ActionController::Metal` rule that cannot run as uncovered, and lists the controllers a route names that nothing has loaded (#176). The matchers read a Symbol value as the String a request carries and redact sensitive values from failure messages (#177). A model's `alias_attribute` and `store_accessor` fields are checked against the column that backs them, where 0.14.0 refused them at class load (#178).
|
|
7
|
+
|
|
8
|
+
Minor rather than patch: each entry can make a request, a boot, a spec, an audit or an export that passes today fail, or read differently. Before upgrading, read the entries that match what you use. A request can now be refused: an ISO date followed by `T` and no hour (`"2026-09-05T10"`, `"2026-09-05T1030"`, `"2026-09-05T10Z"`), any unknown word after a complete date (`"2026-09-05 foo"`), an initial-led zone name Ruby does not know (`"W. Europe"`), an hour or offset the parser misplaces (`"2026-09-05 10 EST"`, `"2026-09-05 10 -05:00"`), an offset after the year in a form other than git's or `ctime`'s, and a zone name with an offset after the year (`"Sat Sep 5 10:00:00 2026 UTC+0000"`), all as `invalid_type`, and a `:date` string with a zone name that does not name midnight UTC (`"2026-09-05 EST"`) (#174). A boot can now fail: an authored `default:` or `example:` on a `:json` field that holds a non-finite `BigDecimal` or a `Time` or `DateTime` finer than a nanosecond, at any depth (#175), and an `in:`, `default:` or `example:` written as a time-carrying `:date` string that is not exactly midnight UTC (#174). Nothing that loaded on 0.14.0 newly fails because of #178: on 0.14.0 an alias or store accessor field was refused at class load as a missing column, so its checks reject nothing that loaded. An action can now receive something different: a `:datetime` such as `"2026-09-05 EST"` is `05:00Z` where it was `00:00Z`, and `"Sat Sep 5 10:00:00 2026 EST"` or `"... -0500"` is `15:00Z` where it was `10:00Z` (#174), and `nil` for an omitted nullable `:json` field with `default: ""` (#175). An export now reads differently: a `:date` `in:` member written as a String prints as `YYYY-MM-DD` (#175), and a rootless `unknown: :error` JSON body lists `authenticity_token`, `_method`, `utf8`, `commit` and `button` as free optional properties (#178). An audit can now fail: a gate on uncovered actions fails for a bare Metal controller rule that cannot run, which was covered, and for a routed controller that the audit now loads, whose file now runs and whose load error now raises (#176). And a spec can fail where it passed: a controller matcher given a Symbol value that asserted a rejection now sees an acceptance, a `:json` field's Symbol values come back as Strings in a matcher's returned hash (#177), and a `returning` message that showed a sensitive value now shows the placeholder (#177).
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`permittable:audit` lists the controllers a route names that nothing has loaded.** The audit collected controllers from `ActionController::Metal.descendants`, which holds only loaded classes, so `Rails::HealthController` behind `GET /up` had no row. `Audit.rails_controllers(routes)` now adds every controller a route names, constantized; a missing constant, or one that is not an `ActionController::Metal` class, is skipped. A controller from a gem is still ignored by default, and its row shows the ignore reason. Running the audit loads those controllers, so a controller with an import-time side effect runs it, and an error raised inside a controller's file is now raised, not rescued as a `NameError`. (#176)
|
|
12
|
+
- **The matchers print a hash with its sensitive values redacted, and say where it differs.** A `returning` mismatch printed the whole returned hash with `inspect`, and a `returning` expectation as written, so a CI log carried `sensitive:` values and the values in `config.filter_parameters`. Both now print the registry's placeholder, by the names from `sensitive:` (matched by substring, like Rails) and, under a booted Rails app, `config.filter_parameters`; under a `root:` contract a scoped filter such as `"payment.number"` applies, and an `ActionController::Parameters` is unwrapped first. When redaction hides a `returning` mismatch the message adds `differs at: <path>`, naming only paths. Matching still compares the real values, and a pass or fail is unchanged. (#177)
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- **A `:date` or `:datetime` string reads or refuses the zone, offset or hour `Date._parse` drops.** `Date._parse` reports a zone only beside a time of day, so a zone name after a bare date, an hour after an ISO `T` and an offset after the year (git and `ctime` format) were dropped or misplaced, and the value came from the date alone. A zone name Ruby knows is applied (`"2026-09-05 EST"` is `05:00Z` as a `:datetime`, not `00:00Z`; `"Sat Sep 5 10:00:00 2026 EST"` and `"... -0500"` are `15:00Z`, not `10:00Z`; `"20260905 10 EST"` is `15:00Z`), and an offset after the year of git's and `ctime`'s format is read the same way. These are refused as `invalid_type`: an ISO `T` with no hour, any unknown word after a complete date, an initial-led zone name Ruby does not know (`"W. Europe"`, `"S. Africa Standard Time"`), an hour or offset the parser misplaces, and an offset after the year in any other form. Dotted eras (`A.D.`, `B.C.`) are skipped like `AD` and `BC`, and a single letter is never a zone. A `:date` string that carries a time or zone is still accepted only at exactly midnight UTC, so a `:date` zone name that moves the instant, `EST` included, is now refused, and the same rule fails an `in:`, `default:` or `example:` member at class load. The value is a plain `Date` built from its year, month and day. Date-only strings, `Date`, `Time`, `DateTime`, `TimeWithZone` and a zone name that names midnight UTC are unchanged. Still read as before: `"2026-09-05 10 A.D."`, `"2026-09-05 10 A."` and `"2026-09-05 GMT+5"` drop the hour or offset. (#174)
|
|
16
|
+
- **An authored `:json` value that the export cannot carry fails at class load, and a `:date` member is published as a date.** The check from #163 covered only `Float`. A `default:` or `example:` for a `:json` field that holds a non-finite `BigDecimal` (`NaN`, `Infinity`, `-Infinity`) or a `Time` or `DateTime` finer than a nanosecond, at any depth, raises `ArgumentError` naming the field and the path (`a.b[1].c`), with the hint to round a `Time` with `.round(9)`; 0.14.0 loaded the class and exported the `BigDecimal` as a String and the `Time` as a different instant. A `:date` `in:` member written as a String is exported as `YYYY-MM-DD`, so `in: ["Sep 5, 2026", Date.new(2026, 9, 6)]` is `["2026-09-05", "2026-09-06"]`, where the first value failed the field's own `format: date`; a member past year 9999 is published in a spelling the server reads back as the same `Date`. A `:datetime` String member is still published as written. The runtime cast of a request is unchanged. (#175)
|
|
17
|
+
- **A blank default on a nullable `:json` field reads as `nil`.** `optional :prefs, :json, nullable: true, default: ""` failed at class load with "must be a Hash", while a request that sends `""` for the field reads as `nil`. It now loads with `nil` as its default, and `with_default("")` reads as `nil` in the matcher. A non-nullable `:json` field given `""` still raises. (#175)
|
|
18
|
+
- **A Symbol value in a controller matcher payload is read as the String a request carries.** The matcher stringified Symbol keys but passed a Symbol value through, so `accept_params(status: :active)` against `in: %w[active]` failed with `invalid_type` for a request the app accepts. A `:json` field's Symbol values come back as Strings in the returned hash. A standalone `Contract` still gets the payload as given, and a Symbol there is still `invalid_type`. A bare `ActionDispatch` module, one defined without `ActionDispatch::Request`, no longer makes a matcher raise for a missing normalizer. (#177)
|
|
19
|
+
- **A bare `ActionController::Metal` rule that cannot run is audited as uncovered.** `Audit.entries` marked an action covered whenever a rule was declared. An action whose `before_action` the gem never registered, because `AbstractController::Callbacks` was missing when `permit_params` ran, and whose effective mode (the rule's own `mode:` or `Permittable.mode`) is `enforce` or `monitor`, is now uncovered, with the note "contract cannot run: controller lacks AbstractController::Callbacks (no before_action)". Including the module after `permit_params` does not make it covered. The class-load check in `permit_params` is unchanged and still reads only the rule's own `mode:`, so such a controller still loads, and the README says so. (#176)
|
|
20
|
+
- **The exported JSON body of a rootless `unknown: :error` contract lists Rails' form keys.** The runtime exempts `authenticity_token`, `_method`, `utf8`, `commit` and `button` at the top of a rootless contract, but the body schema set `additionalProperties: false` without them, so a body the server accepts failed validation against the export. They are now free, optional properties, and a name the contract declares keeps its own schema. A `root:` rule, a multipart body, `JsonSchema.rule` and `Contract#json_schema` are unchanged. A renamed CSRF token and `format` are not listed, as for #165. (#178)
|
|
21
|
+
- **`alias_attribute` and `store_accessor` fields are checked against the column behind them.** The column guard looked a field up by its own name, so an alias or a store key failed at class load as a missing column although assignment accepts it. An alias resolves to its target through any chain, so an alias of an enum, or an enum declared on the alias name, goes through the enum contract, and under `Permittable.check_column_types` its type is compared with the target's. A `store_accessor` is accepted when its store column exists and has no type to compare; it is accepted only under the name its writer takes (`settings_theme` with `prefix: true`, not the bare key), and a subclass finds an accessor a parent declared. An error for a missing alias target or store column now names the target or the store, with its migration. (#178)
|
|
22
|
+
|
|
3
23
|
## 0.14.0 (2026-11-09)
|
|
4
24
|
<!-- title: a :file field type with a multipart export, date strings read as the instant they name, Metal controllers that enforce whatever the include order, and matchers that see what a request sees -->
|
|
5
25
|
|
data/README.md
CHANGED
|
@@ -538,7 +538,7 @@ Validation is **lazy by default**: it runs on the first `permitted_params` call,
|
|
|
538
538
|
|
|
539
539
|
In [monitor mode](#monitor-mode-roll-out-without-rejecting) the same flow runs, but a violation is reported instead of raised and the request proceeds with the raw params passed through.
|
|
540
540
|
|
|
541
|
-
A bare `ActionController::Metal` controller has no callbacks and no `rescue_from` until it includes them. `include AbstractController::Callbacks` and then `include ActionController::Rescue` (the order `ActionController::Base` uses, so a rejection also covers the `before_action`), before or after `include Permittable`. The rendering modules (`AbstractController::Rendering`, `ActionController::Rendering`, `ActionController::Renderers::All`) are optional: without `ActionController::Rendering` the gem writes the error response itself, with the same status, content type and JSON body. An `enforce: true` or `mode: :monitor
|
|
541
|
+
A bare `ActionController::Metal` controller has no callbacks and no `rescue_from` until it includes them. `include AbstractController::Callbacks` and then `include ActionController::Rescue` (the order `ActionController::Base` uses, so a rejection also covers the `before_action`), before or after `include Permittable`. The rendering modules (`AbstractController::Rendering`, `ActionController::Rendering`, `ActionController::Renderers::All`) are optional: without `ActionController::Rendering` the gem writes the error response itself, with the same status, content type and JSON body. An `enforce: true` rule, or a rule declaring `mode: :monitor`, raises `ArgumentError` at class load when `AbstractController::Callbacks` is missing. The class-load check reads only the rule's own `mode:`, not the app-wide `Permittable.mode`, which an initializer usually sets after the controller loads. A rule with no `mode:` under an app-wide `Permittable.mode = :monitor` therefore loads, but its `before_action` is not there to run it: `permittable:audit` reports those actions as `no contract` (`contract cannot run`) instead of as monitored. An `enforce: true` rule that is not in monitor mode still raises at class load when `ActionController::Rescue` is missing or was included before `AbstractController::Callbacks`. Both modules have to be in by the time of `permit_params`. A check registered at that first `permit_params` runs after any callback declared between the include and it (see [Per-request locales](#custom-error-messages-message)), and with `include Permittable` first, a `skip_before_action :enforce_params_contract` goes after the first `permit_params`, since the callback does not exist before it. On a bare `ActionController::Metal` controller that includes `ActionController::Rescue` after `include Permittable`, the gem registers its handler at the first `permit_params` instead, so there it is a catch-all declared after that `permit_params` that answers first, and a re-declaration goes after it.
|
|
542
542
|
|
|
543
543
|
### Declaring a contract
|
|
544
544
|
|
|
@@ -696,11 +696,11 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
|
|
|
696
696
|
| `:decimal` | a real `Numeric`, or a canonical numeric string → `BigDecimal`; a `Rational` whose decimal expansion is exact (`Rational(1, 2)`, `3r`) | Unparseable strings, `"1_000"`, `" 1.5 "`, a `Complex`, a `Rational` no decimal holds exactly (`Rational(1, 3)`), a `Numeric` that cannot be converted |
|
|
697
697
|
| `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
|
|
698
698
|
| `:date` | `Date`; a `Time`, `DateTime` or `ActiveSupport::TimeWithZone` that is exactly midnight UTC, as that `Date`; a string naming a **complete** date, in any format `Date.parse` understands (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`); a string with a time or zone only if it is exactly midnight UTC (`"2026-09-05T00:00:00Z"`) | Unparseable strings, **incomplete** ones (`"09/2026"`, `"5th"`, `"Sept"`), and any other instant, as an object or a string — one with a time of day, or a midnight in another zone |
|
|
699
|
-
| `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date`; a string naming a complete date, with or without a time (an ISO date with an offset or `Z` and no time, `"2026-09-05-05:00"`, is midnight at that offset) | Unparseable strings, any string without a complete date (`"10:30"`), any other date followed by a bare offset and no time of day (`"Sep 5 2026 -05:00"`), and an ISO date followed by `T` and no time (`"2026-09-05T"`) |
|
|
699
|
+
| `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date`; a string naming a complete date, with or without a time (an ISO date with an offset or `Z` and no time, `"2026-09-05-05:00"`, is midnight at that offset) | Unparseable strings, any string without a complete date (`"10:30"`), any other date followed by a bare offset and no time of day (`"Sep 5 2026 -05:00"`), an offset or an hour the parser would drop (`"12:00 2026-09-05 -05:00"`, `"2026-09-05 10 -05:00"`, `"2026-09-05T10"`), any word after a date that is not a zone Ruby knows, a day name or an era (`"2026-09-05 XYZ"`, `"2026-09-05 noon"`), and an ISO date followed by `T` and no time (`"2026-09-05T"`) |
|
|
700
700
|
| `:json` | Any `Hash` — passed through uncast, see [free-form hashes](#free-form-hashes-json) | Arrays, scalars |
|
|
701
701
|
| `:file` | An upload object (`ActionDispatch::Http::UploadedFile`, `Rack::Test::UploadedFile`), passed through uncast | A `String` (including a form's file field sent as text), numbers, hashes, arrays |
|
|
702
702
|
|
|
703
|
-
**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. An ISO date with an offset or `Z` and no time is midnight at that offset (`"2026-09-05 -05:00"` is `2026-09-05T05:00:00Z`; a `:date` takes it only where that is midnight UTC, as in `"2026-09-05+00:00"`); any other date followed by a bare offset and no time of day is refused, since Ruby's parser reads the offset as a time of day or drops it, and so is an ISO date followed by `T` and no time.
|
|
703
|
+
**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. An ISO date with an offset or `Z` and no time is midnight at that offset (`"2026-09-05 -05:00"` is `2026-09-05T05:00:00Z`; a `:date` takes it only where that is midnight UTC, as in `"2026-09-05+00:00"`); any other date followed by a bare offset and no time of day is refused, since Ruby's parser reads the offset as a time of day or drops it, and so is an ISO date followed by `T` and no time. A zone *name* after a date is honoured where Ruby knows it, with or without a time (`"2026-09-05 EST"` is `2026-09-05T05:00:00Z`, so a `:date` refuses it, and `"Sat Sep 5 10:00:00 2026 EST"` is `15:00:00Z`), and refused where it does not. So is **any other word** after a complete date that is not a day name or an era (`"2026-09-05 XYZ"`, `"2026-09-05 noon"`, `"2026-09-05 foo"`): all it can be is a zone, and a zone Ruby cannot honour is never guessed at. An offset after the year of git's and ctime's format is read (`"Sat Sep 5 10:00:00 2026 -0500"` is `15:00:00Z`). An hour or offset the parser would drop or misread (`"2026-09-05T10"`, `"2026-09-05 10 -05:00"`, `"12:00 2026-09-05 -05:00"`) is refused rather than read as some other instant.
|
|
704
704
|
|
|
705
705
|
**Strings in other encodings are inspected, never converted.** A String whose bytes are not valid in its **own** encoding (`"caf\xC3"` in UTF-8, a lone UTF-16 surrogate) is `invalid_type` for every scalar type, before `normalize:` or `format:` sees it. Any other String keeps its encoding: a `:string` value is handed back exactly as it arrived, so a controller using Rails' `skip_parameter_encoding` or `param_encoding` gets its binary or Shift_JIS text unchanged. The number, boolean and date types parse a UTF-8 **copy** of the text, so UTF-16 `"12"` casts to `12` for an `:integer`; when the text has no UTF-8 reading (a byte Windows-1252 leaves undefined) that is `invalid_type`. `normalize:` and `format:` work on the String in its own encoding. Where a normalizer cannot handle that encoding (`:squish` on UTF-16), the value is left as it is; where a `format:` pattern cannot be applied to it (a non-ASCII pattern against UTF-16 or binary bytes), that is a `format` violation. `in:` compares Strings as Ruby does, encoding included. A `:json` field's contents are not examined.
|
|
706
706
|
|
|
@@ -928,7 +928,7 @@ The setting is app-wide, not per-contract, because the error format of an API is
|
|
|
928
928
|
|
|
929
929
|
Under `:log` that bound is the whole record: nothing else names an undeclared key, so beyond the tenth only the count survives. Where you need every name — auditing what a client really sends during a rollout — use `unknown: :error` in monitor mode, which records all of them in `details` and in the instrumentation payload without rejecting the request.
|
|
930
930
|
|
|
931
|
-
Rails merges its own keys into `params`: `controller`, `action`, and `format` from the router, plus `authenticity_token` (or whatever `config.action_controller.request_forgery_protection_token` renames it to), `_method`, `utf8`, and the submit button's name from an ordinary form POST: `commit` from `f.submit` / `submit_tag`, `button` from `f.button` / `button_tag` (sent as `button=` when the `<button>` has no value). All eight are exempt at the top level. So is the click point of an image submit: `image_submit_tag("save.png")` renders an `<input type="image">` with no name, and a browser submitting through it posts where it was clicked as `x=12&y=7` (or `save.x=12&save.y=7` when the tag is named `"save"`, which Rack keeps as those literal keys). `x` and `y` are ordinary names a contract may really be sent, so they are exempt only as a pair, both present and both plain digits (never negative or fractional); a lone `x`, or a pair that is not digits, is flagged as usual, and a contract that *declares* `x` or `y` has it validated as usual and sees the other flagged unless it declares that too. A bracketed image-submit name is not covered (`name: "user[save]"` is parsed by Rack as `{"user"=>{"save"=>{".x"=>..}}}`, nested rather than top level), just as only the top-level `commit` and `button` are. So are the route's **path parameters** (`PATCH /users/1` merges `id`, which the exported OpenAPI documents as a path parameter rather than a body field); a contract that *declares* `id` has it validated as usual, since the URL really carried it, and the export then documents that field as the path parameter's schema (`required :id, :integer, in: 1..1000` becomes `{"type":"integer","minimum":1,"maximum":1000}`) and leaves it out of the request body, `required` list included, so a body without `id` is valid. Only a rootless contract does this, and only for a name the route templates; a `root:` contract's `id` is a body field and stays one, and so does an `array`, nested or `:json` field, since no URL segment can be one. Under `unknown: :error` the body schema forbids undeclared keys, but every name the route templates stays in it as a property that takes any value and is never required, declared or not: `PATCH /users/1` with `{"id":1,"name":"x"}` is accepted, because the path parameter is exempt and the URL's `id` overwrites the body's. A name the route fills from `defaults:` (`post "w", to: "widgets#create", defaults: { kind: "x" }`, on the route, its `scope` or the `mount` it is reached through) is no body field either, since the router overwrites the body's value: the export leaves it out of the body, `required` list included, and under `unknown: :error` keeps it as that same free property, but declares no path parameter for it, because no URL carries it. `rails_routes` names such keys in each descriptor's `defaults:`, apart from `controller`, `action` and `format`. **ParamsWrapper's copy of a JSON body** under the controller's wrapper key (`user` for `UsersController`) goes further: when Rails made that copy, a rootless contract does not see the key at all, because the client never sent it. So an undeclared wrapper key is not flagged, and a scalar or array field that happens to share the wrapper's name (`optional :feedback, :string` on `FeedbackController`) is simply absent, rather than failing as `invalid_type` against Rails' copy of the whole body. The one exception is a rootless contract that declares the wrapper key as a hash container — a nested block (`required :user do ... end`) or `:json`. That contract is reading the copy on purpose, like a `root:` spelled as a field, so the copy is kept and validated as that field. A client that sends `user` itself is checked like any other key: validated if declared, flagged if not. That holds whether the wrapper name is configured as a String or as a Symbol (`wrap_parameters :user`). Either way `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, a form, nor a request.
|
|
931
|
+
Rails merges its own keys into `params`: `controller`, `action`, and `format` from the router, plus `authenticity_token` (or whatever `config.action_controller.request_forgery_protection_token` renames it to), `_method`, `utf8`, and the submit button's name from an ordinary form POST: `commit` from `f.submit` / `submit_tag`, `button` from `f.button` / `button_tag` (sent as `button=` when the `<button>` has no value). All eight are exempt at the top level. So is the click point of an image submit: `image_submit_tag("save.png")` renders an `<input type="image">` with no name, and a browser submitting through it posts where it was clicked as `x=12&y=7` (or `save.x=12&save.y=7` when the tag is named `"save"`, which Rack keeps as those literal keys). `x` and `y` are ordinary names a contract may really be sent, so they are exempt only as a pair, both present and both plain digits (never negative or fractional); a lone `x`, or a pair that is not digits, is flagged as usual, and a contract that *declares* `x` or `y` has it validated as usual and sees the other flagged unless it declares that too. A bracketed image-submit name is not covered (`name: "user[save]"` is parsed by Rack as `{"user"=>{"save"=>{".x"=>..}}}`, nested rather than top level), just as only the top-level `commit` and `button` are. So are the route's **path parameters** (`PATCH /users/1` merges `id`, which the exported OpenAPI documents as a path parameter rather than a body field); a contract that *declares* `id` has it validated as usual, since the URL really carried it, and the export then documents that field as the path parameter's schema (`required :id, :integer, in: 1..1000` becomes `{"type":"integer","minimum":1,"maximum":1000}`) and leaves it out of the request body, `required` list included, so a body without `id` is valid. Only a rootless contract does this, and only for a name the route templates; a `root:` contract's `id` is a body field and stays one, and so does an `array`, nested or `:json` field, since no URL segment can be one. Under `unknown: :error` the body schema forbids undeclared keys, but the form keys above (`authenticity_token`, `_method`, `utf8`, `commit`, `button`) stay in a rootless contract's JSON body as free, optional properties, as in its multipart body, because the runtime accepts them (a `root:` contract's body is unchanged: they sit outside the root; a name the contract declares keeps its own schema; `Contract#json_schema` describes the contract alone and lists none), and every name the route templates stays in it as a property that takes any value and is never required, declared or not: `PATCH /users/1` with `{"id":1,"name":"x"}` is accepted, because the path parameter is exempt and the URL's `id` overwrites the body's. A name the route fills from `defaults:` (`post "w", to: "widgets#create", defaults: { kind: "x" }`, on the route, its `scope` or the `mount` it is reached through) is no body field either, since the router overwrites the body's value: the export leaves it out of the body, `required` list included, and under `unknown: :error` keeps it as that same free property, but declares no path parameter for it, because no URL carries it. `rails_routes` names such keys in each descriptor's `defaults:`, apart from `controller`, `action` and `format`. **ParamsWrapper's copy of a JSON body** under the controller's wrapper key (`user` for `UsersController`) goes further: when Rails made that copy, a rootless contract does not see the key at all, because the client never sent it. So an undeclared wrapper key is not flagged, and a scalar or array field that happens to share the wrapper's name (`optional :feedback, :string` on `FeedbackController`) is simply absent, rather than failing as `invalid_type` against Rails' copy of the whole body. The one exception is a rootless contract that declares the wrapper key as a hash container — a nested block (`required :user do ... end`) or `:json`. That contract is reading the copy on purpose, like a `root:` spelled as a field, so the copy is kept and validated as that field. A client that sends `user` itself is checked like any other key: validated if declared, flagged if not. That holds whether the wrapper name is configured as a String or as a Symbol (`wrap_parameters :user`). Either way `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, a form, nor a request.
|
|
932
932
|
|
|
933
933
|
All of this changes what is *checked* only. Monitor mode still hands back the form keys, the path parameters and the wrapper's copy in its raw pass-through, where behaving exactly like the pre-contract app is the whole promise and a legacy action may read `params[:id]` or `_method` itself; only the router's three are dropped there.
|
|
934
934
|
|
|
@@ -1041,6 +1041,8 @@ The error carries a ready-to-paste migration command, typed from your own field
|
|
|
1041
1041
|
|
|
1042
1042
|
A field naming a column the model lists in `ignored_columns` raises too, but says so (remove it from `ignored_columns`, or declare it `virtual: true`) rather than suggesting a migration.
|
|
1043
1043
|
|
|
1044
|
+
An attribute assignment accepts without a column of its own is checked against the column behind it. A field named for an `alias_attribute` (`alias_attribute :title, :name`) is checked as its target `name`, type included. A field named for a `store_accessor` (`store_accessor :settings, :theme`) passes when the store's column (`settings`) exists, since the value lives inside that column; it has no type of its own to compare, and a missing store column is reported by the store's name rather than as a missing `theme` column. The name is the one assignment takes, so with `prefix: true` it is `settings_theme` and the bare `theme` is still reported missing. An accessor a parent model declared counts too. Rails does not record which prefix built which accessor, so two stores that share a key under different prefixes are ambiguous: the guard picks the store by the longest matching key, and may then check the other store's column. An alias whose target column is missing is reported as `'title' is an alias of 'name'`, with the migration for `name`. Only a name no column, alias or store accessor backs still needs `virtual: true`.
|
|
1045
|
+
|
|
1044
1046
|
#### Checking types too (opt-in)
|
|
1045
1047
|
|
|
1046
1048
|
A dropped column fails the deploy; a **retyped** one doesn't, unless you ask:
|
|
@@ -1267,7 +1269,7 @@ No Rails required for the core: `Permittable::Generator.draft(model: User)`, `.f
|
|
|
1267
1269
|
|
|
1268
1270
|
A controller declaring `permit_params :create` looks adopted. If it also answers `PATCH`, that action is validating **nothing** — and until now nothing in the gem said so. [`permittable:generate`](#generating-draft-contracts-permittablegenerate) only notices controllers with no contract at all, and the [OpenAPI export](#exporting-openapi-docs-that-cannot-drift) documents what exists rather than what is missing.
|
|
1269
1271
|
|
|
1270
|
-
The audit crosses the contract registry with the **route set**, so a half-covered controller is as visible as an uncovered one. It reads every controller a route can reach: `ActionController::Base` and `::API` subclasses, and bare `ActionController::Metal` ones such as a lean webhook endpoint. The export and the generator read the same list.
|
|
1272
|
+
The audit crosses the contract registry with the **route set**, so a half-covered controller is as visible as an uncovered one. It reads every controller a route can reach: `ActionController::Base` and `::API` subclasses, and bare `ActionController::Metal` ones such as a lean webhook endpoint. It also loads any autoloaded controller a route names (`Rails::HealthController` behind `/up`, which nothing has loaded yet), since the route's own controller is part of the app; a route whose controller constant does not exist is skipped, not raised on. A controller from a gem is still [ignored by default](#ignoring-controllers-and-actions). The export and the generator read the same list of loaded controllers; the audit also loads any autoloaded controller a route names.
|
|
1271
1273
|
|
|
1272
1274
|
```sh
|
|
1273
1275
|
bin/rails permittable:audit # the table plus a summary
|
|
@@ -1428,7 +1430,7 @@ Both work on a controller class, a controller instance, or a [standalone `Contra
|
|
|
1428
1430
|
|
|
1429
1431
|
The negated forms follow the same rules as `not_to permit_param`: `not_to accept_params(...)` passes only when the contract rejects the payload, and `not_to reject_params(...)` only when it accepts it. Neither takes a qualifier — `not_to accept_params(...).returning(x)` would pass both on a rejection and on an acceptance returning anything else, so it raises and names the positive form — and both fail, rather than passing, when no rule covers the action or the subject declares no contracts.
|
|
1430
1432
|
|
|
1431
|
-
The matchers run **without a request**, so they know the controller's settings but not its route. The CSRF parameter name set at class level with `request_forgery_protection_token` is exempt from `unknown: :error` as it is at runtime, but a per-instance override is not seen, and neither are a route's **path parameters**: a payload `id` on a member action under `unknown: :error` is reported `unknown`, although a real `PATCH /users/1` carries it in the URL and is accepted. Leave path parameters out of the payload you pass to `accept_params` / `reject_params`. On a controller the payload goes through the same normalization a request gets, so a `nil` array element is dropped as Rails drops it (unless the app turned `perform_deep_munge` off); a standalone `Contract` gets the payload as given.
|
|
1433
|
+
The matchers run **without a request**, so they know the controller's settings but not its route. The CSRF parameter name set at class level with `request_forgery_protection_token` is exempt from `unknown: :error` as it is at runtime, but a per-instance override is not seen, and neither are a route's **path parameters**: a payload `id` on a member action under `unknown: :error` is reported `unknown`, although a real `PATCH /users/1` carries it in the URL and is accepted. Leave path parameters out of the payload you pass to `accept_params` / `reject_params`. On a controller the payload goes through the same normalization a request gets, so a `nil` array element is dropped as Rails drops it (unless the app turned `perform_deep_munge` off), and a Symbol value (`status: :active`) reads as the String a request carries; a standalone `Contract` gets the payload as given, so a Symbol value there is still `invalid_type`. A failure message that echoes params (the returned hash, a `returning` expectation) redacts them like a request log: the names of `sensitive:` fields, plus the app's `config.filter_parameters` when a Rails application is booted. Matching is by substring, like Rails, so `sensitive: true` on `pin` also redacts `shipping`; when a `returning` mismatch is hidden by that, the message adds `differs at: pin, shipping`, names only. Under a `root:` contract the hash is filtered as the request logs it, under its root, so a scoped filter such as `"payment.number"` applies; the note stops at a redacted key and never names anything below it.
|
|
1432
1434
|
|
|
1433
1435
|
### Standalone contracts (no controller)
|
|
1434
1436
|
|
|
@@ -1545,7 +1547,7 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
|
|
|
1545
1547
|
| `:date` / `:datetime` | `string` + `format: date` / `date-time` |
|
|
1546
1548
|
| `:file` | `string` + `contentMediaType: application/octet-stream` (the 3.1 spelling; `format: binary` is 3.0's). An operation whose contract has a `:file` field gets a `multipart/form-data` request body instead of `application/json` |
|
|
1547
1549
|
| `:decimal` | `type: ["string", "number"]` + `format: decimal` (string is the precision-safe encoding) |
|
|
1548
|
-
| `in:` list / numeric Range | `enum` of the cast members (a `:
|
|
1550
|
+
| `in:` list / numeric Range | `enum` of the cast members (a `:datetime` member written as a String is published as written; a `:date` member, however written, as `YYYY-MM-DD`) / `minimum` + `maximum` (exclusive ends honoured). An `in:` object that only answers `include?` is flagged `x-permittable-custom-validation` |
|
|
1549
1551
|
| `length:` | `minLength`/`maxLength` on strings, `minItems`/`maxItems` on arrays |
|
|
1550
1552
|
| `format:` | `pattern`, valid under the `u` flag Ajv compiles with: `\A`/`\z` become `^`/`$`, `\s` and `.` are spelled out as the classes they are in Ruby (ECMA-262's `\s` also matches NBSP and U+2028; its `.` also stops at `\r`), and redundant escapes like `\-` and `\#` are written bare |
|
|
1551
1553
|
| `default:` / `desc:` / `example:` | `default` / `description` / `examples` |
|
data/lib/permittable/audit.rb
CHANGED
|
@@ -48,11 +48,13 @@ module Permittable
|
|
|
48
48
|
# never included the concern, which is exactly the case worth finding).
|
|
49
49
|
#
|
|
50
50
|
# `missing_action` marks a route to an action the controller does not
|
|
51
|
-
# define — see Audit.missing_action?. `
|
|
51
|
+
# define — see Audit.missing_action?. `inert` marks one whose rule exists
|
|
52
|
+
# but cannot run, so `rule` is nil and the action is uncovered — see
|
|
53
|
+
# Audit.inert?. `route` identifies the route the
|
|
52
54
|
# path came from (nil for a hand-built descriptor, which is then its own
|
|
53
55
|
# route); see `summary`. `ignored` is why the entry is left out of the
|
|
54
56
|
# counts — a key of IGNORE_REASONS — or nil when it is audited.
|
|
55
|
-
Entry = Struct.new(:controller, :action, :verb, :path, :rule, :missing_action, :route, :ignored,
|
|
57
|
+
Entry = Struct.new(:controller, :action, :verb, :path, :rule, :missing_action, :route, :ignored, :inert,
|
|
56
58
|
keyword_init: true) do
|
|
57
59
|
def covered?
|
|
58
60
|
!rule.nil?
|
|
@@ -66,6 +68,10 @@ module Permittable
|
|
|
66
68
|
!ignored.nil?
|
|
67
69
|
end
|
|
68
70
|
|
|
71
|
+
def inert?
|
|
72
|
+
inert == true
|
|
73
|
+
end
|
|
74
|
+
|
|
69
75
|
# The mode this action would actually run in, rule-level declaration
|
|
70
76
|
# first and the app-wide default behind it.
|
|
71
77
|
def mode
|
|
@@ -111,12 +117,33 @@ module Permittable
|
|
|
111
117
|
outside = :outside_root if outside_root?(controller, root)
|
|
112
118
|
routes.select { |route| route[:controller].to_s == key }.map do |route|
|
|
113
119
|
action = route[:action].to_s
|
|
120
|
+
rule = rule_for(controller, action)
|
|
121
|
+
inert = inert?(controller, rule)
|
|
114
122
|
Entry.new(controller: key, action: action, verb: route[:verb], path: route[:path],
|
|
115
|
-
rule:
|
|
116
|
-
|
|
123
|
+
rule: (rule unless inert), missing_action: missing[action], route: route[:route],
|
|
124
|
+
ignored: configured_ignore(key, action, ignore) || outside, inert: inert)
|
|
117
125
|
end
|
|
118
126
|
end
|
|
119
127
|
|
|
128
|
+
# Whether `rule` is declared on `controller` but can never run there: the
|
|
129
|
+
# gem never registered its before_action on it — a bare ActionController::
|
|
130
|
+
# Metal controller without AbstractController::Callbacks at its
|
|
131
|
+
# `permit_params`, which asks the flag the gem sets when it registers the
|
|
132
|
+
# callback (a callbacks module included afterwards does not undo that) —
|
|
133
|
+
# while the rule validates from that callback: an `enforce: true` rule, or
|
|
134
|
+
# one in monitor mode by its own mode: or the app-wide Permittable.mode.
|
|
135
|
+
# permit_params raises for this at class load, but reads only the rule's own
|
|
136
|
+
# mode, since the app-wide one is often set after the class loads; the
|
|
137
|
+
# effective mode is judged here. Such an action is audited as uncovered
|
|
138
|
+
# rather than as the monitored one it claims to be.
|
|
139
|
+
def inert?(controller, rule)
|
|
140
|
+
return false unless rule && defined?(::ActionController::Metal) && controller.is_a?(Class) &&
|
|
141
|
+
controller < ::ActionController::Metal
|
|
142
|
+
|
|
143
|
+
(rule[:enforce] || (rule[:mode] || Permittable.mode) == :monitor) &&
|
|
144
|
+
!(controller.respond_to?(:permittable_callback_registered) && controller.permittable_callback_registered)
|
|
145
|
+
end
|
|
146
|
+
|
|
120
147
|
# Why `controller` — or one of its actions — is left out of the audit and
|
|
121
148
|
# of permittable:generate: :configured (Permittable.audit_ignore names the
|
|
122
149
|
# controller, or "controller#action"), :outside_root, or nil. The
|
|
@@ -151,11 +178,32 @@ module Permittable
|
|
|
151
178
|
# themselves are Metal descendants too; they are the framework's abstract
|
|
152
179
|
# bases, which no route names, and the old lists never held them.
|
|
153
180
|
# Anonymous classes cannot be routed to, so only named ones are kept.
|
|
154
|
-
|
|
181
|
+
#
|
|
182
|
+
# `descendants` only holds classes that have been loaded, and a framework
|
|
183
|
+
# controller is autoloaded: Rails::HealthController behind `/up` is a
|
|
184
|
+
# registered constant until something touches it, so it had no row. Pass
|
|
185
|
+
# the route descriptors and every controller a route names is loaded and
|
|
186
|
+
# added — it is the route's own controller. A constant that does not exist,
|
|
187
|
+
# or is not a controller class, is left out rather than raised on.
|
|
188
|
+
def rails_controllers(routes = [])
|
|
155
189
|
return [] unless defined?(::ActionController::Metal)
|
|
156
190
|
|
|
157
191
|
frameworks = %w[ActionController::Base ActionController::API]
|
|
158
|
-
::ActionController::Metal.descendants
|
|
192
|
+
loaded = ::ActionController::Metal.descendants + routed_controllers(routes)
|
|
193
|
+
loaded.uniq.select { |controller| controller.name && !frameworks.include?(controller.name) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The controller classes the routes' `controller:` keys name, constantized
|
|
197
|
+
# (loading an autoloaded one). `safe_constantize` answers nil for a missing
|
|
198
|
+
# constant and raises on anything else — an error inside the controller's
|
|
199
|
+
# own file is the app's to see, so nothing more is rescued here. A key that
|
|
200
|
+
# names no constant, or one that is not an ActionController::Metal class,
|
|
201
|
+
# yields nothing.
|
|
202
|
+
def routed_controllers(routes)
|
|
203
|
+
routes.map { |route| route[:controller].to_s }.reject(&:empty?).uniq.filter_map do |key|
|
|
204
|
+
found = ActiveSupport::Inflector.safe_constantize("#{ActiveSupport::Inflector.camelize(key)}Controller")
|
|
205
|
+
found if found.is_a?(Class) && found < ::ActionController::Metal
|
|
206
|
+
end
|
|
159
207
|
end
|
|
160
208
|
|
|
161
209
|
# The root a controller's source must be inside to be audited:
|
|
@@ -434,7 +482,16 @@ module Permittable
|
|
|
434
482
|
end
|
|
435
483
|
|
|
436
484
|
def uncovered_note(entry)
|
|
437
|
-
not_found_note(entry) || ("ACCEPTS A BODY" if entry.body?)
|
|
485
|
+
not_found_note(entry) || inert_note(entry) || ("ACCEPTS A BODY" if entry.body?)
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Says why a declared contract is not counted, and keeps the body warning
|
|
489
|
+
# a gate reader needs.
|
|
490
|
+
def inert_note(entry)
|
|
491
|
+
return unless entry.inert?
|
|
492
|
+
|
|
493
|
+
["contract cannot run: controller lacks AbstractController::Callbacks (no before_action)",
|
|
494
|
+
("ACCEPTS A BODY" if entry.body?)].compact.join(" — ")
|
|
438
495
|
end
|
|
439
496
|
|
|
440
497
|
# The one wording for a route Rails would 404, covered or not.
|
|
@@ -143,29 +143,41 @@ module Permittable
|
|
|
143
143
|
end
|
|
144
144
|
|
|
145
145
|
# An authored hash is exported as a schema `default`/`example`, and JSON
|
|
146
|
-
# has no NaN or Infinity: JSON.generate raises on
|
|
147
|
-
# is written, long after the class loaded
|
|
148
|
-
#
|
|
149
|
-
#
|
|
146
|
+
# has no NaN or Infinity: JSON.generate raises on a Float's when the
|
|
147
|
+
# document is written, long after the class loaded, and a BigDecimal's was
|
|
148
|
+
# exported as the String "NaN". Nor does the export keep a Time finer than
|
|
149
|
+
# a nanosecond: it prints nine digits, a different instant (a :datetime
|
|
150
|
+
# field's own default is refused for the same reason). A request cannot
|
|
151
|
+
# carry any of these (JSON has none; a form sends Strings), so only the
|
|
152
|
+
# authored value is held to it, here, and not permittable_check_json for
|
|
153
|
+
# every request.
|
|
150
154
|
def permittable_check_json(field, value)
|
|
151
155
|
status, read = super
|
|
152
156
|
return [status, read] unless status == :ok
|
|
153
157
|
|
|
154
|
-
|
|
155
|
-
return [:error,
|
|
158
|
+
problem = unexportable_json(value, "")
|
|
159
|
+
return [:error, problem] if problem
|
|
156
160
|
|
|
157
161
|
[status, read]
|
|
158
162
|
end
|
|
159
163
|
|
|
160
|
-
#
|
|
161
|
-
|
|
164
|
+
# Why the first value that would not survive the export is refused, and
|
|
165
|
+
# where it sits (`a.b[1].c`), or nil.
|
|
166
|
+
def unexportable_json(value, path)
|
|
162
167
|
case value
|
|
163
|
-
when Float then path unless value.finite?
|
|
164
|
-
when
|
|
165
|
-
when
|
|
168
|
+
when Float, BigDecimal then "holds NaN or Infinity at #{path}, which JSON cannot carry" unless value.finite?
|
|
169
|
+
when Time, DateTime then unexportable_time(value, path)
|
|
170
|
+
when Hash then value.each_pair.lazy.filter_map { |k, v| unexportable_json(v, path.empty? ? k.to_s : "#{path}.#{k}") }.first
|
|
171
|
+
when Array then value.each_with_index.lazy.filter_map { |v, i| unexportable_json(v, "#{path}[#{i}]") }.first
|
|
166
172
|
end
|
|
167
173
|
end
|
|
168
174
|
|
|
175
|
+
def unexportable_time(time, path)
|
|
176
|
+
return if Coercion.whole_nanoseconds?(time)
|
|
177
|
+
|
|
178
|
+
"holds a Time at #{path} finer than a nanosecond, so the export publishes a different instant; round it, e.g. with .round(9)"
|
|
179
|
+
end
|
|
180
|
+
|
|
169
181
|
def permittable_violation(_field, param, code)
|
|
170
182
|
{ param: param, code: code.to_s }
|
|
171
183
|
end
|
|
@@ -48,12 +48,11 @@ module Permittable
|
|
|
48
48
|
return false unless schema_reachable?(klass)
|
|
49
49
|
|
|
50
50
|
fields.flatten.compact.each do |field|
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
column = backing_column(klass, field)
|
|
52
|
+
unless klass.column_names.include?(column)
|
|
53
|
+
raise ArgumentError, ignored_column_message(label, klass, column) if ignored_column?(klass, column)
|
|
53
54
|
|
|
54
|
-
raise ArgumentError,
|
|
55
|
-
"#{label}: '#{field}' does not exist in the database (table: #{klass.table_name})." \
|
|
56
|
-
"#{column_migration_hint(klass, field, types)}"
|
|
55
|
+
raise ArgumentError, missing_column_message(label, klass, field, column, types)
|
|
57
56
|
end
|
|
58
57
|
|
|
59
58
|
ensure_column_type!(label, klass, field, types, allowed, transformed: transformed) if check_types
|
|
@@ -61,6 +60,114 @@ module Permittable
|
|
|
61
60
|
true
|
|
62
61
|
end
|
|
63
62
|
|
|
63
|
+
# `alias_attribute :title, :name` makes `title` an attribute assignment
|
|
64
|
+
# accepts, backed by `name`'s column, so the existence check, the
|
|
65
|
+
# ignored_columns check and the type check all look at the target. Aliases
|
|
66
|
+
# may chain; the walk stops at a name it has seen, so a cycle cannot hang a
|
|
67
|
+
# class body. A duck-typed model without `attribute_aliases` (or whose
|
|
68
|
+
# reader returns nil) has none.
|
|
69
|
+
def resolve_alias(klass, field)
|
|
70
|
+
name = field.to_s
|
|
71
|
+
aliases = attribute_aliases_of(klass)
|
|
72
|
+
seen = [name]
|
|
73
|
+
while (target = aliases[name])
|
|
74
|
+
break if seen.include?(target.to_s)
|
|
75
|
+
|
|
76
|
+
name = target.to_s
|
|
77
|
+
seen << name
|
|
78
|
+
end
|
|
79
|
+
name
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def attribute_aliases_of(klass)
|
|
83
|
+
klass.respond_to?(:attribute_aliases) ? Hash(klass.attribute_aliases) : {}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The column a field is stored in: its alias target's, or for a store
|
|
87
|
+
# accessor (`store_accessor :settings, :theme`) the column of its store,
|
|
88
|
+
# `settings`, itself resolved when the store is an alias. The field is then
|
|
89
|
+
# a key inside that column, not a column of its own, so the column's type
|
|
90
|
+
# says nothing about it (see ensure_column_type!). The guard accepts it for
|
|
91
|
+
# the reason it accepts an alias or an enum: assignment takes it and the
|
|
92
|
+
# data lands in a column. `virtual: true` stays the opt-out for what no
|
|
93
|
+
# column holds. That is also why the store column itself must exist.
|
|
94
|
+
def backing_column(klass, field)
|
|
95
|
+
name = resolve_alias(klass, field)
|
|
96
|
+
store = stored_attribute_store(klass, name)
|
|
97
|
+
store ? resolve_alias(klass, store) : name
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The store (the column holding the hash) a `store_accessor` field lives
|
|
101
|
+
# in, nil when `name` is a plain attribute. A real column of the same name
|
|
102
|
+
# wins: it is the attribute Rails reads. A duck-typed model without
|
|
103
|
+
# `stored_attributes` has none.
|
|
104
|
+
#
|
|
105
|
+
# `stored_attributes` lists the raw keys (`{settings: [:theme]}`), but the
|
|
106
|
+
# accessor is named by `prefix:`/`suffix:` too (`settings_theme`), so the
|
|
107
|
+
# name is accepted only when the model really has a store accessor's writer
|
|
108
|
+
# under it, which is what assignment calls (see store_accessor_writer?). A
|
|
109
|
+
# writer from anywhere else is an ordinary attribute, not a
|
|
110
|
+
# store key. The store is then the one whose key the name is built from,
|
|
111
|
+
# the longest key first and one whose name the accessor mentions next.
|
|
112
|
+
# Rails does not record which prefix built which accessor, so two stores
|
|
113
|
+
# sharing a key are ambiguous, and the guess may check the other's column.
|
|
114
|
+
def stored_attribute_store(klass, name)
|
|
115
|
+
return unless klass.respond_to?(:stored_attributes)
|
|
116
|
+
return if klass.column_names.include?(name) || !store_accessor_writer?(klass, "#{name}=")
|
|
117
|
+
|
|
118
|
+
pairs = Hash(klass.stored_attributes).flat_map { |store, keys| keys.map { |key| [store, key.to_s] } }
|
|
119
|
+
pairs.select { |_store, key| name.include?(key) }
|
|
120
|
+
.min_by { |store, key| [-key.length, name.include?(store.to_s) ? 0 : 1] }&.first
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Whether `writer` is a store accessor's, whatever the model has layered on
|
|
124
|
+
# it. The accessor is defined by active_record/store.rb itself, so the
|
|
125
|
+
# method chain is walked through `super` (an override in the model, a
|
|
126
|
+
# subclass, a prepended or later-included module calls through to it) until
|
|
127
|
+
# a method defined in that file turns up. That holds across Rails versions:
|
|
128
|
+
# the module holding the accessors is `_store_accessors_module` on 6.1
|
|
129
|
+
# through 8.1 and a `GeneratedStoreMethods` constant on main, which a
|
|
130
|
+
# lookup of either would miss. An ordinary writer (`attr_accessor`, an
|
|
131
|
+
# attribute-API or column writer) is defined elsewhere.
|
|
132
|
+
STORE_SOURCE = %r{/active_record/store\.rb\z}
|
|
133
|
+
|
|
134
|
+
def store_accessor_writer?(klass, writer)
|
|
135
|
+
return false unless klass.method_defined?(writer)
|
|
136
|
+
|
|
137
|
+
method = klass.instance_method(writer)
|
|
138
|
+
while method
|
|
139
|
+
return true if method.source_location&.first.to_s.match?(STORE_SOURCE)
|
|
140
|
+
|
|
141
|
+
method = method.super_method
|
|
142
|
+
end
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def missing_column_message(label, klass, field, column, types)
|
|
147
|
+
store = stored_attribute_store(klass, resolve_alias(klass, field))
|
|
148
|
+
return store_column_message(label, klass, field, store, column) if store
|
|
149
|
+
|
|
150
|
+
if column == field.to_s
|
|
151
|
+
return "#{label}: '#{field}' does not exist in the database (table: #{klass.table_name})." \
|
|
152
|
+
"#{column_migration_hint(klass, field, types)}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# An alias: the migration must add the column the alias points at,
|
|
156
|
+
# not one named for the alias, which would still leave this check failing.
|
|
157
|
+
"#{label}: '#{field}' is an alias of '#{column}', which does not exist in the database " \
|
|
158
|
+
"(table: #{klass.table_name})." \
|
|
159
|
+
"#{column_migration_hint(klass, column, types.is_a?(Hash) ? types[field.to_sym] : types)}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The store column is gone (dropped, or never migrated), so the accessor
|
|
163
|
+
# reads and writes nothing. That is the column the migration is for, not
|
|
164
|
+
# a column named after the accessor, which would shadow it.
|
|
165
|
+
def store_column_message(label, klass, field, store, column)
|
|
166
|
+
"#{label}: '#{field}' is a store_accessor of :#{store} on #{model_expr(klass)}, " \
|
|
167
|
+
"but the table has no '#{column}' column (table: #{klass.table_name}). " \
|
|
168
|
+
"Add the '#{column}' column that holds it, or declare the field virtual: true if the app handles it itself."
|
|
169
|
+
end
|
|
170
|
+
|
|
64
171
|
# The type half of the drift guard, opt-in via Permittable
|
|
65
172
|
# .check_column_types. It compares GROUPS rather than exact types (see
|
|
66
173
|
# TYPE_GROUPS) and stays silent unless both sides are known, so it can
|
|
@@ -68,18 +175,23 @@ module Permittable
|
|
|
68
175
|
# :datetime after the column became a string, say.
|
|
69
176
|
def ensure_column_type!(label, klass, field, types, allowed = nil, transformed: nil)
|
|
70
177
|
declared = types.is_a?(Hash) ? types[field.to_sym] : types
|
|
71
|
-
|
|
178
|
+
target = resolve_alias(klass, field)
|
|
179
|
+
column = klass.columns_hash[target]
|
|
72
180
|
return unless declared && column
|
|
73
181
|
|
|
74
182
|
ensure_not_array_column!(label, klass, field, declared, column)
|
|
75
183
|
|
|
76
184
|
wanted = TYPE_GROUPS[declared.to_sym]
|
|
77
|
-
enum
|
|
185
|
+
# An enum is keyed by the name it was declared on, which may be the
|
|
186
|
+
# alias (`enum :phase` over `alias_attribute :phase, :status`) or the
|
|
187
|
+
# target it points at.
|
|
188
|
+
enum_name = [field.to_s, target].find { |name| enum_attribute?(klass, name) }
|
|
189
|
+
enum = !enum_name.nil?
|
|
78
190
|
# Checked before the column's type, which an enum's contract does not
|
|
79
191
|
# depend on: see ensure_enum_contract!.
|
|
80
192
|
if enum && wanted == :text
|
|
81
|
-
return ensure_enum_contract!(label, klass,
|
|
82
|
-
transformed: Array(transformed).include?(field.to_sym))
|
|
193
|
+
return ensure_enum_contract!(label, klass, enum_name, declared, allowed && allowed[field.to_sym],
|
|
194
|
+
transformed: Array(transformed).include?(field.to_sym), shown: field.to_s)
|
|
83
195
|
end
|
|
84
196
|
|
|
85
197
|
# `column.type` is nil for a SQL type the adapter does not recognise
|
|
@@ -95,7 +207,8 @@ module Permittable
|
|
|
95
207
|
# On an enum, `virtual: true` would be the wrong advice: it switches
|
|
96
208
|
# off the existence check too, and the field IS backed by this column.
|
|
97
209
|
fix = if enum
|
|
98
|
-
"
|
|
210
|
+
"#{enum_subject(enum_name, field)} is an enum on #{model_expr(klass)}: " \
|
|
211
|
+
"declare it :string, in: #{enum_keys_expr(klass, enum_name)}."
|
|
99
212
|
else
|
|
100
213
|
"Change the contract to match the column, migrate the column to match the contract, " \
|
|
101
214
|
"or declare the field virtual: true if it is not backed by this column."
|
|
@@ -153,7 +266,7 @@ module Permittable
|
|
|
153
266
|
# over a string column) is left compared against the column by choice:
|
|
154
267
|
# an enum's mapping says exactly which strings are valid, an attribute
|
|
155
268
|
# override says nothing a contract could be checked against.
|
|
156
|
-
def ensure_enum_contract!(label, klass, field, declared, listed, transformed: false)
|
|
269
|
+
def ensure_enum_contract!(label, klass, field, declared, listed, transformed: false, shown: field)
|
|
157
270
|
problem =
|
|
158
271
|
if listed.nil?
|
|
159
272
|
"declared :#{declared} without an in:"
|
|
@@ -170,11 +283,19 @@ module Permittable
|
|
|
170
283
|
end
|
|
171
284
|
|
|
172
285
|
raise ArgumentError,
|
|
173
|
-
"#{label}:
|
|
286
|
+
"#{label}: #{enum_subject(field, shown)} is an enum on #{model_expr(klass)}, #{problem} (table: #{klass.table_name}). " \
|
|
174
287
|
"A value outside the enum would pass the contract and then raise on assignment. " \
|
|
175
288
|
"Declare it with in: #{enum_keys_expr(klass, field)}."
|
|
176
289
|
end
|
|
177
290
|
|
|
291
|
+
# The field as an enum error names it: `'phase'`, or `'state' (an alias of
|
|
292
|
+
# 'status')` when the enum is declared on the alias's target.
|
|
293
|
+
def enum_subject(enum_name, shown)
|
|
294
|
+
return "'#{shown}'" if enum_name.to_s == shown.to_s
|
|
295
|
+
|
|
296
|
+
"'#{shown}' (an alias of '#{enum_name}')"
|
|
297
|
+
end
|
|
298
|
+
|
|
178
299
|
def enum_values(klass, field)
|
|
179
300
|
mapping = klass.defined_enums[field.to_s]
|
|
180
301
|
mapping.keys.map(&:to_s) + mapping.values.grep(String)
|
data/lib/permittable/contract.rb
CHANGED
|
@@ -140,7 +140,10 @@ module Permittable
|
|
|
140
140
|
end
|
|
141
141
|
|
|
142
142
|
# The request-body schema for this contract — JSON Schema draft 2020-12,
|
|
143
|
-
#
|
|
143
|
+
# what the OpenAPI exporter emits for a controller rule, less what only an
|
|
144
|
+
# HTTP request carries (the form keys a rootless `unknown: :error` body
|
|
145
|
+
# lists, the route's path parameters, a multipart body's flat part names):
|
|
146
|
+
# a contract exempts none of those.
|
|
144
147
|
def json_schema
|
|
145
148
|
JsonSchema.rule(rule)
|
|
146
149
|
end
|
|
@@ -223,7 +223,7 @@ module Permittable
|
|
|
223
223
|
# applies here too — a :decimal in: member is otherwise always
|
|
224
224
|
# exported as a string, so a numerically-exported default: is no
|
|
225
225
|
# longer a member of its own enum's exported list. in_published (a
|
|
226
|
-
# :
|
|
226
|
+
# :datetime member kept in its authored String form, see
|
|
227
227
|
# Coercion.published_in_member) takes precedence over the cast
|
|
228
228
|
# members when both apply.
|
|
229
229
|
schema["enum"] = (field[:in_published] || allowed).map { |v| json_value(v, decimal: :number) }
|
data/lib/permittable/open_api.rb
CHANGED
|
@@ -204,12 +204,27 @@ module Permittable
|
|
|
204
204
|
media_type = body_media_type(rule)
|
|
205
205
|
schema = JsonSchema.rule(rule)
|
|
206
206
|
schema = form_schema(schema, rule) if media_type == "multipart/form-data"
|
|
207
|
+
allow_form_keys!(schema) if media_type != "multipart/form-data" && rule[:unknown] == :error && !rule[:root]
|
|
207
208
|
{
|
|
208
209
|
"required" => !!(rule[:root] || rule[:fields].any? { |f| f[:required] }),
|
|
209
210
|
"content" => { media_type => { "schema" => schema } }
|
|
210
211
|
}
|
|
211
212
|
end
|
|
212
213
|
|
|
214
|
+
# Under `unknown: :error` the runtime still exempts Rails' form keys
|
|
215
|
+
# (FORM_KEYS) at the top of a rootless contract, so a JSON body carrying
|
|
216
|
+
# `authenticity_token` or `_method` (a form's fields sent as JSON, a record
|
|
217
|
+
# echoed back) is accepted, and the body schema must not forbid it. They
|
|
218
|
+
# stay in as free, never-required properties, as the multipart body lists
|
|
219
|
+
# them (see form_schema). A name the contract declares keeps its own
|
|
220
|
+
# schema. A `root:` rule needs none of this: the keys sit outside the root,
|
|
221
|
+
# in a wrapper that is permissive already. The plain JsonSchema.rule and
|
|
222
|
+
# Contract#json_schema describe the contract, not an HTTP body, and a
|
|
223
|
+
# standalone Contract exempts no form key, so they do not list them.
|
|
224
|
+
def allow_form_keys!(schema)
|
|
225
|
+
Permittable::FORM_KEYS.each { |key| schema["properties"][key] ||= {} }
|
|
226
|
+
end
|
|
227
|
+
|
|
213
228
|
# A multipart body is not one JSON object: each part is a form field, and
|
|
214
229
|
# Rails reads the part NAMES. `user[avatar]` is the avatar of the `user`
|
|
215
230
|
# hash and a repeated `docs[]` is an array, so the schema's properties
|
data/lib/permittable/rspec.rb
CHANGED
|
@@ -469,6 +469,8 @@ module Permittable
|
|
|
469
469
|
end
|
|
470
470
|
|
|
471
471
|
def cast_json_default(field, expected)
|
|
472
|
+
# Absent on a nullable field is the nil a contract stores for it.
|
|
473
|
+
return nil if field[:nullable] && Coercion.absent_value?(expected)
|
|
472
474
|
return expected unless expected.is_a?(Hash)
|
|
473
475
|
|
|
474
476
|
status, value = AuthoredValues.read_json(field, expected)
|
|
@@ -634,7 +636,7 @@ module Permittable
|
|
|
634
636
|
# `not_to ...returning(x)` would pass both on a rejection and on an
|
|
635
637
|
# acceptance returning anything else.
|
|
636
638
|
def does_not_match?(subject) # rubocop:disable Naming/PredicatePrefix -- the RSpec protocol name
|
|
637
|
-
raise ArgumentError, negated_qualifier_message if @returning || !@expected_violations.empty?
|
|
639
|
+
raise ArgumentError, negated_qualifier_message(subject) if @returning || !@expected_violations.empty?
|
|
638
640
|
|
|
639
641
|
rule = locate(subject)
|
|
640
642
|
return false unless rule
|
|
@@ -649,9 +651,12 @@ module Permittable
|
|
|
649
651
|
if @expect_accepted
|
|
650
652
|
return "expected #{subject_name} to #{description}, but it rejected them: #{summary(@violations)}" unless @violations.empty?
|
|
651
653
|
|
|
652
|
-
"expected #{subject_name} to #{description}, but it accepted them but returned
|
|
654
|
+
"expected #{subject_name} to #{description}, but it accepted them but returned " \
|
|
655
|
+
"#{printable(@result.to_h)}#{differs_note}"
|
|
653
656
|
else
|
|
654
|
-
|
|
657
|
+
if @violations.empty?
|
|
658
|
+
return "expected #{subject_name} to #{description}, but it accepted them, returning #{printable(@result.to_h)}"
|
|
659
|
+
end
|
|
655
660
|
|
|
656
661
|
"expected #{subject_name} to #{description}, but the violations were: #{summary(@violations)}"
|
|
657
662
|
end
|
|
@@ -668,7 +673,7 @@ module Permittable
|
|
|
668
673
|
label = @expect_accepted ? "accept those params" : "reject those params"
|
|
669
674
|
label += action_label
|
|
670
675
|
label += " with #{summary(@expected_violations)}" unless @expected_violations.empty?
|
|
671
|
-
label += " returning #{@returning
|
|
676
|
+
label += " returning #{printable(@returning)}" if @returning
|
|
672
677
|
label
|
|
673
678
|
end
|
|
674
679
|
|
|
@@ -682,15 +687,27 @@ module Permittable
|
|
|
682
687
|
# first so a matcher object reused across examples (or re-chained
|
|
683
688
|
# with another for_action) never reports a previous run's problem.
|
|
684
689
|
def locate(subject)
|
|
685
|
-
@problem = @violations = @result = nil
|
|
690
|
+
@problem = @violations = @result = @root = nil
|
|
686
691
|
@subject = resolve_subject(subject)
|
|
687
|
-
resolve_rule
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
692
|
+
rule = resolve_rule
|
|
693
|
+
@root = rule[:root] if rule
|
|
694
|
+
rule
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def negated_qualifier_message(subject = nil)
|
|
698
|
+
# Raised before the rule is located, so the root a scoped filter
|
|
699
|
+
# needs is looked up here, best effort: a subject or an ambiguity
|
|
700
|
+
# that locate would refuse (a BasicObject answers no respond_to?) is not
|
|
701
|
+
# this message's to report, and leaves the filter unscoped (the
|
|
702
|
+
# shallow one) rather than masking the qualifier error.
|
|
703
|
+
begin
|
|
704
|
+
locate(subject) if subject
|
|
705
|
+
rescue StandardError
|
|
706
|
+
@root = nil
|
|
707
|
+
end
|
|
691
708
|
call = @expect_accepted ? "accept_params" : "reject_params"
|
|
692
709
|
qualifiers = []
|
|
693
|
-
qualifiers << "returning #{@returning
|
|
710
|
+
qualifiers << "returning #{printable(@returning)}" if @returning
|
|
694
711
|
qualifiers << "with_violation #{summary(@expected_violations)}" unless @expected_violations.empty?
|
|
695
712
|
"#{LABEL}: `not_to #{call}(...)` cannot take qualifiers (here: #{qualifiers.join(', ')}) — " \
|
|
696
713
|
"negating one is ambiguous, since it would pass both when the params are " \
|
|
@@ -788,36 +805,40 @@ module Permittable
|
|
|
788
805
|
# payload belongs to the spec. The copy stringifies Symbol keys, which
|
|
789
806
|
# the normalizer's HashWithIndifferentAccess does anyway but only after
|
|
790
807
|
# it has read a Symbol :tempfile key as an upload, a key a request
|
|
791
|
-
# (String keys only) never has
|
|
792
|
-
# so it is normalized like the
|
|
808
|
+
# (String keys only) never has, and Symbol values (see copy_payload).
|
|
809
|
+
# A Parameters payload is unwrapped first so it is normalized like the
|
|
810
|
+
# Hash it wraps. Without actionpack's request code only the normalizer
|
|
811
|
+
# is skipped; the copy (and its Symbol stringifying) still happens.
|
|
793
812
|
def request_params
|
|
813
|
+
payload = copy_payload(@params.respond_to?(:to_unsafe_h) ? @params.to_unsafe_h : @params)
|
|
794
814
|
utils = request_utils
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
payload = @params.respond_to?(:to_unsafe_h) ? @params.to_unsafe_h : @params
|
|
798
|
-
utils.normalize_encode_params(copy_payload(payload))
|
|
815
|
+
utils ? utils.normalize_encode_params(payload) : payload
|
|
799
816
|
end
|
|
800
817
|
|
|
801
818
|
# Rails main moved the class from ActionDispatch::Request::Utils to
|
|
802
|
-
# ActionDispatch::Http::Utils. nil
|
|
803
|
-
# loaded
|
|
804
|
-
#
|
|
805
|
-
#
|
|
819
|
+
# ActionDispatch::Http::Utils. nil when actionpack's request code is not
|
|
820
|
+
# loaded (a non-Rails stand-in controller, or a bare ActionDispatch module
|
|
821
|
+
# that something else namespaced): there is nothing to normalize with, and
|
|
822
|
+
# nothing was renamed. Once ActionDispatch::Request exists, a rename this
|
|
823
|
+
# does not know raises instead of silently skipping the normalization,
|
|
824
|
+
# which would bring the false greens back.
|
|
806
825
|
def request_utils
|
|
807
|
-
return unless defined?(ActionDispatch)
|
|
808
|
-
|
|
809
826
|
if defined?(ActionDispatch::Http::Utils) then ActionDispatch::Http::Utils
|
|
810
827
|
elsif defined?(ActionDispatch::Request::Utils) then ActionDispatch::Request::Utils
|
|
811
|
-
|
|
828
|
+
elsif defined?(ActionDispatch::Request)
|
|
812
829
|
raise "Permittable's matchers cannot find Rails' parameter normalizer: neither " \
|
|
813
830
|
"ActionDispatch::Http::Utils nor ActionDispatch::Request::Utils is defined"
|
|
814
831
|
end
|
|
815
832
|
end
|
|
816
833
|
|
|
834
|
+
# Symbol keys become Strings, and so do Symbol values: a request carries
|
|
835
|
+
# every scalar as a String (or a JSON number, boolean or null), never a
|
|
836
|
+
# Symbol, so `status: :active` is the "active" the contract would read.
|
|
817
837
|
def copy_payload(value)
|
|
818
838
|
case value
|
|
819
839
|
when Hash then value.to_h { |key, item| [key.is_a?(Symbol) ? key.to_s : key, copy_payload(item)] }
|
|
820
840
|
when Array then value.map { |item| copy_payload(item) }
|
|
841
|
+
when Symbol then value.to_s
|
|
821
842
|
else value
|
|
822
843
|
end
|
|
823
844
|
end
|
|
@@ -874,6 +895,116 @@ module Permittable
|
|
|
874
895
|
raise ArgumentError, "#{LABEL}: the subject of accept_params/reject_params must include Permittable (got #{subject.inspect})"
|
|
875
896
|
end
|
|
876
897
|
|
|
898
|
+
# A hash echoed into a failure message, which lands in CI logs, goes out
|
|
899
|
+
# redacted the way a request log is: by the names `sensitive:` fields
|
|
900
|
+
# registered, and by the app's config.filter_parameters when a Rails
|
|
901
|
+
# application is booted. Only the printed copy is filtered; the matching
|
|
902
|
+
# itself reads the real values.
|
|
903
|
+
def printable(hash)
|
|
904
|
+
redacted(hash).inspect
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
# The redacted structure itself, which differs_note walks beside the
|
|
908
|
+
# real one. A rule with a root: has its hash put back under that root
|
|
909
|
+
# for the app's filter, as a request logs it, so a scoped filter
|
|
910
|
+
# ("payment.number", /\Apayment\.code\z/) matches; the registry pass
|
|
911
|
+
# runs on the hash alone, as it does at runtime.
|
|
912
|
+
def redacted(hash)
|
|
913
|
+
inner = redact_sensitive(hash)
|
|
914
|
+
filter = parameter_filter
|
|
915
|
+
return inner unless filter
|
|
916
|
+
return filter.filter(inner) unless @root
|
|
917
|
+
|
|
918
|
+
key = @root.to_s
|
|
919
|
+
filter.filter(key => inner)[key]
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
# The registry's names, by whatever it can answer: #include?, else its
|
|
923
|
+
# #names matched the way the registry matches (case-insensitive
|
|
924
|
+
# substring). One with neither is left to its #to_proc in
|
|
925
|
+
# parameter_filter. A masked value is replaced whole, a Hash included.
|
|
926
|
+
# An ActionController::Parameters is unwrapped, so its nested values
|
|
927
|
+
# are reached too.
|
|
928
|
+
def redact_sensitive(value)
|
|
929
|
+
value = value.to_unsafe_h if value.respond_to?(:to_unsafe_h)
|
|
930
|
+
return value.map { |item| redact_sensitive(item) } if value.is_a?(Array)
|
|
931
|
+
return value unless value.is_a?(Hash)
|
|
932
|
+
|
|
933
|
+
registry = Permittable.filter_parameter_registry
|
|
934
|
+
value.to_h do |key, item|
|
|
935
|
+
[key, sensitive_name?(registry, key) ? Permittable::FilterParameterRegistry::FILTERED : redact_sensitive(item)]
|
|
936
|
+
end
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
def sensitive_name?(registry, key)
|
|
940
|
+
if registry.respond_to?(:include?)
|
|
941
|
+
registry.include?(key)
|
|
942
|
+
elsif registry.respond_to?(:names)
|
|
943
|
+
name = key.to_s.downcase
|
|
944
|
+
registry.names.any? { |sensitive| name.include?(sensitive.to_s.downcase) }
|
|
945
|
+
else
|
|
946
|
+
false
|
|
947
|
+
end
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
# Rails' ParameterFilter over the app's config.filter_parameters, plus
|
|
951
|
+
# the registry's own #to_proc when it can answer neither #include? nor
|
|
952
|
+
# #names (a proc masks only Strings, but it is all such a registry
|
|
953
|
+
# offers). ActiveSupport::ParameterFilter is not loaded by
|
|
954
|
+
# `require "active_support"` before 7.0, and a Rails 6.1 app without
|
|
955
|
+
# ActionDispatch::Request has not loaded it either; it has been
|
|
956
|
+
# requirable since 6.0.
|
|
957
|
+
def parameter_filter
|
|
958
|
+
filters = []
|
|
959
|
+
app = defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
960
|
+
filters.concat(Array(app.config.filter_parameters)) if app
|
|
961
|
+
registry = Permittable.filter_parameter_registry
|
|
962
|
+
filters << registry.to_proc unless registry.respond_to?(:include?) || registry.respond_to?(:names)
|
|
963
|
+
return if filters.empty?
|
|
964
|
+
|
|
965
|
+
begin
|
|
966
|
+
require "active_support/parameter_filter"
|
|
967
|
+
rescue LoadError
|
|
968
|
+
return
|
|
969
|
+
end
|
|
970
|
+
ActiveSupport::ParameterFilter.new(filters)
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
# The registry matches by substring, like Rails (`pin` also redacts
|
|
974
|
+
# `shipping`), so an expected and an actual hash that differ only under
|
|
975
|
+
# redacted names print the same. Say where they differ, by name only.
|
|
976
|
+
def differs_note
|
|
977
|
+
return "" unless @returning
|
|
978
|
+
|
|
979
|
+
expected = ActiveSupport::HashWithIndifferentAccess.new(@returning).to_h
|
|
980
|
+
actual = @result.to_h
|
|
981
|
+
return "" if expected == actual || printable(expected) != printable(actual)
|
|
982
|
+
|
|
983
|
+
# A filter on the root itself masks the whole hash, leaving the root as the one path.
|
|
984
|
+
paths = differing_paths(expected, actual, redacted(expected), redacted(actual)).map { |path| path || @root.to_s }
|
|
985
|
+
paths.empty? ? "" : "; differs at: #{paths.join(', ')}"
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
# Walks the real hashes beside their redacted copies and stops at the
|
|
989
|
+
# redaction boundary: a node the copy shows as masked is named by its
|
|
990
|
+
# path alone when the real values differ, and not entered, since a key
|
|
991
|
+
# or an index below it is as secret as its value.
|
|
992
|
+
def differing_paths(expected, actual, expected_masked, actual_masked, prefix = nil)
|
|
993
|
+
filtered = Permittable::FilterParameterRegistry::FILTERED
|
|
994
|
+
masked = expected_masked == filtered || actual_masked == filtered
|
|
995
|
+
if !masked && expected.is_a?(Hash) && actual.is_a?(Hash)
|
|
996
|
+
(expected.keys | actual.keys).flat_map do |key|
|
|
997
|
+
differing_paths(expected[key], actual[key], expected_masked[key], actual_masked[key], [prefix, key].compact.join("."))
|
|
998
|
+
end
|
|
999
|
+
elsif !masked && expected.is_a?(Array) && actual.is_a?(Array) && expected.length == actual.length
|
|
1000
|
+
expected.each_index.flat_map do |i|
|
|
1001
|
+
differing_paths(expected[i], actual[i], expected_masked[i], actual_masked[i], "#{prefix}[#{i}]")
|
|
1002
|
+
end
|
|
1003
|
+
else
|
|
1004
|
+
expected == actual ? [] : [prefix]
|
|
1005
|
+
end
|
|
1006
|
+
end
|
|
1007
|
+
|
|
877
1008
|
def summary(violations)
|
|
878
1009
|
violations.map { |v| v[:code] ? "#{v[:param]} (#{v[:code]})" : v[:param] }.join(", ")
|
|
879
1010
|
end
|
|
@@ -13,9 +13,10 @@ namespace :permittable do
|
|
|
13
13
|
|
|
14
14
|
# Every controller, not just the ones including Permittable — a controller
|
|
15
15
|
# that never included it is unguarded, which is exactly the finding. Bare
|
|
16
|
-
# ActionController::Metal ones included
|
|
17
|
-
|
|
16
|
+
# ActionController::Metal ones included, and autoloaded framework ones
|
|
17
|
+
# (Rails::HealthController) the routes name; see Audit.rails_controllers.
|
|
18
18
|
routes = Permittable::OpenAPI.rails_routes(Rails.application)
|
|
19
|
+
controllers = Permittable::Audit.rails_controllers(routes)
|
|
19
20
|
|
|
20
21
|
entries = Permittable::Audit.entries(controllers: controllers, routes: routes)
|
|
21
22
|
stale = Permittable::Audit.stale(controllers: controllers, routes: routes)
|
data/lib/permittable/version.rb
CHANGED
data/lib/permittable.rb
CHANGED
|
@@ -1041,9 +1041,13 @@ module Permittable
|
|
|
1041
1041
|
found = Date._parse(value)
|
|
1042
1042
|
return [:error, "invalid_type"] unless complete_date?(found)
|
|
1043
1043
|
return cast_date_instant(value, found) if names_an_instant?(found)
|
|
1044
|
-
|
|
1045
|
-
#
|
|
1046
|
-
|
|
1044
|
+
|
|
1045
|
+
# No time found, but a zone or offset may have been dropped
|
|
1046
|
+
# ("5/9/2026 -0500", "2026-09-05 EST") or an hour with it
|
|
1047
|
+
# ("2026-09-05T10"), which :datetime refuses or reads the same way.
|
|
1048
|
+
if !plain && (dropped = dropped_tail(value, found))
|
|
1049
|
+
return date_at_midnight(*dropped)
|
|
1050
|
+
end
|
|
1047
1051
|
|
|
1048
1052
|
# Built from the components rather than re-running Date.parse, which
|
|
1049
1053
|
# would parse the same string a second time — and Date.parse is the
|
|
@@ -1082,7 +1086,11 @@ module Permittable
|
|
|
1082
1086
|
# ruled out the ISO forms cast_datetime reads without one; :datetime's
|
|
1083
1087
|
# reading of it is then the same, with one parse fewer.
|
|
1084
1088
|
def cast_date_instant(value, found = nil)
|
|
1085
|
-
|
|
1089
|
+
date_at_midnight(*(found ? cast_parsed_datetime(value, found) : cast_datetime(value)))
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
# The Date of an instant read from a String when it is midnight UTC.
|
|
1093
|
+
def date_at_midnight(status, instant)
|
|
1086
1094
|
return [:error, "invalid_type"] unless status == :ok && instant == instant.beginning_of_day
|
|
1087
1095
|
|
|
1088
1096
|
[:ok, Date.new(instant.year, instant.month, instant.day)]
|
|
@@ -1167,7 +1175,9 @@ module Permittable
|
|
|
1167
1175
|
# parts, shared with cast_date_instant so a :date need not parse twice.
|
|
1168
1176
|
def cast_parsed_datetime(value, found)
|
|
1169
1177
|
return [:error, "invalid_type"] unless complete_date?(found) && honoured_zone?(found)
|
|
1170
|
-
|
|
1178
|
+
|
|
1179
|
+
dropped = dropped_tail(value, found)
|
|
1180
|
+
return dropped if dropped
|
|
1171
1181
|
|
|
1172
1182
|
[:ok, DateTime.parse(value, true, Date::GREGORIAN).to_time.utc]
|
|
1173
1183
|
rescue ArgumentError, RangeError
|
|
@@ -1236,23 +1246,102 @@ module Permittable
|
|
|
1236
1246
|
(zone.start_with?("-") ? -1 : 1) * ((hours * 3600) + (minutes * 60))
|
|
1237
1247
|
end
|
|
1238
1248
|
|
|
1239
|
-
#
|
|
1240
|
-
#
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
#
|
|
1244
|
-
#
|
|
1245
|
-
#
|
|
1246
|
-
# "
|
|
1247
|
-
|
|
1248
|
-
return false if found.key?(:zone)
|
|
1249
|
+
# An ISO date, then a "T": what follows is an ISO time, so a parse that
|
|
1250
|
+
# found no hour ("2026-09-05T10", "T1030", "T10Z") dropped it.
|
|
1251
|
+
ISO_DATE_T_START = /\A\s*\d{4}(-?)\d{2}\1\d{2}[Tt]/
|
|
1252
|
+
|
|
1253
|
+
# Trailing words, set apart by a space: a zone name ("EST", "Eastern
|
|
1254
|
+
# Standard Time", "W. Europe Standard Time") or a day or era word. The
|
|
1255
|
+
# first word has two letters or more, or one and a ".", as a lone letter
|
|
1256
|
+
# is a military zone or the ISO "T".
|
|
1257
|
+
ZONE_NAME_TAIL = /(?<=\s)[[:alpha:]](?:\.|[[:alpha:]]+)[[:alpha:].]*(?:\s+[[:alpha:].]+)*\z/
|
|
1249
1258
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1259
|
+
# An era word at the start of such a tail: the parser reads it with the
|
|
1260
|
+
# date ("Sep 5 2026 BC" is the year -2025), so it is not what follows.
|
|
1261
|
+
ERA_WORD = /\A(?:a\.?\s?d\.?|c\.?\s?e\.?|b\.?\s?c\.?(?:\s?e\.?)?)(?:\s+|\z)/i
|
|
1252
1262
|
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1263
|
+
# What ends a date the way git, `date` and Time#to_s write it, with the
|
|
1264
|
+
# offset after the year: "Sat Sep 5 10:00:00 2026", "Sep 5 10:00 2026".
|
|
1265
|
+
TIME_THEN_YEAR = /\d:\d{2}(?::\d{2}(?:[.,]\d+)?)?\s+\d{4}\s*\z/
|
|
1266
|
+
private_constant :ZONE_NAME_TAIL, :ERA_WORD, :TIME_THEN_YEAR
|
|
1267
|
+
|
|
1268
|
+
# What Date._parse did with the END of a date or date-time String when it
|
|
1269
|
+
# dropped or misplaced it, as nil when it did neither, or the result
|
|
1270
|
+
# ([:ok, instant] or [:error, "invalid_type"]) the String must be given:
|
|
1271
|
+
#
|
|
1272
|
+
# - an hour after an ISO "T" it did not find ("2026-09-05T10");
|
|
1273
|
+
# - a trailing offset taken for a time of day ("Sep 5 2026 -05:00",
|
|
1274
|
+
# "2026-09-05 10 -05:00", "2026-09-05 UTC-05:00") or dropped without a
|
|
1275
|
+
# trace ("5/9/2026 -0500", "12:00 2026-09-05 -05:00"), outside the ISO
|
|
1276
|
+
# form midnight_at_offset reads. An offset after "10:00:00 2026" is the
|
|
1277
|
+
# exception: that is git's and ctime's format, which puts it after the
|
|
1278
|
+
# year, so it is read, like a zone name;
|
|
1279
|
+
# - a trailing zone NAME, which it reads only next to a time of day:
|
|
1280
|
+
# "2026-09-05 EST" is midnight at -05:00 and "Sat Sep 5 10:00:00 2026
|
|
1281
|
+
# EST" 10:00 there. A name it does not know is refused like any other
|
|
1282
|
+
# zone (honoured_zone?), and so is ANY other word after a complete date
|
|
1283
|
+
# ("2026-09-05 noon"), since all it can be is a zone. A day name and an
|
|
1284
|
+
# era word are what the parser reads as part of the date, not a zone.
|
|
1285
|
+
#
|
|
1286
|
+
# The tail is the culprit when the parser found no zone and the String
|
|
1287
|
+
# without it names the same date (and time). Only a String that ends in a
|
|
1288
|
+
# signed number or a word is parsed again, so a plain "2026-09-05" is not.
|
|
1289
|
+
def dropped_tail(value, found)
|
|
1290
|
+
return [:error, "invalid_type"] if !found.key?(:hour) && ISO_DATE_T_START.match?(value)
|
|
1291
|
+
return if found.key?(:zone)
|
|
1292
|
+
|
|
1293
|
+
if (tail = SIGNED_TAIL.match(value))
|
|
1294
|
+
return unless tail[1] || found.key?(:hour)
|
|
1295
|
+
|
|
1296
|
+
rest = Date._parse(tail.pre_match)
|
|
1297
|
+
return unless same_date?(rest, found)
|
|
1298
|
+
|
|
1299
|
+
git_style = tail[1] && found.key?(:hour) && TIME_THEN_YEAR.match?(tail.pre_match) && same_time?(rest, found)
|
|
1300
|
+
git_style ? instant_in_zone(tail.pre_match, tail[0].strip) : [:error, "invalid_type"]
|
|
1301
|
+
elsif (tail = ZONE_NAME_TAIL.match(value))
|
|
1302
|
+
pre = tail.pre_match
|
|
1303
|
+
text = tail[0]
|
|
1304
|
+
if (era = ERA_WORD.match(text))
|
|
1305
|
+
pre += era[0]
|
|
1306
|
+
text = era.post_match
|
|
1307
|
+
end
|
|
1308
|
+
return if text.empty?
|
|
1309
|
+
|
|
1310
|
+
rest = Date._parse(pre)
|
|
1311
|
+
return unless same_date?(rest, found)
|
|
1312
|
+
|
|
1313
|
+
zone = Date._parse("00:00 #{text}")
|
|
1314
|
+
return unless zone.key?(:zone) # a day name
|
|
1315
|
+
# A single letter is a military zone, which is no zone here: alone it
|
|
1316
|
+
# is nothing ("2026-09-05 a", "A."), but the rest of an initial-led
|
|
1317
|
+
# name Ruby did not know ("W. Europe") is dropped, so it is refused
|
|
1318
|
+
# like any other word it does not know.
|
|
1319
|
+
return text.match?(/\A[[:alpha:]]\.?\z/) ? nil : [:error, "invalid_type"] if zone[:zone].length == 1
|
|
1320
|
+
# The hour the tail made the parser lose ("2026-09-05 10 EST").
|
|
1321
|
+
return [:error, "invalid_type"] unless same_time?(rest, found)
|
|
1322
|
+
|
|
1323
|
+
instant_in_zone(pre, text)
|
|
1324
|
+
end
|
|
1325
|
+
end
|
|
1326
|
+
|
|
1327
|
+
# The wall clock `date` names, as it is read in the zone `text` names:
|
|
1328
|
+
# [:ok, instant], or an error when Ruby cannot honour the zone.
|
|
1329
|
+
def instant_in_zone(date, text)
|
|
1330
|
+
zone = Date._parse("00:00 #{text}")
|
|
1331
|
+
return [:error, "invalid_type"] unless zone.key?(:zone) && honoured_zone?(zone)
|
|
1332
|
+
|
|
1333
|
+
[:ok, DateTime.parse(date, true, Date::GREGORIAN).to_time.utc - zone[:offset]]
|
|
1334
|
+
end
|
|
1335
|
+
|
|
1336
|
+
def same_time?(rest, found)
|
|
1337
|
+
TIME_PARTS.all? { |part| rest[part] == found[part] }
|
|
1338
|
+
end
|
|
1339
|
+
|
|
1340
|
+
TIME_PARTS = %i[hour min sec sec_fraction].freeze
|
|
1341
|
+
private_constant :TIME_PARTS
|
|
1342
|
+
|
|
1343
|
+
def same_date?(rest, found)
|
|
1344
|
+
complete_date?(rest) && rest.values_at(:year, :mon, :mday) == found.values_at(:year, :mon, :mday)
|
|
1256
1345
|
end
|
|
1257
1346
|
|
|
1258
1347
|
# Presets only make sense on String input; a non-String value (JSON
|
|
@@ -1446,13 +1535,17 @@ module Permittable
|
|
|
1446
1535
|
end
|
|
1447
1536
|
|
|
1448
1537
|
# What an exported enum lists for one member: the cast value, re-encoded
|
|
1449
|
-
# as JSON — except a :
|
|
1450
|
-
#
|
|
1451
|
-
#
|
|
1452
|
-
#
|
|
1453
|
-
#
|
|
1538
|
+
# as JSON — except a :datetime member authored as a String, which is
|
|
1539
|
+
# published AS WRITTEN. Re-encoding a cast Time prints whole seconds, so
|
|
1540
|
+
# "2026-09-05T10:00:00.25Z" was published as "…10:00:00Z", a value the
|
|
1541
|
+
# server refuses. The authored String went through the very cast a request
|
|
1542
|
+
# does, so the server accepts it by construction. A :date member is
|
|
1543
|
+
# re-encoded: a Date has no digits to lose, and the String as written
|
|
1544
|
+
# ("Sep 6, 2026", "2026-09-05T00:00:00Z") is a date the server reads but
|
|
1545
|
+
# not one `format: date` accepts, so it is published as the YYYY-MM-DD
|
|
1546
|
+
# the server compares against.
|
|
1454
1547
|
def published_in_member(type, member, value)
|
|
1455
|
-
member.is_a?(String) &&
|
|
1548
|
+
member.is_a?(String) && type == :datetime ? member : value
|
|
1456
1549
|
end
|
|
1457
1550
|
|
|
1458
1551
|
# Range#include? walks discrete ranges; cover? is the O(1) bounds check
|
|
@@ -1799,7 +1892,7 @@ module Permittable
|
|
|
1799
1892
|
end
|
|
1800
1893
|
|
|
1801
1894
|
# `published` is stored only where it differs from the cast members (a
|
|
1802
|
-
# String-authored :
|
|
1895
|
+
# String-authored :datetime member), so it is read as an override.
|
|
1803
1896
|
def cast_in_members!(field, members)
|
|
1804
1897
|
status, cast, published = Coercion.cast_in_members(field[:type], members, nullable: field[:nullable])
|
|
1805
1898
|
unless status == :ok
|
|
@@ -1896,6 +1989,12 @@ module Permittable
|
|
|
1896
1989
|
def validate_json_authored_value!(field, opt)
|
|
1897
1990
|
return unless field.key?(opt)
|
|
1898
1991
|
return if authored_nil!(field, opt)
|
|
1992
|
+
|
|
1993
|
+
# A request sending "" for a nullable :json field is the explicit null
|
|
1994
|
+
# the walker reads it as, so a blank authored value is that nil, as a
|
|
1995
|
+
# nullable scalar's is (see validate_authored_value!).
|
|
1996
|
+
return field[opt] = nil if field[:nullable] && Coercion.absent_value?(field[opt])
|
|
1997
|
+
|
|
1899
1998
|
raise ArgumentError, "#{LABEL}: :#{opt} for :#{field[:name]} must be a Hash" unless field[opt].is_a?(Hash)
|
|
1900
1999
|
|
|
1901
2000
|
status, read = AuthoredValues.read_json(field, field[opt])
|
|
@@ -2351,6 +2450,11 @@ module Permittable
|
|
|
2351
2450
|
# rescue_from until the app includes them, and a rule that needs one of
|
|
2352
2451
|
# them would otherwise load and then not run: raise at class load, naming
|
|
2353
2452
|
# the module. Other hosts (a plain class, a duck) are left as they are.
|
|
2453
|
+
# Only the rule's OWN declared mode is read, never Permittable.mode: that
|
|
2454
|
+
# is usually set by an initializer, and an app must not stop booting over
|
|
2455
|
+
# it. The effective mode can only add a requirement, so an action it makes
|
|
2456
|
+
# monitored without a before_action is left to Audit.inert?, which reports
|
|
2457
|
+
# it as not covered.
|
|
2354
2458
|
def permittable_ensure_runnable!(rule)
|
|
2355
2459
|
return unless defined?(::ActionController::Metal) && self < ::ActionController::Metal
|
|
2356
2460
|
|
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.15.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-11-
|
|
11
|
+
date: 2026-11-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|