permittable 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -0
- data/README.md +435 -32
- data/lib/permittable/audit.rb +241 -24
- data/lib/permittable/authored_values.rb +70 -10
- data/lib/permittable/column_guard.rb +31 -11
- data/lib/permittable/contract.rb +50 -1
- data/lib/permittable/error_envelope.rb +60 -6
- data/lib/permittable/generator.rb +335 -66
- data/lib/permittable/json_schema.rb +102 -15
- data/lib/permittable/open_api.rb +470 -81
- data/lib/permittable/railtie.rb +87 -0
- data/lib/permittable/rspec.rb +49 -7
- data/lib/permittable/tasks/generate.rake +11 -6
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +367 -69
- metadata +2 -2
|
@@ -16,22 +16,31 @@ module Permittable
|
|
|
16
16
|
# the same as `{}`. JSON Schema cannot say that — `type: integer` reads
|
|
17
17
|
# null as a present value of the wrong type — so the schema rejects a
|
|
18
18
|
# null the server would accept and ignore.
|
|
19
|
-
# * `validate:`/`transform:`/`finalize` are opaque callables
|
|
20
|
-
#
|
|
21
|
-
# with `x-permittable-*`
|
|
19
|
+
# * `validate:`/`transform:`/`finalize` are opaque callables the schema
|
|
20
|
+
# cannot run, so the field carrying one (or, for `finalize`, the rule's
|
|
21
|
+
# own object) is flagged with an `x-permittable-*` extension rather than
|
|
22
|
+
# mistranslated. `transform:` only reshapes a value already accepted, so
|
|
23
|
+
# it changes no verdict; `validate:` and `finalize` can refuse a value.
|
|
22
24
|
#
|
|
23
|
-
#
|
|
25
|
+
# The first two leave the schema STRICTER than the server, never looser: a
|
|
24
26
|
# client that validates against the published document is conservative,
|
|
25
|
-
# never surprised by a 422.
|
|
27
|
+
# never surprised by a 422. The third does not: `validate:` and `finalize`
|
|
28
|
+
# leave it LOOSER (below). spec/schema_conformance_spec.rb holds the line,
|
|
26
29
|
# asserting the direction of every divergence it permits.
|
|
27
30
|
#
|
|
28
31
|
# Where a rule has no JSON Schema keyword, the schema is LOOSER instead,
|
|
29
32
|
# and a client following it can still earn a 422. Each such rule stays
|
|
30
|
-
# visible on its own field,
|
|
31
|
-
# rather than letting it
|
|
33
|
+
# visible in the schema (on its own field, or for `finalize` on the rule's
|
|
34
|
+
# object), and the conformance spec labels each one rather than letting it
|
|
35
|
+
# pass as agreement:
|
|
32
36
|
# * a `:json` field's `max_depth:` — `x-permittable-max-depth`;
|
|
33
37
|
# * a Range of non-numbers (`in: "a".."m"`) — `x-permittable-range`;
|
|
34
38
|
# * a `validate:` proc — `x-permittable-custom-validation`;
|
|
39
|
+
# * a `finalize` block, whose `violate!` cross-field checks no field's
|
|
40
|
+
# schema can carry — `x-permittable-finalize` on the rule's object;
|
|
41
|
+
# * a `sensitive:` field's `in:` and `format:`, withheld because they
|
|
42
|
+
# would publish the secret itself — the same marker (see
|
|
43
|
+
# withholds_constraints?);
|
|
35
44
|
# * a `normalize:` step, which the server runs BEFORE it checks, so
|
|
36
45
|
# " " passes a minLength of 3 and is then absent —
|
|
37
46
|
# `x-permittable-normalize`;
|
|
@@ -66,8 +75,16 @@ module Permittable
|
|
|
66
75
|
# `root:` envelope when the rule declares one. The wrapper itself stays
|
|
67
76
|
# permissive even under `unknown: :error` — the runtime never inspects
|
|
68
77
|
# sibling keys outside the root.
|
|
78
|
+
#
|
|
79
|
+
# `finalize` is the one opaque callable that belongs to the rule, not to
|
|
80
|
+
# a field: it runs once every field has passed and can still refuse the
|
|
81
|
+
# payload (`ends_on` before `starts_on`), which no per-field keyword can
|
|
82
|
+
# say. The flag therefore rides on the rule's own object, inside the
|
|
83
|
+
# `root:` wrapper where the fields are, so a client or generator that
|
|
84
|
+
# reads the document knows the schema alone is not the whole verdict.
|
|
69
85
|
def rule(permit_rule)
|
|
70
86
|
schema = object(permit_rule[:fields], unknown: permit_rule[:unknown])
|
|
87
|
+
schema["x-permittable-finalize"] = true if permit_rule[:finalize]
|
|
71
88
|
return schema unless permit_rule[:root]
|
|
72
89
|
|
|
73
90
|
root = permit_rule[:root].to_s
|
|
@@ -125,12 +142,31 @@ module Permittable
|
|
|
125
142
|
def scalar_schema(field)
|
|
126
143
|
schema = SCALAR_SCHEMAS.fetch(field[:type]).deep_dup
|
|
127
144
|
apply_format_name!(schema, field)
|
|
128
|
-
|
|
145
|
+
withheld = withholds_constraints?(field)
|
|
146
|
+
apply_in!(schema, field) unless withheld
|
|
129
147
|
apply_string_bounds!(schema, field)
|
|
130
|
-
apply_pattern!(schema, field[:format])
|
|
148
|
+
apply_pattern!(schema, field[:format]) unless withheld
|
|
149
|
+
schema["x-permittable-custom-validation"] = true if withheld
|
|
131
150
|
schema
|
|
132
151
|
end
|
|
133
152
|
|
|
153
|
+
# A `sensitive:` field's `in:` list/Range and hand-written `format:` ARE
|
|
154
|
+
# the secret: an enum of the only accepted PINs is an allowlist, and
|
|
155
|
+
# `pattern: "^FMT_SECRET$"` or a 4000..4999 range names or narrows the
|
|
156
|
+
# value the same way, so publishing them would defeat the redaction as
|
|
157
|
+
# surely as publishing a `default:` (see annotate). They are left out,
|
|
158
|
+
# with `x-permittable-custom-validation` as the only signal, which makes
|
|
159
|
+
# the exported schema LOOSER than the server for that field. A
|
|
160
|
+
# `format:` PRESET is the exception: it names a public rule, not a
|
|
161
|
+
# secret, so it keeps its `format` and `pattern`. `field[:sensitive]` is
|
|
162
|
+
# already resolved onto a cascaded child, so a child counts. Only a field
|
|
163
|
+
# that actually declares one of the two is flagged.
|
|
164
|
+
def withholds_constraints?(field)
|
|
165
|
+
return false unless field[:sensitive]
|
|
166
|
+
|
|
167
|
+
!field[:in].nil? || (!field[:format].nil? && field[:format_name].nil?)
|
|
168
|
+
end
|
|
169
|
+
|
|
134
170
|
# A `:json` field's shape is deliberately undeclared, so the schema says
|
|
135
171
|
# "an object" and carries only the bounds the field does declare. JSON
|
|
136
172
|
# Schema has no nesting-depth keyword, so `max_depth:` stays visible as an
|
|
@@ -293,17 +329,39 @@ module Permittable
|
|
|
293
329
|
!allowed.nil? && !allowed.is_a?(Range) && Coercion.in_list(allowed).nil?
|
|
294
330
|
end
|
|
295
331
|
|
|
332
|
+
# Every field whose schema admits a string gets its bounds here: :string,
|
|
333
|
+
# and the string encodings of :decimal, :date and :datetime. minLength
|
|
334
|
+
# and maxLength only ever constrain the string branch, so the number
|
|
335
|
+
# branch of a :decimal is untouched. `length:` itself is :string-only.
|
|
296
336
|
def apply_string_bounds!(schema, field)
|
|
297
|
-
return unless
|
|
337
|
+
return unless Array(schema["type"]).include?("string")
|
|
298
338
|
|
|
299
339
|
min, max = length_bounds(field[:length])
|
|
300
|
-
|
|
301
|
-
# string can never validly be empty — the schema says so.
|
|
302
|
-
min = 1 if field[:required] && min.to_i < 1
|
|
340
|
+
min = 1 if blank_floor?(field) && min.to_i < 1
|
|
303
341
|
schema["minLength"] = min if min
|
|
304
342
|
schema["maxLength"] = max if max
|
|
305
343
|
end
|
|
306
344
|
|
|
345
|
+
# "" is ABSENT whatever the type, and an absent required field violates,
|
|
346
|
+
# so a required field can never validly be "" — the schema says so. For
|
|
347
|
+
# :date, :datetime and :decimal that is the only thing the schema can say
|
|
348
|
+
# about a blank: `format` is an annotation, and "decimal" is no format a
|
|
349
|
+
# validator knows. (A whitespace-only string needs no floor there: those
|
|
350
|
+
# types refuse it as invalid_type, and the schema never claims to catch a
|
|
351
|
+
# cast failure. For :date and :datetime `format` at least names the
|
|
352
|
+
# failure to a validator that asserts it; for :decimal nothing does, so
|
|
353
|
+
# " " passes the schema and earns a 422. That is a documented looser
|
|
354
|
+
# case, the same one as "abc" for a :decimal, and deliberately has no
|
|
355
|
+
# `pattern` to close it.)
|
|
356
|
+
#
|
|
357
|
+
# A nullable field is the exception: it reads "" as an explicit null, a
|
|
358
|
+
# value rather than an absence, so a floor would reject what the server
|
|
359
|
+
# takes. A nullable :string keeps the floor it has always had (stricter
|
|
360
|
+
# than the server, the safe direction); the other types start without.
|
|
361
|
+
def blank_floor?(field)
|
|
362
|
+
field[:required] && (field[:type] == :string || !field[:nullable])
|
|
363
|
+
end
|
|
364
|
+
|
|
307
365
|
def apply_pattern!(schema, regexp)
|
|
308
366
|
return unless regexp
|
|
309
367
|
|
|
@@ -365,13 +423,18 @@ module Permittable
|
|
|
365
423
|
# untranslatable or opaque fact in this file is handled: left out, with
|
|
366
424
|
# the `x-permittable-*` extension (here, `writeOnly`/`x-permittable-
|
|
367
425
|
# sensitive`) as the only signal that something is missing.
|
|
426
|
+
#
|
|
427
|
+
# The same goes for an array whose elements hold a sensitive field at
|
|
428
|
+
# any depth (see holds_sensitive?): its `default:`/`example:` carries
|
|
429
|
+
# that field's value, so it is omitted too.
|
|
368
430
|
def annotate(schema, field)
|
|
369
431
|
decimal_mode = field[:kind] == JSON_TYPE ? :string : :number
|
|
370
|
-
unless field
|
|
432
|
+
unless holds_sensitive?(field)
|
|
371
433
|
schema["default"] = json_value(field[:default], decimal: decimal_mode) if field.key?(:default)
|
|
372
434
|
schema["examples"] = [json_value(field[:example], decimal: decimal_mode)] if field.key?(:example)
|
|
373
435
|
end
|
|
374
|
-
|
|
436
|
+
# dup: the stored desc is frozen; the export is editable and detached.
|
|
437
|
+
schema["description"] = field[:desc].dup if field[:desc]
|
|
375
438
|
if field[:sensitive]
|
|
376
439
|
schema["writeOnly"] = true
|
|
377
440
|
schema["x-permittable-sensitive"] = true
|
|
@@ -382,6 +445,30 @@ module Permittable
|
|
|
382
445
|
schema
|
|
383
446
|
end
|
|
384
447
|
|
|
448
|
+
# Is this field sensitive, or does it contain a sensitive field at any
|
|
449
|
+
# depth? A container's `default:`/`example:` is a whole value, so a
|
|
450
|
+
# sensitive sub-field's value is inside it. `array :cards, default:
|
|
451
|
+
# [{}]` with `optional :pan, sensitive: true, default: "4111..."` even
|
|
452
|
+
# stores `[{"pan" => "4111..."}]`, because the walker fills in each
|
|
453
|
+
# element's sub-field defaults (see AuthoredValues). Omitting pan's own
|
|
454
|
+
# default protected nothing while the array still published the value.
|
|
455
|
+
#
|
|
456
|
+
# The whole container value is omitted. Redacting only the sensitive
|
|
457
|
+
# keys inside it was the alternative, and it would mislead. Without the
|
|
458
|
+
# key, `[{}]` documents a default the server never hands out, since it
|
|
459
|
+
# fills pan in. An example without its required secret fails the very
|
|
460
|
+
# items schema it is attached to. A placeholder can fail the
|
|
461
|
+
# sub-field's own format or in:. The field's own default is omitted
|
|
462
|
+
# rather than replaced for the same reasons.
|
|
463
|
+
#
|
|
464
|
+
# The answer is read off the declaration, not the value, so even
|
|
465
|
+
# `default: []` is withheld. A value stored as authored (beside a
|
|
466
|
+
# `transform:`) can key its hashes by String or Symbol, and a check
|
|
467
|
+
# that has to find the sensitive key there is one more way to miss it.
|
|
468
|
+
def holds_sensitive?(field)
|
|
469
|
+
field[:sensitive] || Array(field[:fields]).any? { |child| holds_sensitive?(child) }
|
|
470
|
+
end
|
|
471
|
+
|
|
385
472
|
# The server checks the NORMALIZED string, so minLength/maxLength/pattern
|
|
386
473
|
# describe a value the client never sends: under `normalize: :squish`,
|
|
387
474
|
# " " satisfies a minLength of 3 and then squishes to "" (absent), and
|