permittable 0.3.0 → 0.4.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 +13 -0
- data/README.md +54 -6
- data/lib/permittable/open_api.rb +4 -0
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +113 -11
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e6009aed769f45203bb8053f4310d4ac986ec941815704e613f63bddf819bf92
|
|
4
|
+
data.tar.gz: 721cead2003e010c773e4bab9de20830d85f40182a049b8e4db6fa82d1f377d7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5ff703088a1c1389cc49150521380c56f937d4ba0f99de0137e7390a91fa66a16bdad09092f0c65964b94e332bee170b17bdc6d7e9d03c11a7b2020f95953c78
|
|
7
|
+
data.tar.gz: 64b5097368d5df03d8dfacb4c9eb79cf6ecb08e2c0b933a80281201139e496f143f0336d886d4a4d33a15dfc0f2c7642f10ee5641e625874a80526717386d869
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 0.4.0 (2026-08-24)
|
|
4
|
+
<!-- title: monitor mode -->
|
|
5
|
+
|
|
6
|
+
The rollout switch. Adopting contracts on a live API — or tightening an existing one — used to mean flipping unknown clients from "accepted" to "422" in a single deploy. A contract can now run in **monitor mode**: the full pipeline executes (unwrap, cast, validate, defaults), but a violation is **reported instead of rejected** and the request proceeds exactly as it did before the contract existed. Deploy monitoring, dashboard the would-be rejections, then enforce controller by controller — every 422 you finally return is one you already counted.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **`mode: :monitor` on `permit_params`, and an app-wide `Permittable.mode` default** (`:enforce` unless set; a rule's own `mode:` always wins, in both directions). On a violating request in monitor mode nothing raises and nothing renders: the `invalid_parameters.permittable` event fires with `mode: :monitor`, the logger warns with the offending paths, and `permitted_params` returns the **raw pass-through** — exactly what the client sent, no casts, no defaults, no transforms (a missing `root:` passes an empty hash; a rootless contract drops only the router's bookkeeping keys). Monitor rules validate **eagerly in the `before_action` regardless of `enforce:`**, so telemetry never depends on the action calling `permitted_params` — legacy actions still reading `params` directly are exactly the ones being monitored.
|
|
10
|
+
- **`permittable_violations(action = nil)`** — the recorded violation details for the (memoized) validation of `action`, `[]` when the request was clean. The monitor-mode observable; under enforce it swallows its own trigger's raise, making "would this request fail?" a one-liner in tests.
|
|
11
|
+
- **`mode:` key on the `invalid_parameters.permittable` payload** (`:enforce` / `:monitor`), so one subscriber can dashboard enforced rejections and monitored would-be rejections side by side. Additive — existing subscribers are unaffected.
|
|
12
|
+
- **`x-permittable-mode: "monitor"`** on exported OpenAPI operations whose rule declares monitor mode — the docs must not promise a 422 the server doesn't yet send. Only the per-rule declaration is exported; the app-wide `Permittable.mode` is runtime configuration, not contract data.
|
|
13
|
+
|
|
14
|
+
Contracts that don't opt in are byte-for-byte unaffected: the default mode is `:enforce` and the enforce path behaves exactly as before.
|
|
15
|
+
|
|
3
16
|
## 0.3.0 (2026-08-24)
|
|
4
17
|
<!-- title: OpenAPI export -->
|
|
5
18
|
|
data/README.md
CHANGED
|
@@ -54,7 +54,7 @@ A violating request never reaches your action:
|
|
|
54
54
|
- [Violations and error responses](#violations-and-error-responses) · [Custom error messages](#custom-error-messages-message) · [Unknown parameters](#unknown-parameters)
|
|
55
55
|
- [Output reshaping](#output-reshaping-transform-and-finalize) · [The schema-drift guard](#the-schema-drift-guard)
|
|
56
56
|
- [Sensitive parameters](#sensitive-parameters-and-log-redaction) · [Instrumentation](#instrumentation)
|
|
57
|
-
- [Exporting OpenAPI](#exporting-openapi-docs-that-cannot-drift)
|
|
57
|
+
- [Monitor mode](#monitor-mode-roll-out-without-rejecting) · [Exporting OpenAPI](#exporting-openapi-docs-that-cannot-drift)
|
|
58
58
|
- [API reference](#api-reference) · [Errors caught at class load](#errors-caught-at-class-load) · [Compatibility](#compatibility)
|
|
59
59
|
|
|
60
60
|
---
|
|
@@ -72,6 +72,7 @@ A violating request never reaches your action:
|
|
|
72
72
|
| Reshapes output | ❌ | ❌ | ✅ |
|
|
73
73
|
| Checked against your schema at boot | ❌ | ❌ | ✅ |
|
|
74
74
|
| Exports OpenAPI / JSON Schema | ❌ | ❌ | ✅ |
|
|
75
|
+
| Report-only rollout mode | ❌ | ❌ | ✅ |
|
|
75
76
|
|
|
76
77
|
The design rests on one idea: **a contract is data, not code.** It is declared once at the class level, frozen, inheritable, and introspectable. Everything else here follows from that — the drift guard can read it at boot, `finalize` can run on a bare object with no controller state, and the whole contract can be printed or tested without a request.
|
|
77
78
|
|
|
@@ -108,10 +109,12 @@ params
|
|
|
108
109
|
|
|
109
110
|
Validation is **lazy by default**: it runs on the first `permitted_params` call, so an action that never reads params never pays for it. Pass `enforce: true` to run it in a `before_action` instead, rejecting bad requests before the action body executes. Results are **memoized per action**.
|
|
110
111
|
|
|
112
|
+
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.
|
|
113
|
+
|
|
111
114
|
## Declaring a contract
|
|
112
115
|
|
|
113
116
|
```ruby
|
|
114
|
-
permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, desc: nil, &contract)
|
|
117
|
+
permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, mode: nil, desc: nil, &contract)
|
|
115
118
|
```
|
|
116
119
|
|
|
117
120
|
| Option | Default | Meaning |
|
|
@@ -121,6 +124,7 @@ permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: fals
|
|
|
121
124
|
| `model:` | `nil` | Model class, or `true` to infer from `controller_name`, enabling the [drift guard](#the-schema-drift-guard) |
|
|
122
125
|
| `unknown:` | `:ignore` | `:ignore` / `:log` / `:error` — how to treat undeclared keys |
|
|
123
126
|
| `enforce:` | `false` | `false` validates lazily on first use; `true` validates in a `before_action` |
|
|
127
|
+
| `mode:` | `nil` | `nil` follows `Permittable.mode`; `:monitor` reports violations instead of rejecting — see [monitor mode](#monitor-mode-roll-out-without-rejecting) |
|
|
124
128
|
| `desc:` | `nil` | Documentation only — becomes the operation description in [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) |
|
|
125
129
|
|
|
126
130
|
`permit_params` is **repeatable**, and **the last matching rule wins**. Contracts behave like configuration: a base controller declares a catch-all, and a subclass overrides it for specific actions.
|
|
@@ -383,6 +387,47 @@ ActiveSupport::Notifications.subscribe("invalid_parameters.permittable") do |*,
|
|
|
383
387
|
end
|
|
384
388
|
```
|
|
385
389
|
|
|
390
|
+
## Monitor mode (roll out without rejecting)
|
|
391
|
+
|
|
392
|
+
Adopting contracts on a live API — or tightening one field on an existing contract — has a chicken-and-egg problem: you cannot know what the 422s would break until you enforce them, and you dare not enforce them until you know. Old mobile app versions, third-party integrations, and forgotten cron jobs all send what they send. `mode: :monitor` resolves it: the full pipeline runs (unwrap, cast, validate, defaults), but a violation is **reported instead of rejected** and the request proceeds exactly as it did before the contract existed.
|
|
393
|
+
|
|
394
|
+
```ruby
|
|
395
|
+
class OrdersController < ApplicationController
|
|
396
|
+
permit_params :create, root: :order, mode: :monitor do
|
|
397
|
+
required :sku, :string
|
|
398
|
+
optional :quantity, :integer, in: 1..99
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# The action doesn't have to change while monitoring — it can keep reading
|
|
402
|
+
# params the old way; the contract validates in the before_action.
|
|
403
|
+
end
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
Or flip the whole app at once and pin controllers to their final mode one at a time — a rule's own `mode:` always beats the global, in both directions:
|
|
407
|
+
|
|
408
|
+
```ruby
|
|
409
|
+
# config/initializers/permittable.rb
|
|
410
|
+
Permittable.mode = ENV.fetch("PERMITTABLE_MODE", "enforce").to_sym
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
On a violating request in monitor mode:
|
|
414
|
+
|
|
415
|
+
- **Nothing raises and nothing renders** — the action runs.
|
|
416
|
+
- The [`invalid_parameters.permittable` event](#instrumentation) fires with `mode: :monitor` in the payload (enforced violations carry `mode: :enforce`), and the logger warns with the offending paths. Point your existing subscriber at a dashboard and you have a per-controller rollout report.
|
|
417
|
+
- `permitted_params` returns the **raw pass-through**: exactly what the client sent, untouched — no casts, no defaults, no transforms. A missing `root:` passes an empty hash (the envelope you asked for isn't there); a rootless contract drops only Rails' routing keys.
|
|
418
|
+
- `permittable_violations` returns the recorded details (`[]` when the request was clean), if the action wants to branch on or tag the traffic.
|
|
419
|
+
|
|
420
|
+
Monitor-mode rules validate **eagerly in the `before_action`, regardless of `enforce:`** — telemetry must not depend on the action calling `permitted_params`, since legacy actions still reading `params` directly are exactly the ones worth monitoring. (On a plain-Ruby host without `before_action`, validation stays lazy.)
|
|
421
|
+
|
|
422
|
+
The rollout recipe:
|
|
423
|
+
|
|
424
|
+
1. Write contracts for a legacy controller. The action code stays as-is.
|
|
425
|
+
2. Deploy with `PERMITTABLE_MODE=monitor`. Behaviour is unchanged; telemetry starts.
|
|
426
|
+
3. Watch the dashboard. Every entry is a real client that would have been rejected — fix the contract, or wait for that traffic to drain.
|
|
427
|
+
4. Flip to enforce, controller by controller. Every 422 you now return is one you already counted.
|
|
428
|
+
|
|
429
|
+
[Exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) marks operations whose rule declares `mode: :monitor` with `x-permittable-mode: "monitor"` — the docs shouldn't promise a 422 the server doesn't yet send. Only the per-rule declaration is exported: the global `Permittable.mode` is runtime configuration, not contract data.
|
|
430
|
+
|
|
386
431
|
## Exporting OpenAPI (docs that cannot drift)
|
|
387
432
|
|
|
388
433
|
Because a contract is data, it has a third reader beyond the validator and the drift guard: an exporter that emits **OpenAPI 3.1** (whose request bodies are plain JSON Schema). The schema is generated from the same frozen data the server enforces, so — like the drift guard, pointed outward — the docs cannot lie:
|
|
@@ -422,7 +467,7 @@ How contracts map:
|
|
|
422
467
|
|
|
423
468
|
Every operation references shared components for the [error envelope](#violations-and-error-responses): a `422` response always, plus a `400` when the contract declares a `root:`. So consumers get typed *errors*, not just typed inputs.
|
|
424
469
|
|
|
425
|
-
**What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a Ruby-only construct (or flags) is exported as `x-permittable-pattern` rather than a mistranslated `pattern`; `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`; actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations with no matching route land in `x-permittable-controllers` instead of being dropped. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
|
|
470
|
+
**What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a Ruby-only construct (or flags) is exported as `x-permittable-pattern` rather than a mistranslated `pattern`; `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`; actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; operations with no matching route land in `x-permittable-controllers` instead of being dropped. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
|
|
426
471
|
|
|
427
472
|
Output is deterministic (fixed key order, declaration-order properties), so the generated file can be committed and reviewed as a diff — a contract change shows up in the same PR as its documentation change.
|
|
428
473
|
|
|
@@ -432,8 +477,9 @@ Output is deterministic (fixed key order, declaration-order properties), so the
|
|
|
432
477
|
|
|
433
478
|
| Method | Purpose |
|
|
434
479
|
|---|---|
|
|
435
|
-
| `permitted_params(action = action_name)` | The cast, validated, defaulted `HashWithIndifferentAccess`. Memoized per action. Raises `InvalidParameters` on violation, or `ArgumentError` when no contract covers the action |
|
|
436
|
-
| `
|
|
480
|
+
| `permitted_params(action = action_name)` | The cast, validated, defaulted `HashWithIndifferentAccess`. Memoized per action. Raises `InvalidParameters` on violation (in [monitor mode](#monitor-mode-roll-out-without-rejecting), returns the raw pass-through instead), or `ArgumentError` when no contract covers the action |
|
|
481
|
+
| `permittable_violations(action = action_name)` | The violation details recorded by validating `action` — `[]` when clean. Triggers the same memoized validation; under enforce it swallows the raise, making "would this request fail?" a one-liner |
|
|
482
|
+
| `enforce_params_contract` | The `before_action` entry point. Validates rules declared `enforce: true` and all [monitor-mode](#monitor-mode-roll-out-without-rejecting) rules. Public, so hosts can `skip_before_action` it |
|
|
437
483
|
| `render_invalid_parameters(error)` | The `rescue_from` target. Renders via the host's `render_error` when defined, the inline envelope otherwise |
|
|
438
484
|
|
|
439
485
|
### Class methods
|
|
@@ -450,6 +496,7 @@ Output is deterministic (fixed key order, declaration-order properties), so the
|
|
|
450
496
|
|---|---|
|
|
451
497
|
| `Permittable.filter_parameter_registry` | The live registry of `sensitive:` field names |
|
|
452
498
|
| `Permittable.filter_parameter_registry=` | Swap in your own duck-typed registry |
|
|
499
|
+
| `Permittable.mode` / `Permittable.mode=` | App-wide default (`:enforce`) for rules that don't declare their own `mode:` |
|
|
453
500
|
| `Permittable::InvalidParameters` | Raised on violation; carries `#details` and `#status` |
|
|
454
501
|
| `Permittable::JsonSchema` | Contract data → JSON Schema fragments (`.rule`, `.object`, `.field`) |
|
|
455
502
|
| `Permittable::OpenAPI` | OpenAPI 3.1 assembly (`.document`, `.operations_for`, `.request_body_for`, `.components`) |
|
|
@@ -471,6 +518,7 @@ A bad contract is a programmer error, so it fails when the class loads — never
|
|
|
471
518
|
- An empty contract, or a nested block declaring no sub-fields
|
|
472
519
|
- `finalize` declared twice, without a block, or inside a nested block
|
|
473
520
|
- `permit_params` without a block, or an invalid `unknown:` mode
|
|
521
|
+
- An invalid `mode:` (and `Permittable.mode =` rejects invalid values at assignment)
|
|
474
522
|
- A `model:` that isn't an ActiveRecord class, or `model: true` that can't be inferred
|
|
475
523
|
|
|
476
524
|
## Compatibility
|
|
@@ -488,7 +536,7 @@ Using [concerns_on_rails](https://github.com/VSN2015/concerns_on_rails)? `Concer
|
|
|
488
536
|
|
|
489
537
|
```sh
|
|
490
538
|
bundle install
|
|
491
|
-
bundle exec rspec #
|
|
539
|
+
bundle exec rspec # 125 examples
|
|
492
540
|
bundle exec rubocop
|
|
493
541
|
```
|
|
494
542
|
|
data/lib/permittable/open_api.rb
CHANGED
|
@@ -135,6 +135,10 @@ module Permittable
|
|
|
135
135
|
operation["description"] = rule[:desc] if rule[:desc]
|
|
136
136
|
operation["requestBody"] = rule_request_body(rule)
|
|
137
137
|
operation["responses"] = responses_for(rule)
|
|
138
|
+
# The docs must not promise a 422 the server doesn't yet send. Only
|
|
139
|
+
# the rule's own declaration is contract data — the app-wide
|
|
140
|
+
# Permittable.mode is runtime configuration the export can't see.
|
|
141
|
+
operation["x-permittable-mode"] = "monitor" if rule[:mode] == :monitor
|
|
138
142
|
operation["x-permittable-catch-all"] = true if action == "*"
|
|
139
143
|
operation
|
|
140
144
|
end
|
data/lib/permittable/version.rb
CHANGED
data/lib/permittable.rb
CHANGED
|
@@ -63,6 +63,21 @@ require "permittable/filter_parameter_registry"
|
|
|
63
63
|
# action that never reads params never pays. `enforce: true` installs the
|
|
64
64
|
# check as a before_action instead (reject before the action body runs).
|
|
65
65
|
#
|
|
66
|
+
# MONITOR MODE — the rollout switch. `mode: :monitor` on a rule (or
|
|
67
|
+
# `Permittable.mode = :monitor` app-wide; a rule's own mode: wins) runs the
|
|
68
|
+
# full pipeline but REPORTS violations instead of rejecting: the same
|
|
69
|
+
# "invalid_parameters.permittable" event fires (payload mode: :monitor),
|
|
70
|
+
# the logger warns, and permitted_params returns the raw params passed
|
|
71
|
+
# through untouched — no casts, no defaults, no transforms — so behaviour
|
|
72
|
+
# is identical to the pre-contract app (a missing root: passes an empty
|
|
73
|
+
# hash; a rootless contract drops only the router's bookkeeping keys).
|
|
74
|
+
# Monitor rules validate eagerly in the before_action regardless of
|
|
75
|
+
# enforce:, because telemetry must not depend on the action calling
|
|
76
|
+
# permitted_params — legacy actions still reading `params` directly are
|
|
77
|
+
# exactly the ones being monitored — and monitoring can never halt the
|
|
78
|
+
# request. `permittable_violations` reads the recorded details ([] when
|
|
79
|
+
# the request was clean).
|
|
80
|
+
#
|
|
66
81
|
# Coercion is deliberately STRICT — ActiveModel::Type is not used, because its
|
|
67
82
|
# casts are lenient by design ("abc".to_i == 0, Boolean.cast("abc") == true)
|
|
68
83
|
# and silently corrupting untrusted input is exactly what a contract must not
|
|
@@ -119,6 +134,7 @@ module Permittable
|
|
|
119
134
|
LABEL = "Permittable".freeze
|
|
120
135
|
SCALAR_TYPES = %i[string integer float decimal boolean date datetime].freeze
|
|
121
136
|
UNKNOWN_MODES = %i[ignore log error].freeze
|
|
137
|
+
MODES = %i[enforce monitor].freeze
|
|
122
138
|
# Rails merges routing bookkeeping into params; a top-level (root: false)
|
|
123
139
|
# unknown-keys check must not flag them.
|
|
124
140
|
ROUTING_KEYS = %w[controller action format].freeze
|
|
@@ -144,6 +160,26 @@ module Permittable
|
|
|
144
160
|
end
|
|
145
161
|
|
|
146
162
|
attr_writer :filter_parameter_registry
|
|
163
|
+
|
|
164
|
+
# App-wide default for rules that don't declare their own mode:.
|
|
165
|
+
# :enforce (the default) rejects violating requests; :monitor reports
|
|
166
|
+
# them — same instrumentation event with payload mode: :monitor, plus a
|
|
167
|
+
# logger.warn — and lets the request proceed with the raw params passed
|
|
168
|
+
# through. This is the rollout switch for brownfield adoption: set it
|
|
169
|
+
# from an initializer (Permittable.mode =
|
|
170
|
+
# ENV.fetch("PERMITTABLE_MODE", "enforce").to_sym) and flip controllers
|
|
171
|
+
# to their final mode one at a time, since a rule's own mode: always
|
|
172
|
+
# wins over this default.
|
|
173
|
+
def mode
|
|
174
|
+
@mode || :enforce
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def mode=(value)
|
|
178
|
+
value = value.to_sym
|
|
179
|
+
raise ArgumentError, "#{LABEL}: mode must be one of #{MODES.join(', ')}" unless MODES.include?(value)
|
|
180
|
+
|
|
181
|
+
@mode = value
|
|
182
|
+
end
|
|
147
183
|
end
|
|
148
184
|
|
|
149
185
|
# Raised when the request violates the matching contract. `details` is an
|
|
@@ -564,14 +600,23 @@ module Permittable
|
|
|
564
600
|
# undeclared keys, at every nesting level.
|
|
565
601
|
# enforce: false (default) validates lazily on the first
|
|
566
602
|
# permitted_params call; true validates in a before_action.
|
|
603
|
+
# mode: nil (default) follows Permittable.mode; :enforce rejects
|
|
604
|
+
# violating requests; :monitor reports them and passes the
|
|
605
|
+
# raw params through (see MONITOR MODE in the module
|
|
606
|
+
# comment).
|
|
567
607
|
# desc: documentation only — carried on the rule for exporters
|
|
568
608
|
# (Permittable::OpenAPI); the runtime never reads it.
|
|
569
|
-
def permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, desc: nil, &block)
|
|
609
|
+
def permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, mode: nil, desc: nil, &block)
|
|
570
610
|
raise ArgumentError, "#{LABEL}: permit_params requires a block declaring the contract fields" unless block
|
|
571
611
|
|
|
572
612
|
unknown = unknown.to_sym
|
|
573
613
|
raise ArgumentError, "#{LABEL}: :unknown must be one of #{UNKNOWN_MODES.join(', ')}" unless UNKNOWN_MODES.include?(unknown)
|
|
574
614
|
|
|
615
|
+
mode = mode&.to_sym
|
|
616
|
+
if mode && !MODES.include?(mode)
|
|
617
|
+
raise ArgumentError, "#{LABEL}: :mode must be one of #{MODES.join(', ')}, or nil to follow Permittable.mode"
|
|
618
|
+
end
|
|
619
|
+
|
|
575
620
|
builder = ContractBuilder.new
|
|
576
621
|
fields = builder.build(&block)
|
|
577
622
|
raise ArgumentError, "#{LABEL}: a contract must declare at least one field" if fields.empty?
|
|
@@ -581,7 +626,7 @@ module Permittable
|
|
|
581
626
|
register_sensitive_params(fields)
|
|
582
627
|
|
|
583
628
|
rule = { actions: actions.flatten.map(&:to_s).freeze, root: root && root.to_sym,
|
|
584
|
-
model: model_class, unknown: unknown, enforce: !!enforce, fields: fields,
|
|
629
|
+
model: model_class, unknown: unknown, enforce: !!enforce, mode: mode, fields: fields,
|
|
585
630
|
finalize: builder.finalizer, desc: desc }.freeze
|
|
586
631
|
self.permittable_contracts = permittable_contracts + [rule]
|
|
587
632
|
end
|
|
@@ -657,21 +702,44 @@ module Permittable
|
|
|
657
702
|
rule = self.class.permit_rule_for(action)
|
|
658
703
|
raise ArgumentError, "#{LABEL}: no params contract declared covering ##{action}" unless rule
|
|
659
704
|
|
|
660
|
-
@permittable_validated[action] = validate_params_contract!(rule)
|
|
705
|
+
@permittable_validated[action] = validate_params_contract!(rule, action)
|
|
661
706
|
end
|
|
662
707
|
|
|
663
708
|
# before_action entry point (public so hosts can `skip_before_action
|
|
664
|
-
# :enforce_params_contract`).
|
|
665
|
-
# `enforce: true
|
|
709
|
+
# :enforce_params_contract`). Two kinds of rule validate here: those that
|
|
710
|
+
# opted in with `enforce: true`, and monitor-mode rules — monitoring must
|
|
711
|
+
# not depend on the action calling permitted_params (legacy actions still
|
|
712
|
+
# reading `params` directly are exactly the ones being monitored), and it
|
|
713
|
+
# can never halt the request because monitor mode never raises.
|
|
666
714
|
def enforce_params_contract
|
|
667
715
|
action = permittable_action_name
|
|
668
716
|
return nil unless action
|
|
669
717
|
|
|
670
718
|
rule = self.class.permit_rule_for(action)
|
|
671
|
-
permitted_params(action) if rule && rule[:enforce]
|
|
719
|
+
permitted_params(action) if rule && (rule[:enforce] || permittable_mode(rule) == :monitor)
|
|
672
720
|
nil
|
|
673
721
|
end
|
|
674
722
|
|
|
723
|
+
# The violation details recorded by validating `action` (default: the
|
|
724
|
+
# current action) — [] when the request satisfied the contract. Triggers
|
|
725
|
+
# the same memoized validation as permitted_params, so under monitor mode
|
|
726
|
+
# this is the request-level observable ("what would have been
|
|
727
|
+
# rejected?"); under enforce mode it swallows the raise and hands back
|
|
728
|
+
# the details, which makes "would this request fail?" a one-liner in
|
|
729
|
+
# tests.
|
|
730
|
+
def permittable_violations(action = nil)
|
|
731
|
+
action = (action || permittable_action_name).to_s
|
|
732
|
+
@permittable_violations ||= {}
|
|
733
|
+
unless @permittable_violations.key?(action)
|
|
734
|
+
begin
|
|
735
|
+
permitted_params(action)
|
|
736
|
+
rescue InvalidParameters
|
|
737
|
+
# validation recorded the details before raising
|
|
738
|
+
end
|
|
739
|
+
end
|
|
740
|
+
@permittable_violations.fetch(action)
|
|
741
|
+
end
|
|
742
|
+
|
|
675
743
|
# rescue_from target — renders through the shared envelope (the host's
|
|
676
744
|
# render_error when present, the identical inline shape otherwise).
|
|
677
745
|
def render_invalid_parameters(error)
|
|
@@ -683,7 +751,7 @@ module Permittable
|
|
|
683
751
|
|
|
684
752
|
private
|
|
685
753
|
|
|
686
|
-
def validate_params_contract!(rule)
|
|
754
|
+
def validate_params_contract!(rule, action)
|
|
687
755
|
violations = []
|
|
688
756
|
source = permittable_root_hash(rule, violations)
|
|
689
757
|
result = ActiveSupport::HashWithIndifferentAccess.new
|
|
@@ -693,19 +761,53 @@ module Permittable
|
|
|
693
761
|
end
|
|
694
762
|
# finalize only sees a hash every field vouched for — never garbage.
|
|
695
763
|
result = permittable_run_finalize(rule[:finalize], result, violations) if violations.empty? && rule[:finalize]
|
|
764
|
+
violations.each(&:freeze)
|
|
765
|
+
(@permittable_violations ||= {})[action] = violations.freeze
|
|
696
766
|
return result if violations.empty?
|
|
767
|
+
return permittable_monitor_pass_through(rule, source, violations) if permittable_mode(rule) == :monitor
|
|
697
768
|
|
|
698
769
|
raise_invalid_parameters!(violations, status: source ? :unprocessable_entity : :bad_request)
|
|
699
770
|
end
|
|
700
771
|
|
|
772
|
+
# A rule's own mode: wins; otherwise the app-wide Permittable.mode.
|
|
773
|
+
def permittable_mode(rule)
|
|
774
|
+
rule[:mode] || Permittable.mode
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# Monitor mode's violation path: emit the same instrumentation event the
|
|
778
|
+
# enforce path does (payload mode: :monitor) plus a warn line, then hand
|
|
779
|
+
# back exactly what the client sent — no casts, no defaults, no
|
|
780
|
+
# transforms — so behaviour is identical to the pre-contract app. A
|
|
781
|
+
# missing root: passes an empty hash through (the envelope you asked for
|
|
782
|
+
# isn't there); a rootless contract drops only the router's bookkeeping
|
|
783
|
+
# keys, mirroring their exemption from the unknown-keys check.
|
|
784
|
+
def permittable_monitor_pass_through(rule, source, violations)
|
|
785
|
+
permittable_instrument_violations(violations, mode: :monitor)
|
|
786
|
+
if respond_to?(:logger) && logger
|
|
787
|
+
logger.warn("#{LABEL}: [monitor] ##{permittable_action_name} would have been rejected: " \
|
|
788
|
+
"#{permittable_violation_summary(violations)}")
|
|
789
|
+
end
|
|
790
|
+
return ActiveSupport::HashWithIndifferentAccess.new unless source
|
|
791
|
+
|
|
792
|
+
passed = ActiveSupport::HashWithIndifferentAccess.new(source)
|
|
793
|
+
rule[:root] ? passed : passed.except(*ROUTING_KEYS)
|
|
794
|
+
end
|
|
795
|
+
|
|
701
796
|
def raise_invalid_parameters!(violations, status:)
|
|
702
|
-
violations
|
|
797
|
+
permittable_instrument_violations(violations, mode: :enforce)
|
|
798
|
+
raise InvalidParameters.new("Invalid parameters: #{permittable_violation_summary(violations)}",
|
|
799
|
+
details: violations, status: status)
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
def permittable_instrument_violations(violations, mode:)
|
|
703
803
|
ActiveSupport::Notifications.instrument(
|
|
704
804
|
"invalid_parameters.permittable",
|
|
705
|
-
controller: permittable_controller_name, action: permittable_action_name, details: violations
|
|
805
|
+
controller: permittable_controller_name, action: permittable_action_name, details: violations, mode: mode
|
|
706
806
|
)
|
|
707
|
-
|
|
708
|
-
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
def permittable_violation_summary(violations)
|
|
810
|
+
violations.map { |v| v[:message] ? "#{v[:param]} #{v[:message]}" : "#{v[:param]} (#{v[:code]})" }.join(", ")
|
|
709
811
|
end
|
|
710
812
|
|
|
711
813
|
# One violation detail entry. A field's `message:` (String, or Hash keyed
|