rubocop-dev_doc 0.12.1 → 0.13.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/config/default.yml +79 -0
- data/lib/dev_doc/test/best_practice_lints.rb +4 -0
- data/lib/dev_doc/test/lints/cross_tenant_canary_check.rb +111 -0
- data/lib/dev_doc/test/lints/cross_tenant_canary_sweep.rb +103 -0
- data/lib/dev_doc/test/lints/enqueue_disable_naming.rb +138 -0
- data/lib/dev_doc/test/lints/external_io_boundary.rb +132 -0
- data/lib/rubocop/cop/dev_doc/auth/adjacent_justification.rb +55 -0
- data/lib/rubocop/cop/dev_doc/auth/login_only_policy_justification.rb +165 -0
- data/lib/rubocop/cop/dev_doc/auth/no_record_presence_in_policy.rb +104 -0
- data/lib/rubocop/cop/dev_doc/auth/unscoped_find_justification.rb +194 -0
- data/lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb +30 -14
- data/lib/rubocop/cop/dev_doc/rails/no_persistence_in_service.rb +168 -0
- data/lib/rubocop/cop/dev_doc/rails/no_transaction_in_controller.rb +92 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +12 -0
- data/lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb +6 -1
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +12 -2
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
require_relative 'adjacent_justification'
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module DevDoc
|
|
6
|
+
module Auth
|
|
7
|
+
# A policy gate whose entire body is "is anyone signed in?" must carry
|
|
8
|
+
# an adjacent justification naming where per-record scoping happens.
|
|
9
|
+
#
|
|
10
|
+
# ## Rationale
|
|
11
|
+
# `authorize :index do user_signed_in? end` authorizes EVERY
|
|
12
|
+
# authenticated user — including users belonging to other tenants. That
|
|
13
|
+
# is only safe when something *else* scopes what the action can reach:
|
|
14
|
+
# the controller resolves records through `current_user` associations,
|
|
15
|
+
# the id in the URL is a non-enumerable capability token, or the data is
|
|
16
|
+
# genuinely public. When none of those hold — when the action resolves
|
|
17
|
+
# any record from a client-controlled param — a login-only gate IS a
|
|
18
|
+
# cross-tenant IDOR: any signed-in user can enumerate ids and read
|
|
19
|
+
# another tenant's records. This cop exists because that exact shape
|
|
20
|
+
# shipped: routes resolving records straight from params, gated by
|
|
21
|
+
# policies whose only check was `user_signed_in?`.
|
|
22
|
+
#
|
|
23
|
+
# The cop cannot see the controller from the policy file, so it flags
|
|
24
|
+
# every login-only gate and requires the developer to state the scoping
|
|
25
|
+
# mechanism in an adjacent comment (containing the configurable marker,
|
|
26
|
+
# default "login-only:"). Writing that sentence honestly is the check:
|
|
27
|
+
# it forces the question "what stops a stranger with someone else's id?"
|
|
28
|
+
# at the moment the gate is written. A login-only catch-all
|
|
29
|
+
# (`authorize :glib_all`) gates every action of the resource and
|
|
30
|
+
# deserves the same scrutiny.
|
|
31
|
+
#
|
|
32
|
+
# ## What an offense means
|
|
33
|
+
# An offense does NOT assert the gate is wrong. Many login-only gates
|
|
34
|
+
# are correct — the record IS the current user, the controller scopes
|
|
35
|
+
# every lookup through `current_user` associations, the route is
|
|
36
|
+
# token-addressed, or the data is public. The cop asserts that scoping
|
|
37
|
+
# story is UNDOCUMENTED at the gate, the one place a security reviewer
|
|
38
|
+
# will look for it. A correct-but-unmarked gate is still a violation
|
|
39
|
+
# until the story is written down; the marker is the intended
|
|
40
|
+
# resolution, not a workaround. On adoption, expect a one-time
|
|
41
|
+
# justification sweep over existing gates — writing each sentence
|
|
42
|
+
# honestly IS the audit.
|
|
43
|
+
#
|
|
44
|
+
# ## Remedy
|
|
45
|
+
# Prefer binding the decision to the record or context over justifying:
|
|
46
|
+
#
|
|
47
|
+
# ❌ — every signed-in user passes; nothing binds them to the record
|
|
48
|
+
# authorize :index do
|
|
49
|
+
# user_signed_in?
|
|
50
|
+
# end
|
|
51
|
+
#
|
|
52
|
+
# ✔️ — the gate binds the user to the record
|
|
53
|
+
# authorize :index do
|
|
54
|
+
# record.is_a?(Project) && record.member?(current_user)
|
|
55
|
+
# end
|
|
56
|
+
#
|
|
57
|
+
# ✔️ — genuinely login-only, with the scoping stated where it can be
|
|
58
|
+
# # reviewed and re-checked
|
|
59
|
+
# # login-only: the controller resolves records exclusively through
|
|
60
|
+
# # current_user.projects; no params-derived id is honored.
|
|
61
|
+
# authorize :index do
|
|
62
|
+
# user_signed_in?
|
|
63
|
+
# end
|
|
64
|
+
#
|
|
65
|
+
# ✔️ — a policy that is login-only THROUGHOUT (current-user pages,
|
|
66
|
+
# # own-resource CRUD) states it once, above the class
|
|
67
|
+
# # login-only: every action operates on current_user itself; the
|
|
68
|
+
# # controller never resolves a record from params.
|
|
69
|
+
# class MfaDevicePolicy < ApplicationPolicy
|
|
70
|
+
# authorize :index do user_signed_in? end
|
|
71
|
+
# authorize :create do user_signed_in? end
|
|
72
|
+
# end
|
|
73
|
+
#
|
|
74
|
+
# A class-level marker covers every gate in the class — including ones
|
|
75
|
+
# added after the marker was written. Prefer per-gate markers unless
|
|
76
|
+
# the whole class genuinely shares one scoping story.
|
|
77
|
+
#
|
|
78
|
+
# ## Configuration
|
|
79
|
+
# `JustificationMarker` (default: `login-only:`) — the phrase the
|
|
80
|
+
# adjacent comment must contain (same line or the contiguous comment
|
|
81
|
+
# block directly above the gate, or above an enclosing class/module
|
|
82
|
+
# declaration to cover all gates within it).
|
|
83
|
+
#
|
|
84
|
+
# `LoginOnlyPredicates` (default: `user_signed_in?`, `everyone`) —
|
|
85
|
+
# bare predicates that make a gate login-only (or weaker) when they are
|
|
86
|
+
# its entire body. A literal `true` body is always flagged.
|
|
87
|
+
#
|
|
88
|
+
# NOTE: The cop is deliberately narrow: it flags only `authorize` blocks
|
|
89
|
+
# whose body is exactly one of the configured predicates (or `true`).
|
|
90
|
+
# Compound gates (`user_signed_in? && owner?`) and plain `def index?`
|
|
91
|
+
# policy methods are not tracked — the former carry more than login,
|
|
92
|
+
# the latter are building blocks whose decision point is elsewhere.
|
|
93
|
+
#
|
|
94
|
+
# @example
|
|
95
|
+
# # bad — login-only gate with no stated scoping
|
|
96
|
+
# authorize :index do
|
|
97
|
+
# user_signed_in?
|
|
98
|
+
# end
|
|
99
|
+
#
|
|
100
|
+
# # bad — `true` is weaker still
|
|
101
|
+
# authorize :show do
|
|
102
|
+
# true
|
|
103
|
+
# end
|
|
104
|
+
#
|
|
105
|
+
# # good — decision bound to the record
|
|
106
|
+
# authorize :show do
|
|
107
|
+
# record.participant?(current_user)
|
|
108
|
+
# end
|
|
109
|
+
#
|
|
110
|
+
# # good — justified login-only gate
|
|
111
|
+
# # login-only: records resolve through current_user associations only.
|
|
112
|
+
# authorize :index do
|
|
113
|
+
# user_signed_in?
|
|
114
|
+
# end
|
|
115
|
+
class LoginOnlyPolicyJustification < Base
|
|
116
|
+
include AdjacentJustification
|
|
117
|
+
|
|
118
|
+
MSG = 'This gate authorizes EVERY signed-in user, including other tenants\' — if the ' \
|
|
119
|
+
'controller resolves any record from params, that is a cross-tenant IDOR. Bind the ' \
|
|
120
|
+
'decision to the record/context, or state the scoping mechanism in a "%<marker>s" ' \
|
|
121
|
+
'comment directly above (controller self-scope via current_user, non-enumerable ' \
|
|
122
|
+
'capability id, or genuinely public data).'.freeze
|
|
123
|
+
|
|
124
|
+
def on_block(node)
|
|
125
|
+
send_node = node.send_node
|
|
126
|
+
return unless send_node.method?(:authorize) && send_node.receiver.nil?
|
|
127
|
+
return unless login_only_body?(node.body)
|
|
128
|
+
return if adjacent_justification?(node) || class_level_justification?(node)
|
|
129
|
+
|
|
130
|
+
add_offense(node.body, message: format(MSG, marker: justification_marker))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
# A marker above an enclosing class/module declaration covers every
|
|
136
|
+
# gate within it — for policies that are login-only throughout.
|
|
137
|
+
# standalone_only: without it, a marker trailing the PREVIOUS class's
|
|
138
|
+
# `end` would silence this class too.
|
|
139
|
+
def class_level_justification?(node)
|
|
140
|
+
node.each_ancestor(:class, :module).any? do |ancestor|
|
|
141
|
+
adjacent_justification?(ancestor, standalone_only: true)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def login_only_body?(body)
|
|
146
|
+
return false unless body
|
|
147
|
+
return true if body.true_type?
|
|
148
|
+
|
|
149
|
+
body.send_type? && body.receiver.nil? && body.arguments.empty? &&
|
|
150
|
+
login_only_predicates.include?(body.method_name)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def login_only_predicates
|
|
154
|
+
@login_only_predicates ||=
|
|
155
|
+
Array(cop_config.fetch('LoginOnlyPredicates', %w[user_signed_in? everyone])).map(&:to_sym)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def justification_marker
|
|
159
|
+
cop_config.fetch('JustificationMarker', 'login-only:')
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Auth
|
|
5
|
+
# Policy code must not use `record.present?` / `record.blank?` as an
|
|
6
|
+
# authorization signal.
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# A policy's `record` answers "what is being acted on". Whether it is
|
|
10
|
+
# *present* is a **loading** outcome — it only proves the controller's
|
|
11
|
+
# lookup resolved something — never an **authorization** fact. Gating on
|
|
12
|
+
# presence fails in two distinct ways:
|
|
13
|
+
#
|
|
14
|
+
# 1. **Fail-open via placeholder records.** Pundit-style stacks support
|
|
15
|
+
# headless policies, where the "record" is a Symbol naming the policy
|
|
16
|
+
# rather than a model instance — and resource-resolution layers can
|
|
17
|
+
# fall back to that Symbol when a lookup resolves nothing. A gate
|
|
18
|
+
# written as `record.present?` is then true for exactly the request
|
|
19
|
+
# it was written to refuse: the one whose record could not be
|
|
20
|
+
# resolved. This is an observed production incident shape — a
|
|
21
|
+
# cross-tenant guard reduced to `record.present?` passed for a
|
|
22
|
+
# foreign-tenant id because the missing record arrived as a truthy
|
|
23
|
+
# placeholder.
|
|
24
|
+
#
|
|
25
|
+
# 2. **Presence asserts nothing about the record.** Even when the record
|
|
26
|
+
# is genuinely a model instance, `present?` says nothing about its
|
|
27
|
+
# type, its tenant, or its relationship to the user. Authorization
|
|
28
|
+
# must bind the user to the record — a type check plus the
|
|
29
|
+
# ownership/attribute conditions that actually make the decision.
|
|
30
|
+
#
|
|
31
|
+
# ## Remedy
|
|
32
|
+
# Replace the presence check with a type check — it subsumes the nil
|
|
33
|
+
# guard and additionally asserts the record is the thing the policy
|
|
34
|
+
# believes it is — and bind the decision to the record's attributes:
|
|
35
|
+
#
|
|
36
|
+
# ❌
|
|
37
|
+
# authorize :create do
|
|
38
|
+
# admin_of_organization?(organization) && record.present?
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
# ✔️
|
|
42
|
+
# authorize :create do
|
|
43
|
+
# admin_of_organization?(organization) && record.is_a?(Member)
|
|
44
|
+
# end
|
|
45
|
+
#
|
|
46
|
+
# ✔️ — bind to the user, not to mere existence
|
|
47
|
+
# def viewing_own_record?
|
|
48
|
+
# record.is_a?(Member) && record.user_id == user.id
|
|
49
|
+
# end
|
|
50
|
+
#
|
|
51
|
+
# NOTE: The cop is deliberately narrow: it flags `present?`/`blank?`
|
|
52
|
+
# only on the literal `record` receiver. Aliases (`def member = record`
|
|
53
|
+
# then `member.present?`) are not tracked — reviewers must catch those.
|
|
54
|
+
# Nil guards that precede attribute checks (`return false if
|
|
55
|
+
# record.nil?`) are not flagged; they fail closed and the attribute
|
|
56
|
+
# check that follows carries the authorization.
|
|
57
|
+
#
|
|
58
|
+
# @example
|
|
59
|
+
# # bad — presence as the authorization answer
|
|
60
|
+
# authorize :create do
|
|
61
|
+
# record.present?
|
|
62
|
+
# end
|
|
63
|
+
#
|
|
64
|
+
# # bad — negated form, same loading fact
|
|
65
|
+
# def member_missing?
|
|
66
|
+
# record.blank?
|
|
67
|
+
# end
|
|
68
|
+
#
|
|
69
|
+
# # good — type check binds the gate to a real record
|
|
70
|
+
# authorize :create do
|
|
71
|
+
# record.is_a?(Member)
|
|
72
|
+
# end
|
|
73
|
+
#
|
|
74
|
+
# # good — authorization binds the user to the record
|
|
75
|
+
# def viewing_own_record?
|
|
76
|
+
# record.is_a?(Member) && record.user_id == user.id
|
|
77
|
+
# end
|
|
78
|
+
class NoRecordPresenceInPolicy < Base
|
|
79
|
+
MSG = '`record.%<method>s` is a loading fact, not an authorization fact — resolution ' \
|
|
80
|
+
'fallbacks can make it true via a placeholder even when nothing was resolved. ' \
|
|
81
|
+
'Use a type check (`record.is_a?(SomeModel)`) plus the ownership/attribute ' \
|
|
82
|
+
'conditions that actually authorize this user.'.freeze
|
|
83
|
+
|
|
84
|
+
RESTRICT_ON_SEND = %i[present? blank?].freeze
|
|
85
|
+
|
|
86
|
+
def on_send(node)
|
|
87
|
+
return unless bare_record?(node.receiver)
|
|
88
|
+
|
|
89
|
+
add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
|
|
90
|
+
end
|
|
91
|
+
alias on_csend on_send
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# True if `node` is a bare `record` send (the Pundit-style policy
|
|
96
|
+
# reader — no receiver, no arguments).
|
|
97
|
+
def bare_record?(node)
|
|
98
|
+
node&.send_type? && node.method_name == :record && node.receiver.nil? && node.arguments.empty?
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
require_relative 'adjacent_justification'
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module DevDoc
|
|
6
|
+
module Auth
|
|
7
|
+
# A controller lookup that resolves a record from a client-controlled
|
|
8
|
+
# param on a bare model class (`Post.find(params[:id])`) must carry an
|
|
9
|
+
# adjacent justification naming where the record gets bound to the
|
|
10
|
+
# requester.
|
|
11
|
+
#
|
|
12
|
+
# ## Rationale
|
|
13
|
+
# `Post.find(params[:id])` reaches EVERY row of the table — the id is
|
|
14
|
+
# client-controlled and usually enumerable, so unless something else
|
|
15
|
+
# binds the resolved record to the requester (a policy that checks the
|
|
16
|
+
# record's owner/tenant, a tenant-scoped base controller, a
|
|
17
|
+
# non-enumerable capability id), any signed-in user can read or act on
|
|
18
|
+
# any tenant's record by walking ids. This cop exists because that
|
|
19
|
+
# exact shape shipped as cross-tenant IDORs: bare `find`s whose
|
|
20
|
+
# policies checked only "signed in" or bound the wrong level (the
|
|
21
|
+
# parent, not the specific record).
|
|
22
|
+
#
|
|
23
|
+
# The signal is inclusion-based: a CONSTANT receiver means unscoped. A
|
|
24
|
+
# lookup that travels through an association or `current_user`
|
|
25
|
+
# (`current_user.posts.find(...)`, `@project.members.find_by(...)`) has
|
|
26
|
+
# a non-constant receiver and passes silently — scoping through the
|
|
27
|
+
# association IS the fix this cop pushes toward.
|
|
28
|
+
#
|
|
29
|
+
# ## What an offense means
|
|
30
|
+
# An offense does NOT assert the site is vulnerable. The cop cannot see
|
|
31
|
+
# the policy, callback, or later guard that may already bind the
|
|
32
|
+
# record to the requester — it asserts the binding is UNDOCUMENTED at
|
|
33
|
+
# the lookup. A find that is safe through such controlling code is
|
|
34
|
+
# still a violation until the binding is either moved into the lookup
|
|
35
|
+
# (scoping) or stated in the marker comment; the marker is the intended
|
|
36
|
+
# resolution for legitimate root finds, not a workaround. On adoption,
|
|
37
|
+
# expect a one-time justification sweep over existing lookups — writing
|
|
38
|
+
# each binding sentence honestly IS the audit.
|
|
39
|
+
#
|
|
40
|
+
# ## Remedy
|
|
41
|
+
# Prefer scoping the lookup over justifying it:
|
|
42
|
+
#
|
|
43
|
+
# ❌ — any signed-in user can reach any row
|
|
44
|
+
# @post = Post.find(params[:id])
|
|
45
|
+
#
|
|
46
|
+
# ✔️ — the lookup itself binds the record to the requester
|
|
47
|
+
# @post = current_user.posts.find(params[:id])
|
|
48
|
+
#
|
|
49
|
+
# ✔️ — nested records resolve through the already-bound parent
|
|
50
|
+
# @comment = @post.comments.find_by(id: params[:comment_id])
|
|
51
|
+
#
|
|
52
|
+
# ✔️ — genuinely necessary root find, binding stated for review
|
|
53
|
+
# # unscoped-find: root record of the route; the policy receives it as
|
|
54
|
+
# # resource: and checks record-derived tenant admin.
|
|
55
|
+
# @post = Post.find(params[:id])
|
|
56
|
+
#
|
|
57
|
+
# ✔️ — the lookup key itself is the binding: a non-enumerable
|
|
58
|
+
# # capability id (see NonEnumerableKeys)
|
|
59
|
+
# @organization = Organization.find_by!(uuid: params[:organization_id])
|
|
60
|
+
#
|
|
61
|
+
# ## Configuration
|
|
62
|
+
# `JustificationMarker` (default: `unscoped-find:`) — the phrase the
|
|
63
|
+
# adjacent comment must contain (same line or the contiguous comment
|
|
64
|
+
# block directly above).
|
|
65
|
+
#
|
|
66
|
+
# `FinderMethods` (default: `find`, `find_by`, `find_by!`) — lookup
|
|
67
|
+
# methods the cop watches.
|
|
68
|
+
#
|
|
69
|
+
# `NonEnumerableKeys` (default: `uuid`, `token`, `secret_key`) — lookup
|
|
70
|
+
# keys treated as non-enumerable capability ids. A lookup is skipped
|
|
71
|
+
# only when its whole argument list is plain keyword pairs and every
|
|
72
|
+
# params-derived value is keyed by one of these
|
|
73
|
+
# (`find_by!(uuid: params[:id])`) — the unguessable key IS the binding,
|
|
74
|
+
# which the rationale above already endorses. The cop only trusts the
|
|
75
|
+
# key NAME; it cannot verify the column is actually unguessable, so
|
|
76
|
+
# only list keys your codebase reserves for high-entropy values.
|
|
77
|
+
# `slug` is deliberately not a default: slugs are public, guessable
|
|
78
|
+
# identifiers, not capabilities. Anything whose key can't be read off
|
|
79
|
+
# the call keeps the offense: positional lookups (`find(params[:id])` —
|
|
80
|
+
# even when `:id` holds a token), `**` splats, dynamic keys, and
|
|
81
|
+
# nested-hash values (association paths). Set to `[]` to disable the
|
|
82
|
+
# skip entirely.
|
|
83
|
+
#
|
|
84
|
+
# NOTE: The cop is deliberately narrow: it flags only a DIRECT constant
|
|
85
|
+
# receiver with an argument that references `params`. A chain rooted in
|
|
86
|
+
# a constant (`Post.includes(:x).find(params[:id])`) and an id copied
|
|
87
|
+
# into a local first (`id = params[:id]; Post.find(id)`) are not
|
|
88
|
+
# tracked — reviewers must catch those.
|
|
89
|
+
#
|
|
90
|
+
# @example
|
|
91
|
+
# # bad — unscoped, client-controlled id
|
|
92
|
+
# @post = Post.find(params[:id])
|
|
93
|
+
#
|
|
94
|
+
# # bad — find_by hides the same reach
|
|
95
|
+
# @post = Post.find_by(id: params[:post_id])
|
|
96
|
+
#
|
|
97
|
+
# # good — scoped through the requester
|
|
98
|
+
# @post = current_user.posts.find(params[:id])
|
|
99
|
+
#
|
|
100
|
+
# # good — justified root find
|
|
101
|
+
# # unscoped-find: policy binds via resource:; see PostPolicy#show?
|
|
102
|
+
# @post = Post.find(params[:id])
|
|
103
|
+
class UnscopedFindJustification < Base
|
|
104
|
+
include AdjacentJustification
|
|
105
|
+
|
|
106
|
+
MSG = '`%<receiver>s.%<method>s(params[...])` resolves a record from a client-controlled ' \
|
|
107
|
+
'id with no scoping — unless something binds it to the requester, any signed-in ' \
|
|
108
|
+
'user reaches any tenant\'s record by enumerating ids. Scope the lookup through an ' \
|
|
109
|
+
'association/current_user, or state the binding in a "%<marker>s" comment ' \
|
|
110
|
+
'directly above.'.freeze
|
|
111
|
+
|
|
112
|
+
# FinderMethods is configuration-driven, so the name filter lives in
|
|
113
|
+
# on_send rather than RESTRICT_ON_SEND.
|
|
114
|
+
def on_send(node)
|
|
115
|
+
return unless finder_methods.include?(node.method_name)
|
|
116
|
+
return unless node.receiver&.const_type?
|
|
117
|
+
return unless node.arguments.any? { |argument| references_params?(argument) }
|
|
118
|
+
return if exempted?(node)
|
|
119
|
+
|
|
120
|
+
add_offense(node.loc.selector, message: offense_message(node))
|
|
121
|
+
end
|
|
122
|
+
alias on_csend on_send
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def exempted?(node)
|
|
127
|
+
non_enumerable_keys_only?(node) || adjacent_justification?(node)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# True when the WHOLE argument list is readable keyword pairs and
|
|
131
|
+
# every params-derived value is keyed by a configured non-enumerable
|
|
132
|
+
# key (`find_by!(uuid: params[:id])`). Anything whose key can't be
|
|
133
|
+
# read — a positional argument, a `**` splat, a dynamic key — keeps
|
|
134
|
+
# the offense even when it doesn't itself touch params: the skip must
|
|
135
|
+
# see the entire lookup to vouch for it.
|
|
136
|
+
def non_enumerable_keys_only?(node)
|
|
137
|
+
return false if non_enumerable_keys.empty?
|
|
138
|
+
|
|
139
|
+
node.arguments.all? do |argument|
|
|
140
|
+
argument.hash_type? && argument.children.all? { |child| non_enumerable_pair?(child) }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def non_enumerable_pair?(child)
|
|
145
|
+
return false unless child.pair_type? # kwsplat — the key is invisible
|
|
146
|
+
return false if references_params?(child.key) # dynamic key
|
|
147
|
+
|
|
148
|
+
# A hash value (`uuid: { id: params[:id] }`) is an association
|
|
149
|
+
# path, not a keyed lookup — `uuid` would be the table, `id` the
|
|
150
|
+
# enumerable column.
|
|
151
|
+
!references_params?(child.value) ||
|
|
152
|
+
(non_enumerable_key?(child.key) && !child.value.hash_type?)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def non_enumerable_key?(key)
|
|
156
|
+
key.sym_type? && non_enumerable_keys.include?(key.value)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def non_enumerable_keys
|
|
160
|
+
@non_enumerable_keys ||=
|
|
161
|
+
Array(cop_config.fetch('NonEnumerableKeys', %w[uuid token secret_key])).map(&:to_sym)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def offense_message(node)
|
|
165
|
+
format(
|
|
166
|
+
MSG,
|
|
167
|
+
receiver: node.receiver.const_name,
|
|
168
|
+
method: node.method_name,
|
|
169
|
+
marker: justification_marker
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def references_params?(node)
|
|
174
|
+
return true if bare_params?(node)
|
|
175
|
+
|
|
176
|
+
node.each_descendant(:send).any? { |descendant| bare_params?(descendant) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def bare_params?(node)
|
|
180
|
+
node.send_type? && node.method_name == :params && node.receiver.nil?
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def finder_methods
|
|
184
|
+
@finder_methods ||= Array(cop_config.fetch('FinderMethods', %w[find find_by find_by!])).map(&:to_sym)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def justification_marker
|
|
188
|
+
cop_config.fetch('JustificationMarker', 'unscoped-find:')
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -2,18 +2,32 @@ module RuboCop
|
|
|
2
2
|
module Cop
|
|
3
3
|
module DevDoc
|
|
4
4
|
module Rails
|
|
5
|
-
# Avoid `perform_later` calls inside model files.
|
|
5
|
+
# Avoid `perform_later`/`deliver_later` calls inside model files.
|
|
6
6
|
#
|
|
7
7
|
# ## Rationale
|
|
8
|
-
#
|
|
8
|
+
# Enqueuing a job or mailer is execution-flow control — deciding *when*
|
|
9
|
+
# a side effect fires — and its home is the calling controller or job
|
|
10
|
+
# entry point (see the orchestration best-practice doc), because:
|
|
9
11
|
#
|
|
10
12
|
# - It is prone to conflicts between `transaction` and `perform_later`
|
|
11
13
|
# (the job may run before the transaction commits and read stale data
|
|
12
14
|
# — see `DevDoc/Rails/NoDeliverLaterInTransaction`).
|
|
13
|
-
# -
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
15
|
+
# - Flow control belongs to the Controller, not the Model. This governs
|
|
16
|
+
# the *trigger* only: the mail itself stays composed in the Mailer
|
|
17
|
+
# (parameters, templates — see the controller best-practice doc); the
|
|
18
|
+
# controller merely calls `deliver_later`. Reuse across controllers
|
|
19
|
+
# goes in a controller concern — DRY alone never justifies pushing a
|
|
20
|
+
# trigger into a model.
|
|
21
|
+
# - The exception: an enqueue inseparable from a record state change
|
|
22
|
+
# (e.g. a throttle stamp and its send that must never be split across
|
|
23
|
+
# a boundary) stays in the model behind an explicitly named
|
|
24
|
+
# `notify_*`/`send_*` method, so the side effect is obvious at the
|
|
25
|
+
# call site.
|
|
26
|
+
#
|
|
27
|
+
# The boundary test: is the enqueue bound to a state change on this
|
|
28
|
+
# record, or is ordering already enforced by data flow (the caller can
|
|
29
|
+
# only enqueue with values that exist after the domain call returns)?
|
|
30
|
+
# The latter is flow control — trigger it from the controller.
|
|
17
31
|
#
|
|
18
32
|
# ❌ (in app/models/order.rb)
|
|
19
33
|
# def finalize
|
|
@@ -21,18 +35,20 @@ module RuboCop
|
|
|
21
35
|
# OrderJob.perform_later(self)
|
|
22
36
|
# end
|
|
23
37
|
#
|
|
24
|
-
# ✔️
|
|
25
|
-
#
|
|
38
|
+
# ✔️ State-coupled enqueue behind an explicit `notify_*` name; the
|
|
39
|
+
# side effect is obvious at the call site, and reviewers
|
|
40
|
+
# immediately see that this must not be called inside a
|
|
26
41
|
# `transaction` block.
|
|
27
|
-
# def
|
|
28
|
-
#
|
|
42
|
+
# def notify_finalized
|
|
43
|
+
# update!(finalize_notified_at: Time.current)
|
|
29
44
|
# OrderJob.perform_later(self)
|
|
30
45
|
# end
|
|
31
46
|
#
|
|
32
47
|
# NOTE: This cop flags *any* `perform_later` in `app/models/**/*.rb`,
|
|
33
48
|
# including the legitimate "explicitly named method" case above. The
|
|
34
49
|
# cop is intentionally conservative — disable per-line with a rubocop
|
|
35
|
-
# comment when you have deliberately followed
|
|
50
|
+
# comment stating the justification when you have deliberately followed
|
|
51
|
+
# the naming convention.
|
|
36
52
|
#
|
|
37
53
|
# @example
|
|
38
54
|
# # bad (in app/models/order.rb)
|
|
@@ -41,9 +57,9 @@ module RuboCop
|
|
|
41
57
|
# OrderJob.perform_later(self)
|
|
42
58
|
# end
|
|
43
59
|
#
|
|
44
|
-
# # good —
|
|
45
|
-
# def
|
|
46
|
-
#
|
|
60
|
+
# # good — state-coupled enqueue behind an explicitly named method
|
|
61
|
+
# def notify_finalized
|
|
62
|
+
# update!(finalize_notified_at: Time.current)
|
|
47
63
|
# OrderJob.perform_later(self)
|
|
48
64
|
# end
|
|
49
65
|
class NoPerformLaterInModel < Base
|