rubocop-dev_doc 0.10.4 → 0.11.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 +45 -0
- data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +81 -58
- data/lib/rubocop/cop/dev_doc/auth/current_user_branching_helpers.rb +176 -0
- data/lib/rubocop/cop/dev_doc/auth/role_predicate_outside_policy.rb +102 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 83da1f7ff50b778721408a7f3713a0708a4749bf0af5e5df9e96810aa7f2be1c
|
|
4
|
+
data.tar.gz: 6b69c63a8bd53bd6a012acaa46ae7134581dbf2760f5e7627948dcd8b137558f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8b79ae456b8c5518023e9b89ba0232dfeb9fc64cb9a53d2e3e0ac18854a2d8b6f96b02e5a2ff3fd0928c19a32e9f5ae5204e3a5c3aa24af566dea1d8c7b24186
|
|
7
|
+
data.tar.gz: 7ffae58fc78c00ca6abdfab21e3c4517528183c3130e765b2aac198a44b91c2e8b2c9969fc9c3e7bd7c42243d78406f659ea4be94575df28fde2753bb4f22dfa
|
data/config/default.yml
CHANGED
|
@@ -640,15 +640,60 @@ DevDoc/Auth/LoadResourceCurrentUserGuard:
|
|
|
640
640
|
CurrentUserAssertionMethodNames:
|
|
641
641
|
- assert_current_user_present
|
|
642
642
|
|
|
643
|
+
# AllowedMethods classifies the FIRST method called on current_user (or a
|
|
644
|
+
# same-file alias — `@user = current_user` is taint-tracked) in a condition:
|
|
645
|
+
# listed = data read, passes; unlisted = role predicate or unclassified,
|
|
646
|
+
# flags. A closed set on purpose — each addition is a reviewed "data or
|
|
647
|
+
# permission?" decision. The universally-generic baseline (errors, id, email,
|
|
648
|
+
# persisted?, new_record?) is built into the cop
|
|
649
|
+
# (DEFAULT_ALLOWED_METHODS) and CANNOT be dropped by configuration — the
|
|
650
|
+
# list below is purely ADDITIVE, so projects never repeat the baseline (a
|
|
651
|
+
# project AllowedMethods would otherwise REPLACE it: RuboCop does not merge
|
|
652
|
+
# array config). Add your app's own data reads (MFA flags, verification
|
|
653
|
+
# progress, ...) in .rubocop.yml.
|
|
643
654
|
DevDoc/Auth/CurrentUserBranching:
|
|
644
655
|
Description: "Avoid branching on `current_user` / `user_signed_in?` in page-specific code. Use shared layouts or an inline disable with a reason for genuinely dual-state pages."
|
|
645
656
|
Enabled: true
|
|
657
|
+
AllowedMethods: []
|
|
646
658
|
Exclude:
|
|
647
659
|
- "app/policies/**/*.rb"
|
|
648
660
|
- "app/helpers/**/*.rb"
|
|
649
661
|
- "app/controllers/concerns/**/*.rb"
|
|
650
662
|
- "app/views/layouts/**/*"
|
|
651
663
|
|
|
664
|
+
# The receiver-agnostic companion to CurrentUserBranching: taint can't follow
|
|
665
|
+
# an ivar from controller to view, and wrapper methods break the chain — but
|
|
666
|
+
# the role-predicate NAME is visible on any receiver (`@user.admin?`,
|
|
667
|
+
# `viewer.admin?`). Populate RolePredicates with every role predicate your
|
|
668
|
+
# User/Member models define.
|
|
669
|
+
#
|
|
670
|
+
# The two cops COMPOSE as a pair of nets: CurrentUserBranching is default-deny
|
|
671
|
+
# at the auth root (a brand-new role predicate called on current_user flags as
|
|
672
|
+
# unclassified with zero config), while this cop makes the KNOWN predicates
|
|
673
|
+
# undodgeable on any receiver. The escape requires a new name AND a laundered
|
|
674
|
+
# receiver simultaneously — and each name triaged into RolePredicates closes
|
|
675
|
+
# that hole permanently (the ratchet). RATCHET, step two: once page code
|
|
676
|
+
# queries policies by purpose (`policy(x).auto_approve?`), add any generic
|
|
677
|
+
# role getters your policies export (`user_is_admin?`, `user_is_super_admin?`)
|
|
678
|
+
# to RolePredicates too — policies are excluded paths, so policy-internal use
|
|
679
|
+
# stays free while new loose call sites in page code are blocked mechanically.
|
|
680
|
+
DevDoc/Auth/RolePredicateOutsidePolicy:
|
|
681
|
+
Description: "Role predicates (`admin?`, ...) answer authorization questions and belong in Pundit policies; query via `policy(record).x?` instead of calling them in page code."
|
|
682
|
+
Enabled: true
|
|
683
|
+
RolePredicates:
|
|
684
|
+
- admin?
|
|
685
|
+
- super_admin?
|
|
686
|
+
Exclude:
|
|
687
|
+
- "app/policies/**/*.rb"
|
|
688
|
+
- "app/models/**/*.rb"
|
|
689
|
+
- "app/helpers/**/*.rb"
|
|
690
|
+
- "app/views/layouts/**/*"
|
|
691
|
+
# Tests assert role facts as fixture preconditions (`assert_not
|
|
692
|
+
# stranger.effective_admin_of_organization?(org)`) — they verify the
|
|
693
|
+
# permission model rather than fragment it.
|
|
694
|
+
- "test/**/*"
|
|
695
|
+
- "spec/**/*"
|
|
696
|
+
|
|
652
697
|
DevDoc/I18n/AvoidTitleizeHumanize:
|
|
653
698
|
Description: "Avoid `.titleize`/`.humanize` for display text; it's English-only and bypasses I18n. Use `t(...)`."
|
|
654
699
|
# A review aid, not a clean lint: it can't tell a display string from one
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative "current_user_branching_helpers"
|
|
2
|
+
|
|
1
3
|
module RuboCop
|
|
2
4
|
module Cop
|
|
3
5
|
module DevDoc
|
|
@@ -43,16 +45,63 @@ module RuboCop
|
|
|
43
45
|
# ## Patterns flagged
|
|
44
46
|
# - `if current_user` / `unless current_user` (block or modifier) where
|
|
45
47
|
# the body is non-empty and is not a bare `return` (bare returns are
|
|
46
|
-
# the nil-guard pattern covered by LoadResourceCurrentUserGuard
|
|
48
|
+
# the nil-guard pattern covered by LoadResourceCurrentUserGuard; the
|
|
49
|
+
# exemption covers every nil-test spelling of the guard —
|
|
50
|
+
# `return if current_user.nil?`, `return unless
|
|
51
|
+
# current_user.present?`, `return if !current_user`).
|
|
47
52
|
# - `if user_signed_in?` / `unless user_signed_in?` (any form).
|
|
48
|
-
# -
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
# `current_user
|
|
53
|
-
#
|
|
53
|
+
# - The evasion spellings — all equivalent to the bare form and all
|
|
54
|
+
# measured in the wild once the bare form was policed:
|
|
55
|
+
# `current_user.present?` / `.blank?` / `.nil?`, `!current_user`,
|
|
56
|
+
# `!user_signed_in?`, and boolean combinations
|
|
57
|
+
# (`current_user && x`, `user_signed_in? || y`).
|
|
58
|
+
# - ANY method chain rooted at `current_user` used as a condition —
|
|
59
|
+
# both `current_user&.admin?` and plain `current_user.admin?`. The
|
|
60
|
+
# `&.` is a confession that the dev expects nil; the plain chain is
|
|
61
|
+
# a role/ownership check that belongs in the policy (see below).
|
|
54
62
|
# - Ternaries: `current_user ? a : b`, `user_signed_in? ? a : b`.
|
|
55
63
|
# - Hash/argument values: `authenticated: user_signed_in?`.
|
|
64
|
+
# - ALIASES: a local/instance variable assigned from `current_user`
|
|
65
|
+
# in the same file inherits every rule above — `@user =
|
|
66
|
+
# current_user` followed by `if @user.admin?` is the same branch
|
|
67
|
+
# laundered, and it flags identically. Only PURE aliases are
|
|
68
|
+
# tracked: every assignment to the name must be an unconditional
|
|
69
|
+
# `= current_user` (case dispatch counts as unconditional). A
|
|
70
|
+
# conditional assignment or a second source makes the variable a
|
|
71
|
+
# genuinely nilable/general resource holder — branching on it is
|
|
72
|
+
# resource-presence logic, not auth state. Taint is per-file only:
|
|
73
|
+
# an ivar assigned in the controller and read in a view is not
|
|
74
|
+
# tracked — pair this cop with the receiver-agnostic
|
|
75
|
+
# `DevDoc/Auth/RolePredicateOutsidePolicy`, which closes that gap
|
|
76
|
+
# for the known role predicates.
|
|
77
|
+
#
|
|
78
|
+
# ## AllowedMethods — classifying data reads
|
|
79
|
+
# The first method called on `current_user` (or a tainted alias) in a
|
|
80
|
+
# condition is CLASSIFIED: entries in `AllowedMethods` are data reads
|
|
81
|
+
# (`current_user.errors.any?`, an MFA-configured flag) and pass;
|
|
82
|
+
# everything else — role predicates and methods nobody has classified
|
|
83
|
+
# yet — flags. The list is a closed set on purpose: every new reader
|
|
84
|
+
# used in a condition forces a reviewed "data or permission?" decision
|
|
85
|
+
# in `.rubocop.yml` rather than slipping through. The
|
|
86
|
+
# universally-generic baseline (errors, id, email, persisted?,
|
|
87
|
+
# new_record? — `DEFAULT_ALLOWED_METHODS`) is built into the cop and
|
|
88
|
+
# cannot be un-configured; the yml list is purely ADDITIVE, so
|
|
89
|
+
# projects list only their own readers:
|
|
90
|
+
#
|
|
91
|
+
# DevDoc/Auth/CurrentUserBranching:
|
|
92
|
+
# AllowedMethods:
|
|
93
|
+
# - otp_required_for_login
|
|
94
|
+
#
|
|
95
|
+
# ## Moving a decision to the policy — query by PURPOSE
|
|
96
|
+
# The policy move is only complete when the policy owns the
|
|
97
|
+
# role->behavior rule, not just the role's spelling. Export a
|
|
98
|
+
# decision-shaped query (`auto_approve?`, `initiation_blocked_for_admin?`,
|
|
99
|
+
# or an `authorize` block) and call THAT from page code. A generic role
|
|
100
|
+
# getter (`def user_is_admin? = current_user&.admin?`) re-opens the
|
|
101
|
+
# scattering: every call site invents its own rule again, one policy
|
|
102
|
+
# door down. If your policies must expose such getters as internal
|
|
103
|
+
# building blocks, list their names in the companion cop's
|
|
104
|
+
# RolePredicates so page code cannot call them.
|
|
56
105
|
#
|
|
57
106
|
# ## Allowed paths (Exclude:)
|
|
58
107
|
# By default the cop is silent in:
|
|
@@ -139,9 +188,21 @@ module RuboCop
|
|
|
139
188
|
# ...
|
|
140
189
|
# end
|
|
141
190
|
class CurrentUserBranching < Base
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
191
|
+
include CurrentUserBranchingHelpers
|
|
192
|
+
|
|
193
|
+
MSG = 'Branching on auth state in page code — decide what this branch really is: ' \
|
|
194
|
+
'auth is required here -> the anonymous branch is dead code, delete it; ' \
|
|
195
|
+
'a role/ownership check -> move the DECISION into the Pundit policy and ' \
|
|
196
|
+
'query it by purpose (`policy(record).auto_approve?`, `can?(:update, x)`) ' \
|
|
197
|
+
'— do NOT export a generic role getter (`user_is_admin?`) from the policy: ' \
|
|
198
|
+
'that centralizes only the spelling while every call site keeps its own ' \
|
|
199
|
+
'role->behavior rule; a plain data read (not permission-related) -> ' \
|
|
200
|
+
'classify the method in AllowedMethods; genuinely dual-state -> move the ' \
|
|
201
|
+
'branching to a layout/helper (excluded paths — PRESENTATION branching ' \
|
|
202
|
+
'only, keyed on entity-scoped facts, never smuggled permission logic) or ' \
|
|
203
|
+
'split anonymous/signed-in partials. Aliasing (`user = current_user`) ' \
|
|
204
|
+
'does not exempt any of this. Only a small, local branch on a genuinely ' \
|
|
205
|
+
'dual-state PAGE warrants an inline disable with a reason.'.freeze
|
|
145
206
|
|
|
146
207
|
AUTH_METHODS = %i[current_user user_signed_in?].freeze
|
|
147
208
|
|
|
@@ -152,9 +213,17 @@ module RuboCop
|
|
|
152
213
|
add_offense(if_offense_location(node))
|
|
153
214
|
end
|
|
154
215
|
|
|
155
|
-
# `value: user_signed_in?` —
|
|
216
|
+
# `value: user_signed_in?` — the BOOLEAN auth state passed as data,
|
|
217
|
+
# which smuggles the branch to the consumer/client. Deliberately
|
|
218
|
+
# limited to `user_signed_in?`: bare `current_user` passed as data
|
|
219
|
+
# (`requested_by: current_user`, `model: current_user`,
|
|
220
|
+
# `x.user == current_user`) is actorship recording or identity
|
|
221
|
+
# comparison, not branching — flagging it measured 44 noise
|
|
222
|
+
# offenses / 0 finds on a mature codebase.
|
|
223
|
+
VALUE_FLAGGED_METHODS = %i[user_signed_in?].freeze
|
|
224
|
+
|
|
156
225
|
def on_send(node)
|
|
157
|
-
return unless
|
|
226
|
+
return unless VALUE_FLAGGED_METHODS.include?(node.method_name) && node.receiver.nil?
|
|
158
227
|
return unless used_as_value?(node)
|
|
159
228
|
|
|
160
229
|
add_offense(node.loc.selector)
|
|
@@ -166,46 +235,6 @@ module RuboCop
|
|
|
166
235
|
node.if_type? && auth_condition?(node.condition)
|
|
167
236
|
end
|
|
168
237
|
|
|
169
|
-
def auth_condition?(condition)
|
|
170
|
-
return false unless condition
|
|
171
|
-
|
|
172
|
-
if condition.send_type?
|
|
173
|
-
AUTH_METHODS.include?(condition.method_name) && condition.receiver.nil?
|
|
174
|
-
elsif condition.csend_type?
|
|
175
|
-
# `current_user&.foo` — the safe-nav is itself the auth check;
|
|
176
|
-
# `bare_current_user_condition?` won't match (it requires send),
|
|
177
|
-
# so guard-return forms like `return unless current_user&.admin?`
|
|
178
|
-
# correctly stay flagged.
|
|
179
|
-
receiver = condition.receiver
|
|
180
|
-
receiver&.send_type? &&
|
|
181
|
-
receiver.method_name == :current_user &&
|
|
182
|
-
receiver.receiver.nil?
|
|
183
|
-
else
|
|
184
|
-
false
|
|
185
|
-
end
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
# `return unless current_user` (and similar bare guards) should not
|
|
189
|
-
# be flagged — they are the correct pattern handled by
|
|
190
|
-
# LoadResourceCurrentUserGuard. Detect: an if whose condition is bare
|
|
191
|
-
# `current_user` and one branch is a bare `return` (no arguments).
|
|
192
|
-
def bare_return_guard?(node)
|
|
193
|
-
condition = node.condition
|
|
194
|
-
return false unless bare_current_user_condition?(condition)
|
|
195
|
-
|
|
196
|
-
bare_return_branch?(node.else_branch) || bare_return_branch?(node.if_branch)
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
def bare_current_user_condition?(condition)
|
|
200
|
-
condition&.send_type? &&
|
|
201
|
-
condition.method_name == :current_user &&
|
|
202
|
-
condition.receiver.nil?
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
def bare_return_branch?(branch)
|
|
206
|
-
branch&.return_type? && branch.children.empty?
|
|
207
|
-
end
|
|
208
|
-
|
|
209
238
|
# `if`/`unless` keyword forms have `loc.keyword`; ternary `?:` nodes
|
|
210
239
|
# have `loc.question` instead. Fall back to the condition source range.
|
|
211
240
|
def if_offense_location(node)
|
|
@@ -219,12 +248,6 @@ module RuboCop
|
|
|
219
248
|
end
|
|
220
249
|
end
|
|
221
250
|
|
|
222
|
-
# Is this send node an auth method call (`current_user`, `user_signed_in?`)
|
|
223
|
-
# on no receiver?
|
|
224
|
-
def auth_method_call?(node)
|
|
225
|
-
AUTH_METHODS.include?(node.method_name) && node.receiver.nil?
|
|
226
|
-
end
|
|
227
|
-
|
|
228
251
|
# Is the send node used as a value (argument to another send, pair
|
|
229
252
|
# value in a hash, etc.) rather than already caught as a branch condition?
|
|
230
253
|
def used_as_value?(node)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Auth
|
|
5
|
+
# Condition analysis for CurrentUserBranching, extracted so the cop
|
|
6
|
+
# class stays within size limits. Everything here answers one
|
|
7
|
+
# question: is this AST node auth-state/authorization branching?
|
|
8
|
+
#
|
|
9
|
+
# Three mechanisms cooperate (see the cop docstring for doctrine):
|
|
10
|
+
# - AUTH ROOTS: bare `current_user` / `user_signed_in?` calls, PLUS
|
|
11
|
+
# any local/instance variable assigned from `current_user` in the
|
|
12
|
+
# same file (taint tracking — `@user = current_user` must not be a
|
|
13
|
+
# way to launder the branch). Taint is per-file: an ivar assigned in
|
|
14
|
+
# a controller and read in a view is NOT tracked — the companion
|
|
15
|
+
# role-predicate cop covers that gap receiver-agnostically.
|
|
16
|
+
# - ALLOWED METHODS: the first method called on an auth root is
|
|
17
|
+
# classified. Data reads (`errors`, project-configured readers like
|
|
18
|
+
# `otp_required_for_login`) pass; everything else — role predicates,
|
|
19
|
+
# unknown methods — flags until someone classifies it in
|
|
20
|
+
# `AllowedMethods` (a deliberate, reviewed decision).
|
|
21
|
+
# - GUARD EXEMPTION: bare-return nil guards in every spelling stay
|
|
22
|
+
# allowed (the correct pre-auth pattern).
|
|
23
|
+
module CurrentUserBranchingHelpers
|
|
24
|
+
# Universal non-permission data reads, allowed WITHOUT configuration.
|
|
25
|
+
# Lives in code (not default.yml) so a project setting its own
|
|
26
|
+
# AllowedMethods cannot accidentally drop the baseline — RuboCop
|
|
27
|
+
# REPLACES array config wholesale, it does not merge. The yml key is
|
|
28
|
+
# therefore purely additive.
|
|
29
|
+
DEFAULT_ALLOWED_METHODS = %i[errors id email persisted? new_record?].freeze
|
|
30
|
+
|
|
31
|
+
# Nil-test spellings that keep the guard-return exemption; role/data
|
|
32
|
+
# chains never qualify.
|
|
33
|
+
NIL_TEST_METHODS = %i[present? blank? nil? !].freeze
|
|
34
|
+
|
|
35
|
+
# Identity comparisons are display logic, not access decisions.
|
|
36
|
+
IDENTITY_COMPARISON_METHODS = %i[== !=].freeze
|
|
37
|
+
|
|
38
|
+
def on_new_investigation
|
|
39
|
+
super
|
|
40
|
+
@tainted_names = collect_tainted_names(processed_source.ast)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Names that are PURE aliases of current_user: every assignment to
|
|
46
|
+
# the name, file-wide, is an unconditional `= current_user`. An
|
|
47
|
+
# assignment under an `if` is semantic narrowing (`@user =
|
|
48
|
+
# current_user` only when a token matches — the variable is
|
|
49
|
+
# genuinely nilable, and branching on it is resource-presence
|
|
50
|
+
# logic), and an assignment from any other source makes it a
|
|
51
|
+
# general holder; either disqualifies the name. `case` dispatch
|
|
52
|
+
# (glib_load_resource's per-action assignment) does NOT disqualify —
|
|
53
|
+
# per-action aliasing is still pure aliasing.
|
|
54
|
+
def collect_tainted_names(ast)
|
|
55
|
+
names = Set.new
|
|
56
|
+
disqualified = Set.new
|
|
57
|
+
ast&.each_node(:lvasgn, :ivasgn) do |assign|
|
|
58
|
+
target = pure_alias_assignment?(assign) ? names : disqualified
|
|
59
|
+
target << assign.children[0]
|
|
60
|
+
end
|
|
61
|
+
names - disqualified
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def pure_alias_assignment?(assign)
|
|
65
|
+
value = assign.children[1]
|
|
66
|
+
return false unless value&.send_type? && value.method_name == :current_user && value.receiver.nil?
|
|
67
|
+
|
|
68
|
+
assign.each_ancestor(:if).none?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def tainted_ref?(node)
|
|
72
|
+
node&.type?(:lvar, :ivar) && @tainted_names.include?(node.children.first)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def auth_condition?(condition)
|
|
76
|
+
return false unless condition
|
|
77
|
+
return tainted_ref?(condition) if condition.type?(:lvar, :ivar)
|
|
78
|
+
return auth_condition?(condition.children.last) if condition.begin_type?
|
|
79
|
+
return condition.children.any? { |operand| auth_condition?(operand) } if condition.type?(:and, :or)
|
|
80
|
+
|
|
81
|
+
condition.type?(:send, :csend) && auth_send_condition?(condition)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def auth_send_condition?(node)
|
|
85
|
+
return true if bare_auth_call?(node)
|
|
86
|
+
# Identity comparisons are exempt in BOTH directions
|
|
87
|
+
# (`x.user == current_user` and `current_user.id == x`), so test
|
|
88
|
+
# before the chain-root rule.
|
|
89
|
+
return false if IDENTITY_COMPARISON_METHODS.include?(node.method_name)
|
|
90
|
+
# `!current_user` / `!user_signed_in?` (or a negated chain)
|
|
91
|
+
return auth_send_condition?(node.receiver) if node.method_name == :! && node.receiver&.type?(:send, :csend)
|
|
92
|
+
return flagged_chain_off_auth_root?(node) if chain_first_hop(node)
|
|
93
|
+
|
|
94
|
+
current_user_as_access_argument?(node)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The first method called on an auth root is the classified one:
|
|
98
|
+
# `current_user.oauth_tokens.any?` classifies `oauth_tokens` (the
|
|
99
|
+
# rest operates on the returned data). Allowed data reads pass;
|
|
100
|
+
# role predicates and unknown methods flag.
|
|
101
|
+
def flagged_chain_off_auth_root?(node)
|
|
102
|
+
!allowed_method?(chain_first_hop(node).method_name)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The send whose receiver IS an auth root (bare current_user or a
|
|
106
|
+
# tainted alias) — nil when the chain isn't rooted at auth state.
|
|
107
|
+
# `user_signed_in?` accepts no chain (it returns a bool).
|
|
108
|
+
def chain_first_hop(node)
|
|
109
|
+
current = node
|
|
110
|
+
while current.respond_to?(:receiver) && current.receiver
|
|
111
|
+
return current if auth_root?(current.receiver)
|
|
112
|
+
|
|
113
|
+
current = current.receiver
|
|
114
|
+
end
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def auth_root?(node)
|
|
119
|
+
(node.send_type? && node.method_name == :current_user && node.receiver.nil?) || tainted_ref?(node)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def bare_auth_call?(node)
|
|
123
|
+
node.send_type? && self.class::AUTH_METHODS.include?(node.method_name) && node.receiver.nil?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def allowed_method?(method_name)
|
|
127
|
+
DEFAULT_ALLOWED_METHODS.include?(method_name) ||
|
|
128
|
+
(cop_config['AllowedMethods'] || []).map(&:to_sym).include?(method_name)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# `admins.include?(current_user)` / `accessible_by?(current_user)`
|
|
132
|
+
# in a condition — an access decision parameterised by the current
|
|
133
|
+
# user (or a tainted alias); belongs in the policy.
|
|
134
|
+
def current_user_as_access_argument?(node)
|
|
135
|
+
return false if IDENTITY_COMPARISON_METHODS.include?(node.method_name)
|
|
136
|
+
|
|
137
|
+
node.arguments.any? do |arg|
|
|
138
|
+
(arg.send_type? && arg.method_name == :current_user && arg.receiver.nil?) || tainted_ref?(arg)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# `return unless current_user` (and every nil-test SPELLING of it,
|
|
143
|
+
# on current_user or a tainted alias) is the correct guard pattern —
|
|
144
|
+
# the cop polices branching, not guard orthography. Role/data chains
|
|
145
|
+
# (`return unless current_user.admin?`) do NOT qualify.
|
|
146
|
+
def bare_return_guard?(node)
|
|
147
|
+
return false unless pure_auth_state_test?(node.condition)
|
|
148
|
+
|
|
149
|
+
bare_return_branch?(node.else_branch) || bare_return_branch?(node.if_branch)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def pure_auth_state_test?(condition)
|
|
153
|
+
return false if condition.nil?
|
|
154
|
+
return true if tainted_ref?(condition)
|
|
155
|
+
return false unless condition.type?(:send, :csend)
|
|
156
|
+
return true if bare_current_user?(condition)
|
|
157
|
+
|
|
158
|
+
nil_test_of_auth_state?(condition)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def nil_test_of_auth_state?(node)
|
|
162
|
+
NIL_TEST_METHODS.include?(node.method_name) && node.receiver && pure_auth_state_test?(node.receiver)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def bare_current_user?(node)
|
|
166
|
+
node.method_name == :current_user && node.receiver.nil?
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def bare_return_branch?(branch)
|
|
170
|
+
branch&.return_type? && branch.children.empty?
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Auth
|
|
5
|
+
# Role predicates must not be called outside the policy layer.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# A role predicate (`admin?`, `super_admin?`,
|
|
9
|
+
# `effective_admin_of_organization?`, ...) answers "what may this
|
|
10
|
+
# person do?" — an authorization question. Scattering those calls
|
|
11
|
+
# through controllers and views fragments the permission model: the
|
|
12
|
+
# Pundit policy says one thing, page code quietly decides another.
|
|
13
|
+
#
|
|
14
|
+
# This cop is the receiver-agnostic companion to
|
|
15
|
+
# `DevDoc/Auth/CurrentUserBranching`: that cop tracks `current_user`
|
|
16
|
+
# and its same-file aliases, but taint cannot follow an ivar from a
|
|
17
|
+
# controller into a view, and any wrapper method breaks the chain.
|
|
18
|
+
# Keying on the PREDICATE NAME instead means `current_user.admin?`,
|
|
19
|
+
# `@user.admin?`, `user.admin?`, and `viewer.admin?` are all equally
|
|
20
|
+
# visible — no alias or wrapper hides the question being asked.
|
|
21
|
+
#
|
|
22
|
+
# ## Remedy
|
|
23
|
+
# Move the decision into the Pundit policy and query it where needed:
|
|
24
|
+
# `policy(record).manage?` — no role predicate at the call site.
|
|
25
|
+
# Name the policy query after the DECISION it guards (`auto_approve?`,
|
|
26
|
+
# `initiation_blocked_for_admin?`), never after the role: a generic
|
|
27
|
+
# getter (`user_is_admin?`) re-exports the scattering through the
|
|
28
|
+
# policy door — every call site still maps role to behavior itself.
|
|
29
|
+
# Genuine display logic keyed on a role (badges, admin-only hints)
|
|
30
|
+
# belongs in a helper (an excluded path), where the branching is
|
|
31
|
+
# openly presentation-side.
|
|
32
|
+
#
|
|
33
|
+
# ## The ratchet
|
|
34
|
+
# Once page code queries policies by purpose, add any role-shaped
|
|
35
|
+
# getters your policies DO export (`user_is_super_admin?`, ...) to
|
|
36
|
+
# RolePredicates. Policies are excluded paths, so policy-internal use
|
|
37
|
+
# stays free while new loose call sites in page code flag mechanically.
|
|
38
|
+
#
|
|
39
|
+
# ## Configuration
|
|
40
|
+
# `RolePredicates` is a closed, per-project list — add every role
|
|
41
|
+
# predicate your User/Member models define:
|
|
42
|
+
#
|
|
43
|
+
# DevDoc/Auth/RolePredicateOutsidePolicy:
|
|
44
|
+
# RolePredicates:
|
|
45
|
+
# - admin?
|
|
46
|
+
# - super_admin?
|
|
47
|
+
# - effective_admin_of_organization?
|
|
48
|
+
# - explicit_admin_of_organization?
|
|
49
|
+
#
|
|
50
|
+
# ## Allowed paths (Exclude:)
|
|
51
|
+
# By default the cop is silent in:
|
|
52
|
+
# app/policies/**/*.rb ← where the predicates belong
|
|
53
|
+
# app/models/**/*.rb ← where they are defined/composed
|
|
54
|
+
# app/helpers/**/*.rb ← sanctioned display branching
|
|
55
|
+
# app/views/layouts/**/* ← sanctioned display branching
|
|
56
|
+
# test/, spec/ ← tests assert role facts as fixture
|
|
57
|
+
# preconditions; they verify the permission
|
|
58
|
+
# model rather than fragment it
|
|
59
|
+
#
|
|
60
|
+
# @example
|
|
61
|
+
# # bad — controller decides a permission by role
|
|
62
|
+
# if current_user.admin?
|
|
63
|
+
# auto_approve
|
|
64
|
+
# end
|
|
65
|
+
#
|
|
66
|
+
# # bad — the alias dodge is equally visible
|
|
67
|
+
# if @user.admin?
|
|
68
|
+
# auto_approve
|
|
69
|
+
# end
|
|
70
|
+
#
|
|
71
|
+
# # good — the policy owns the decision; page code queries it
|
|
72
|
+
# if policy(@organization).auto_approve?
|
|
73
|
+
# auto_approve
|
|
74
|
+
# end
|
|
75
|
+
class RolePredicateOutsidePolicy < Base
|
|
76
|
+
MSG = '`%<method>s` is a role predicate — an authorization question that belongs in ' \
|
|
77
|
+
'the Pundit policy. Move the DECISION there and query it by purpose ' \
|
|
78
|
+
'(`policy(record).auto_approve?`) — name the query after the decision it ' \
|
|
79
|
+
'guards, never after the role (`user_is_admin?` just re-exports the ' \
|
|
80
|
+
'scattering through the policy door, and belongs in this cop\'s ' \
|
|
81
|
+
'RolePredicates list). For pure display branching (badges, admin hints), ' \
|
|
82
|
+
'use a helper. Renaming the receiver never exempts it: the flagged thing ' \
|
|
83
|
+
'is the question, not who is asked.'.freeze
|
|
84
|
+
|
|
85
|
+
def on_send(node)
|
|
86
|
+
method_name = node.method_name
|
|
87
|
+
return unless role_predicates.include?(method_name)
|
|
88
|
+
|
|
89
|
+
add_offense(node.loc.selector, message: format(MSG, method: method_name))
|
|
90
|
+
end
|
|
91
|
+
alias on_csend on_send
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def role_predicates
|
|
96
|
+
@role_predicates ||= (cop_config['RolePredicates'] || []).map(&:to_sym)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubocop-dev_doc
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- dev-doc contributors
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -97,7 +97,9 @@ files:
|
|
|
97
97
|
- lib/dev_doc/test/pseudo_i18n_crawler.rb
|
|
98
98
|
- lib/rubocop-dev_doc.rb
|
|
99
99
|
- lib/rubocop/cop/dev_doc/auth/current_user_branching.rb
|
|
100
|
+
- lib/rubocop/cop/dev_doc/auth/current_user_branching_helpers.rb
|
|
100
101
|
- lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb
|
|
102
|
+
- lib/rubocop/cop/dev_doc/auth/role_predicate_outside_policy.rb
|
|
101
103
|
- lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb
|
|
102
104
|
- lib/rubocop/cop/dev_doc/i18n/localizable_props.rb
|
|
103
105
|
- lib/rubocop/cop/dev_doc/i18n/report_text.rb
|