@reinteractive/rails-insight 1.0.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.
- package/LICENSE +15 -0
- package/README.md +210 -0
- package/bin/railsinsight.js +128 -0
- package/package.json +62 -0
- package/src/core/blast-radius.js +496 -0
- package/src/core/constants.js +39 -0
- package/src/core/context-loader.js +227 -0
- package/src/core/drift-detector.js +168 -0
- package/src/core/formatter.js +197 -0
- package/src/core/graph.js +510 -0
- package/src/core/indexer.js +595 -0
- package/src/core/patterns/api.js +27 -0
- package/src/core/patterns/auth.js +25 -0
- package/src/core/patterns/authorization.js +24 -0
- package/src/core/patterns/caching.js +19 -0
- package/src/core/patterns/component.js +18 -0
- package/src/core/patterns/config.js +15 -0
- package/src/core/patterns/controller.js +42 -0
- package/src/core/patterns/email.js +20 -0
- package/src/core/patterns/factory.js +31 -0
- package/src/core/patterns/gemfile.js +9 -0
- package/src/core/patterns/helper.js +10 -0
- package/src/core/patterns/job.js +12 -0
- package/src/core/patterns/model.js +123 -0
- package/src/core/patterns/realtime.js +17 -0
- package/src/core/patterns/route.js +27 -0
- package/src/core/patterns/schema.js +25 -0
- package/src/core/patterns/stimulus.js +13 -0
- package/src/core/patterns/storage.js +16 -0
- package/src/core/patterns/uploader.js +16 -0
- package/src/core/patterns/view.js +20 -0
- package/src/core/patterns/worker.js +12 -0
- package/src/core/patterns.js +27 -0
- package/src/core/scanner.js +394 -0
- package/src/core/version-detector.js +295 -0
- package/src/extractors/api.js +284 -0
- package/src/extractors/auth.js +853 -0
- package/src/extractors/authorization.js +785 -0
- package/src/extractors/caching.js +84 -0
- package/src/extractors/component.js +221 -0
- package/src/extractors/config.js +81 -0
- package/src/extractors/controller.js +273 -0
- package/src/extractors/coverage-snapshot.js +296 -0
- package/src/extractors/email.js +123 -0
- package/src/extractors/factory-registry.js +225 -0
- package/src/extractors/gemfile.js +440 -0
- package/src/extractors/helper.js +55 -0
- package/src/extractors/jobs.js +122 -0
- package/src/extractors/model.js +506 -0
- package/src/extractors/realtime.js +102 -0
- package/src/extractors/routes.js +251 -0
- package/src/extractors/schema.js +178 -0
- package/src/extractors/stimulus.js +149 -0
- package/src/extractors/storage.js +100 -0
- package/src/extractors/test-conventions.js +340 -0
- package/src/extractors/tier2.js +417 -0
- package/src/extractors/tier3.js +84 -0
- package/src/extractors/uploader.js +138 -0
- package/src/extractors/views.js +131 -0
- package/src/extractors/worker.js +62 -0
- package/src/git/diff-parser.js +132 -0
- package/src/providers/interface.js +12 -0
- package/src/providers/local-fs.js +318 -0
- package/src/server.js +71 -0
- package/src/tools/blast-radius-tools.js +129 -0
- package/src/tools/free-tools.js +44 -0
- package/src/tools/handlers/get-controller.js +93 -0
- package/src/tools/handlers/get-coverage-gaps.js +100 -0
- package/src/tools/handlers/get-deep-analysis.js +294 -0
- package/src/tools/handlers/get-domain-clusters.js +113 -0
- package/src/tools/handlers/get-factory-registry.js +43 -0
- package/src/tools/handlers/get-full-index.js +28 -0
- package/src/tools/handlers/get-model.js +108 -0
- package/src/tools/handlers/get-overview.js +153 -0
- package/src/tools/handlers/get-routes.js +18 -0
- package/src/tools/handlers/get-schema.js +40 -0
- package/src/tools/handlers/get-subgraph.js +82 -0
- package/src/tools/handlers/get-test-conventions.js +18 -0
- package/src/tools/handlers/get-well-tested-examples.js +51 -0
- package/src/tools/handlers/helpers.js +115 -0
- package/src/tools/handlers/index-project.js +36 -0
- package/src/tools/handlers/search-patterns.js +104 -0
- package/src/tools/index.js +34 -0
- package/src/tools/pro-tools.js +13 -0
- package/src/utils/file-reader.js +20 -0
- package/src/utils/inflector.js +223 -0
- package/src/utils/ruby-parser.js +115 -0
- package/src/utils/spec-style-detector.js +26 -0
- package/src/utils/token-counter.js +46 -0
- package/src/utils/yaml-parser.js +135 -0
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorization Extractor (#9)
|
|
3
|
+
* Detects authorization strategy (Pundit, CanCanCan, Action Policy, custom RBAC)
|
|
4
|
+
* and extracts a comprehensive RBAC analysis including guard methods,
|
|
5
|
+
* role predicates, controller enforcement map, and domain role disambiguation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { AUTHORIZATION_PATTERNS } from '../core/patterns.js'
|
|
9
|
+
|
|
10
|
+
// Common authorization gem names to search for and report
|
|
11
|
+
const SEARCHED_LIBRARIES = [
|
|
12
|
+
'pundit',
|
|
13
|
+
'cancancan',
|
|
14
|
+
'cancan',
|
|
15
|
+
'rolify',
|
|
16
|
+
'action_policy',
|
|
17
|
+
'access-granted',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
// -------------------------------------------------------
|
|
21
|
+
// Helpers for deep custom RBAC extraction
|
|
22
|
+
// -------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** Extract method bodies from Ruby source as { name → body }. */
|
|
25
|
+
function extractMethodBodies(content) {
|
|
26
|
+
const bodies = {}
|
|
27
|
+
const lines = content.split('\n')
|
|
28
|
+
let currentMethod = null
|
|
29
|
+
let depth = 0
|
|
30
|
+
const bodyLines = []
|
|
31
|
+
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
const defMatch = line.match(/^\s*def\s+(\w+[?!]?)/)
|
|
34
|
+
if (defMatch && depth === 0) {
|
|
35
|
+
if (currentMethod) bodies[currentMethod] = bodyLines.join('\n').trim()
|
|
36
|
+
currentMethod = defMatch[1]
|
|
37
|
+
bodyLines.length = 0
|
|
38
|
+
depth = 0
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (currentMethod) {
|
|
42
|
+
if (/\bdo\b|\bif\b|\bcase\b|\bbegin\b|\bdef\b/.test(line)) depth++
|
|
43
|
+
if (/^\s*end\b/.test(line)) {
|
|
44
|
+
if (depth === 0) {
|
|
45
|
+
bodies[currentMethod] = bodyLines.join('\n').trim()
|
|
46
|
+
currentMethod = null
|
|
47
|
+
bodyLines.length = 0
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
depth--
|
|
51
|
+
}
|
|
52
|
+
bodyLines.push(line)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (currentMethod) bodies[currentMethod] = bodyLines.join('\n').trim()
|
|
56
|
+
return bodies
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Parse the authorization concern for guard methods, helpers, error handling. */
|
|
60
|
+
function parseConcern(content, filePath) {
|
|
61
|
+
if (!content) return null
|
|
62
|
+
|
|
63
|
+
const concern = {
|
|
64
|
+
file: filePath,
|
|
65
|
+
included_in: null,
|
|
66
|
+
error_class: null,
|
|
67
|
+
helper_methods_exposed_to_views: [],
|
|
68
|
+
guard_methods: {},
|
|
69
|
+
error_handling: null,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Detect error class
|
|
73
|
+
const errorClassMatch = content.match(
|
|
74
|
+
/class\s+(\w+(?:::\w+)*Error)\s*<\s*(StandardError|RuntimeError)/,
|
|
75
|
+
)
|
|
76
|
+
if (errorClassMatch) concern.error_class = errorClassMatch[1]
|
|
77
|
+
|
|
78
|
+
// Detect helper_method declarations
|
|
79
|
+
const helperMatch = content.match(/helper_method\s+([^\n]+)/)
|
|
80
|
+
if (helperMatch) {
|
|
81
|
+
concern.helper_methods_exposed_to_views = helperMatch[1]
|
|
82
|
+
.split(',')
|
|
83
|
+
.map((s) => s.trim().replace(/^:/, ''))
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Extract guard methods (require_*! pattern)
|
|
88
|
+
const bodies = extractMethodBodies(content)
|
|
89
|
+
for (const [name, body] of Object.entries(bodies)) {
|
|
90
|
+
if (/^require_\w+!$/.test(name)) {
|
|
91
|
+
const guard = { requirement: null, raises: null }
|
|
92
|
+
// Look for the predicate check
|
|
93
|
+
const predicateMatch =
|
|
94
|
+
body.match(/unless\s+(?:Current\.user\.)?(\w+\?)/) ||
|
|
95
|
+
body.match(/raise.*unless.*?(\w+\?)/) ||
|
|
96
|
+
body.match(/if\s+(?:!|not\s)(?:Current\.user\.)?(\w+\?)/)
|
|
97
|
+
if (predicateMatch) {
|
|
98
|
+
guard.requirement = predicateMatch[1]
|
|
99
|
+
} else {
|
|
100
|
+
// Try to extract multi-predicate: "a? || b?"
|
|
101
|
+
const multiMatch = body.match(
|
|
102
|
+
/unless\s+(?:Current\.user\.)?(\w+\?(?:\s*\|\|\s*(?:Current\.user\.)?\w+\?)*)/,
|
|
103
|
+
)
|
|
104
|
+
if (multiMatch)
|
|
105
|
+
guard.requirement = multiMatch[1].replace(/Current\.user\./g, '')
|
|
106
|
+
}
|
|
107
|
+
// Detect what error is raised
|
|
108
|
+
const raiseMatch = body.match(/raise\s+(\w+(?:::\w+)*)/)
|
|
109
|
+
if (raiseMatch) guard.raises = raiseMatch[1]
|
|
110
|
+
concern.guard_methods[name] = guard
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Error handling: rescue_from
|
|
115
|
+
const rescueMatch = content.match(
|
|
116
|
+
/rescue_from\s+(\w+(?:::\w+)*),\s*with:\s*:(\w+)/,
|
|
117
|
+
)
|
|
118
|
+
if (rescueMatch) {
|
|
119
|
+
const handlerName = rescueMatch[2]
|
|
120
|
+
const handlerBody = bodies[handlerName] || ''
|
|
121
|
+
const errorHandling = {
|
|
122
|
+
rescue_from: rescueMatch[1],
|
|
123
|
+
handler: handlerName,
|
|
124
|
+
}
|
|
125
|
+
// Detect response logic
|
|
126
|
+
if (/redirect/.test(handlerBody)) {
|
|
127
|
+
const redirectMatch = handlerBody.match(/redirect_to\s+([^\n,]+)/)
|
|
128
|
+
errorHandling.html_response = redirectMatch
|
|
129
|
+
? `redirect with flash — ${redirectMatch[1].trim()}`
|
|
130
|
+
: 'redirect with flash alert'
|
|
131
|
+
}
|
|
132
|
+
if (/head\s*:forbidden|head\s*403/.test(handlerBody)) {
|
|
133
|
+
errorHandling.non_html_response = 'head :forbidden (HTTP 403)'
|
|
134
|
+
}
|
|
135
|
+
concern.error_handling = errorHandling
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return concern
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Extract role predicates from User model content. */
|
|
142
|
+
function extractRolePredicates(content) {
|
|
143
|
+
if (!content) return null
|
|
144
|
+
|
|
145
|
+
const bodies = extractMethodBodies(content)
|
|
146
|
+
const atomic = {}
|
|
147
|
+
const composite = {}
|
|
148
|
+
const legacy_aliases = {}
|
|
149
|
+
|
|
150
|
+
for (const [name, body] of Object.entries(bodies)) {
|
|
151
|
+
if (!name.endsWith('?')) continue
|
|
152
|
+
const trimmedBody = body.trim().replace(/\s+/g, ' ')
|
|
153
|
+
|
|
154
|
+
// Detect if this is a simple role check (atomic)
|
|
155
|
+
const singleRoleMatch = trimmedBody.match(/^role\s*==\s*['"](\w+)['"]$/)
|
|
156
|
+
if (singleRoleMatch) {
|
|
157
|
+
atomic[name] = `role == '${singleRoleMatch[1]}'`
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Detect composite predicates (using || or &&)
|
|
162
|
+
if (/\w+\?\s*(\|\||&&)\s*\w+\?/.test(trimmedBody)) {
|
|
163
|
+
composite[name] = trimmedBody
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Detect alias/delegate (method simply calls another predicate)
|
|
168
|
+
const aliasMatch = trimmedBody.match(/^(\w+\?)$/)
|
|
169
|
+
if (aliasMatch && bodies[aliasMatch[1]] !== undefined) {
|
|
170
|
+
legacy_aliases[name] = aliasMatch[1]
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Check for send(:method_name) pattern used by aliases
|
|
175
|
+
const sendMatch = trimmedBody.match(/^send\s*\(\s*:(\w+\?)\s*\)$/)
|
|
176
|
+
if (sendMatch) {
|
|
177
|
+
legacy_aliases[name] = sendMatch[1]
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Simple delegation: method_name → another_method?
|
|
182
|
+
if (/^\w+\?$/.test(trimmedBody)) {
|
|
183
|
+
legacy_aliases[name] = trimmedBody
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (
|
|
188
|
+
Object.keys(atomic).length === 0 &&
|
|
189
|
+
Object.keys(composite).length === 0 &&
|
|
190
|
+
Object.keys(legacy_aliases).length === 0
|
|
191
|
+
) {
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
source_file: 'app/models/user.rb',
|
|
197
|
+
atomic,
|
|
198
|
+
composite,
|
|
199
|
+
legacy_aliases:
|
|
200
|
+
Object.keys(legacy_aliases).length > 0 ? legacy_aliases : undefined,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Extract role definition from User model content. */
|
|
205
|
+
function extractRoleDefinition(content, schemaData) {
|
|
206
|
+
if (!content) return null
|
|
207
|
+
|
|
208
|
+
// Try multiple enum patterns
|
|
209
|
+
const enumPatterns = [
|
|
210
|
+
// Modern: enum :role, { key: 0, ... }
|
|
211
|
+
/enum\s+:role,\s*\{([^}]+)\}/,
|
|
212
|
+
// Legacy: enum role: { key: 0, ... }
|
|
213
|
+
/enum\s+role:\s*\{([^}]+)\}/,
|
|
214
|
+
// Array: enum :role, [ :a, :b ]
|
|
215
|
+
/enum\s+:role,\s*\[([^\]]+)\]/,
|
|
216
|
+
// Legacy array: enum role: [ :a, :b ]
|
|
217
|
+
/enum\s+role:\s*\[([^\]]+)\]/,
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
let enumBody = null
|
|
221
|
+
let enumType = 'string'
|
|
222
|
+
for (const re of enumPatterns) {
|
|
223
|
+
const m = content.match(re)
|
|
224
|
+
if (m) {
|
|
225
|
+
enumBody = m[1]
|
|
226
|
+
break
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!enumBody) return null
|
|
231
|
+
|
|
232
|
+
// Parse roles from enum body
|
|
233
|
+
const roles = {}
|
|
234
|
+
const pairRe = /(\w+):\s*(\d+)/g
|
|
235
|
+
let pm
|
|
236
|
+
let hasIntValues = false
|
|
237
|
+
while ((pm = pairRe.exec(enumBody))) {
|
|
238
|
+
hasIntValues = true
|
|
239
|
+
roles[pm[1]] = { value: pm[1], default: false }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!hasIntValues) {
|
|
243
|
+
// Symbol-only enum (string type)
|
|
244
|
+
const symbols =
|
|
245
|
+
enumBody.match(/\w+/g)?.filter((v) => !/^\d+$/.test(v)) || []
|
|
246
|
+
for (const s of symbols) {
|
|
247
|
+
roles[s] = { value: s, default: false }
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
enumType = 'integer'
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Check for default role
|
|
254
|
+
const defaultMatch = content.match(
|
|
255
|
+
/default:\s*['"](\w+)['"]|default.*role.*['"](\w+)['"]/,
|
|
256
|
+
)
|
|
257
|
+
if (defaultMatch) {
|
|
258
|
+
const defaultRole = defaultMatch[1] || defaultMatch[2]
|
|
259
|
+
if (roles[defaultRole]) roles[defaultRole].default = true
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Check schema for column details
|
|
263
|
+
let storage = { model: 'User', column: 'role' }
|
|
264
|
+
if (schemaData) {
|
|
265
|
+
const usersTable = (schemaData.tables || []).find((t) => t.name === 'users')
|
|
266
|
+
if (usersTable) {
|
|
267
|
+
const roleCol = usersTable.columns?.find((c) => c.name === 'role')
|
|
268
|
+
if (roleCol) {
|
|
269
|
+
storage.column_type = roleCol.type || 'string'
|
|
270
|
+
if (roleCol.constraints) {
|
|
271
|
+
if (/default/.test(roleCol.constraints))
|
|
272
|
+
storage.default = roleCol.constraints.match(
|
|
273
|
+
/default:\s*['"]?(\w+)['"]?/,
|
|
274
|
+
)?.[1]
|
|
275
|
+
if (/null:\s*false/.test(roleCol.constraints)) storage.null = false
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const roleIndex = usersTable.indexes?.find((i) =>
|
|
279
|
+
i.columns?.includes('role'),
|
|
280
|
+
)
|
|
281
|
+
if (roleIndex) storage.indexed = true
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Detect role normalization callbacks
|
|
286
|
+
let normalization = null
|
|
287
|
+
const normMatch = content.match(/before_validation\s+:(\w+)(?:.*?#\s*(.+))?/)
|
|
288
|
+
if (normMatch && /role|legacy/.test(normMatch[1])) {
|
|
289
|
+
normalization = `before_validation :${normMatch[1]}${normMatch[2] ? ' — ' + normMatch[2].trim() : ''}`
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Detect legacy role aliases
|
|
293
|
+
const legacy_aliases = {}
|
|
294
|
+
const bodies = extractMethodBodies(content)
|
|
295
|
+
// Look for normalization method that maps old values to new
|
|
296
|
+
for (const [name, body] of Object.entries(bodies)) {
|
|
297
|
+
if (/normalize|legacy|remap/.test(name) && /role/.test(body)) {
|
|
298
|
+
const mappings = body.matchAll(/['"](\w+)['"]\s*=>\s*['"](\w+)['"]/g)
|
|
299
|
+
for (const mapping of mappings) {
|
|
300
|
+
legacy_aliases[mapping[1]] = mapping[2]
|
|
301
|
+
}
|
|
302
|
+
// Also check gsub/sub patterns
|
|
303
|
+
const gsubMatch = body.match(/gsub.*['"](\w+)['"].*['"](\w+)['"]/)
|
|
304
|
+
if (gsubMatch) legacy_aliases[gsubMatch[1]] = gsubMatch[2]
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
storage,
|
|
310
|
+
enum_type: enumType === 'integer' ? 'integer' : 'string',
|
|
311
|
+
roles,
|
|
312
|
+
legacy_aliases:
|
|
313
|
+
Object.keys(legacy_aliases).length > 0 ? legacy_aliases : undefined,
|
|
314
|
+
normalization,
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Build the controller enforcement map by scanning controller files. */
|
|
319
|
+
function buildEnforcementMap(provider, entries, guardMethodNames) {
|
|
320
|
+
if (guardMethodNames.length === 0) return null
|
|
321
|
+
|
|
322
|
+
const guardPattern = new RegExp(
|
|
323
|
+
`(?:before_action|prepend_before_action)\\s+:?(${guardMethodNames.map((n) => n.replace(/[!?]/g, '\\$&')).join('|')})`,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
const namespaces = {}
|
|
327
|
+
const unguarded = []
|
|
328
|
+
const controllerGuards = {} // className → { file, guard, superclass }
|
|
329
|
+
|
|
330
|
+
const controllerEntries = entries.filter(
|
|
331
|
+
(e) =>
|
|
332
|
+
e.categoryName === 'controllers' ||
|
|
333
|
+
e.category === 'controller' ||
|
|
334
|
+
(e.path && e.path.includes('app/controllers/') && e.path.endsWith('.rb')),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
for (const entry of controllerEntries) {
|
|
338
|
+
const content = provider.readFile(entry.path)
|
|
339
|
+
if (!content) continue
|
|
340
|
+
|
|
341
|
+
const classMatch = content.match(
|
|
342
|
+
/class\s+(\w+(?:::\w+)*)\s*<\s*(\w+(?:::\w+)*)/,
|
|
343
|
+
)
|
|
344
|
+
if (!classMatch) continue
|
|
345
|
+
|
|
346
|
+
const className = classMatch[1]
|
|
347
|
+
const superclass = classMatch[2]
|
|
348
|
+
|
|
349
|
+
// Check for guard before_actions
|
|
350
|
+
const guards = []
|
|
351
|
+
const guardRe = new RegExp(guardPattern.source, 'g')
|
|
352
|
+
let gm
|
|
353
|
+
while ((gm = guardRe.exec(content))) {
|
|
354
|
+
const guardName = gm[1]
|
|
355
|
+
// Check for only/except options
|
|
356
|
+
const afterGuard = content.slice(gm.index, gm.index + 200)
|
|
357
|
+
const onlyMatch = afterGuard.match(
|
|
358
|
+
/only:\s*(?:\[([^\]]+)\]|:(\w+)|%i\[([^\]]+)\])/,
|
|
359
|
+
)
|
|
360
|
+
const only = onlyMatch
|
|
361
|
+
? (onlyMatch[1] || onlyMatch[2] || onlyMatch[3] || '')
|
|
362
|
+
.replace(/[:%]/g, ' ')
|
|
363
|
+
.trim()
|
|
364
|
+
.split(/[\s,]+/)
|
|
365
|
+
.filter(Boolean)
|
|
366
|
+
: null
|
|
367
|
+
guards.push({ method: guardName, only })
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Check for allow_unauthenticated_access
|
|
371
|
+
const unauthMatch = content.match(
|
|
372
|
+
/allow_unauthenticated_access(?:\s+only:\s*(?:%i\[([^\]]+)\]|:(\w+)|\[([^\]]+)\]))?/,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
controllerGuards[className] = {
|
|
376
|
+
file: entry.path,
|
|
377
|
+
guards,
|
|
378
|
+
superclass,
|
|
379
|
+
allow_unauthenticated: !!unauthMatch,
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (unauthMatch && guards.length === 0) {
|
|
383
|
+
const only = (unauthMatch[1] || unauthMatch[2] || unauthMatch[3] || '')
|
|
384
|
+
.replace(/[:%]/g, ' ')
|
|
385
|
+
.trim()
|
|
386
|
+
const label = only
|
|
387
|
+
? `${className} (allow_unauthenticated_access on ${only})`
|
|
388
|
+
: `${className} (allow_unauthenticated_access)`
|
|
389
|
+
unguarded.push(label)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Resolve inheritance: mark controllers that inherit guards
|
|
394
|
+
for (const [className, info] of Object.entries(controllerGuards)) {
|
|
395
|
+
if (info.guards.length === 0 && !info.allow_unauthenticated) {
|
|
396
|
+
// Check if superclass has a guard
|
|
397
|
+
const parent = controllerGuards[info.superclass]
|
|
398
|
+
if (parent && parent.guards.length > 0) {
|
|
399
|
+
info.inherited_guard = {
|
|
400
|
+
from: info.superclass,
|
|
401
|
+
guard: parent.guards[0].method,
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Group by namespace
|
|
408
|
+
for (const [className, info] of Object.entries(controllerGuards)) {
|
|
409
|
+
if (info.guards.length === 0 && !info.inherited_guard) continue
|
|
410
|
+
|
|
411
|
+
let ns = 'other'
|
|
412
|
+
if (className.startsWith('Admin::')) ns = 'admin_namespace'
|
|
413
|
+
else if (className.startsWith('Settings::')) ns = 'settings_namespace'
|
|
414
|
+
else if (info.file?.includes('/admin/')) ns = 'admin_namespace'
|
|
415
|
+
else if (info.file?.includes('/settings/')) ns = 'settings_namespace'
|
|
416
|
+
else ns = 'customer_area'
|
|
417
|
+
|
|
418
|
+
if (!namespaces[ns]) namespaces[ns] = { controllers: {} }
|
|
419
|
+
|
|
420
|
+
const ctrlEntry = { file: info.file }
|
|
421
|
+
if (info.guards.length > 0) {
|
|
422
|
+
const primaryGuard = info.guards[0]
|
|
423
|
+
ctrlEntry.guard = primaryGuard.method
|
|
424
|
+
if (primaryGuard.only) ctrlEntry.only = primaryGuard.only.join(', ')
|
|
425
|
+
// Additional guards beyond the first
|
|
426
|
+
if (info.guards.length > 1) {
|
|
427
|
+
ctrlEntry.additional_guards = info.guards.slice(1).map((g) => ({
|
|
428
|
+
guard: g.method,
|
|
429
|
+
only: g.only ? g.only.join(', ') : null,
|
|
430
|
+
}))
|
|
431
|
+
}
|
|
432
|
+
} else if (info.inherited_guard) {
|
|
433
|
+
ctrlEntry.guard = `inherited (${info.inherited_guard.guard})`
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
namespaces[ns].controllers[className] = ctrlEntry
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Add base_guard labels for namespaces
|
|
440
|
+
for (const [ns, data] of Object.entries(namespaces)) {
|
|
441
|
+
// Find the base controller for this namespace
|
|
442
|
+
const baseNames = Object.keys(data.controllers).filter(
|
|
443
|
+
(n) => n.endsWith('BaseController') || n === 'Admin::BaseController',
|
|
444
|
+
)
|
|
445
|
+
if (baseNames.length > 0) {
|
|
446
|
+
const baseCtrl = controllerGuards[baseNames[0]]
|
|
447
|
+
if (baseCtrl?.guards?.length > 0) {
|
|
448
|
+
data.base_guard = `${baseCtrl.guards[0].method} (before_action on ${baseNames[0]})`
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
...namespaces,
|
|
455
|
+
unguarded_controllers: unguarded.length > 0 ? unguarded : undefined,
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Detect domain roles that are NOT part of the auth system. */
|
|
460
|
+
function detectDomainRoles(provider, entries, authRoleModel) {
|
|
461
|
+
const domainRoles = []
|
|
462
|
+
|
|
463
|
+
// Look for concerns or models with "role" in the name that aren't the auth role model
|
|
464
|
+
for (const entry of entries) {
|
|
465
|
+
if (entry.categoryName !== 'models' && entry.category !== 'model') continue
|
|
466
|
+
if (!/role/i.test(entry.path)) continue
|
|
467
|
+
// Skip the auth role model itself
|
|
468
|
+
if (entry.path === 'app/models/user.rb') continue
|
|
469
|
+
|
|
470
|
+
const content = provider.readFile(entry.path)
|
|
471
|
+
if (!content) continue
|
|
472
|
+
|
|
473
|
+
const isConcern =
|
|
474
|
+
/module\s+\w+/.test(content) &&
|
|
475
|
+
/extend\s+ActiveSupport::Concern/.test(content)
|
|
476
|
+
const classMatch = content.match(/(?:module|class)\s+(\w+(?:::\w+)*)/)
|
|
477
|
+
const name = classMatch ? classMatch[1] : entry.path
|
|
478
|
+
|
|
479
|
+
// Determine purpose from content
|
|
480
|
+
let purpose = 'unknown'
|
|
481
|
+
if (isConcern) {
|
|
482
|
+
// Look for constant arrays or hashes that suggest domain data
|
|
483
|
+
const constMatch = content.match(/(\w+)\s*=\s*(?:\[|%w)/)
|
|
484
|
+
if (constMatch)
|
|
485
|
+
purpose = `Static list defined as ${constMatch[1]} constant`
|
|
486
|
+
else purpose = 'Concern module'
|
|
487
|
+
} else {
|
|
488
|
+
// Check if it's an ActiveRecord model
|
|
489
|
+
if (/class\s+\w+\s*<\s*(?:Application|Active)Record/.test(content)) {
|
|
490
|
+
purpose = 'Domain model for business entities (not access control)'
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
domainRoles.push({
|
|
495
|
+
concern: `${name} (${entry.path})`,
|
|
496
|
+
purpose,
|
|
497
|
+
auth_relevance:
|
|
498
|
+
'none — purely domain data, not related to access control',
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return domainRoles.length > 0 ? domainRoles[0] : null
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Extract authorization information.
|
|
507
|
+
* @param {import('../providers/interface.js').FileProvider} provider
|
|
508
|
+
* @param {Array<{path: string, category: string}>} entries
|
|
509
|
+
* @param {{gems?: object}} gemInfo
|
|
510
|
+
* @param {object|null} schemaData
|
|
511
|
+
* @returns {object}
|
|
512
|
+
*/
|
|
513
|
+
export function extractAuthorization(
|
|
514
|
+
provider,
|
|
515
|
+
entries,
|
|
516
|
+
gemInfo = {},
|
|
517
|
+
schemaData = null,
|
|
518
|
+
) {
|
|
519
|
+
const gems = gemInfo.gems || {}
|
|
520
|
+
const result = {
|
|
521
|
+
strategy: null,
|
|
522
|
+
policies: [],
|
|
523
|
+
abilities: null,
|
|
524
|
+
roles: null,
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const hasPundit = !!gems.pundit
|
|
528
|
+
const hasCanCan = !!gems.cancancan || !!gems.cancan
|
|
529
|
+
const hasActionPolicy = !!gems.action_policy
|
|
530
|
+
const hasRolify = !!gems.rolify
|
|
531
|
+
const hasAccessGranted = !!gems['access-granted']
|
|
532
|
+
|
|
533
|
+
// Report which libraries were searched and not found
|
|
534
|
+
const searchedNotFound = SEARCHED_LIBRARIES.filter((lib) => !gems[lib])
|
|
535
|
+
|
|
536
|
+
// Pundit
|
|
537
|
+
if (hasPundit) {
|
|
538
|
+
result.strategy = 'pundit'
|
|
539
|
+
const policyEntries = entries.filter(
|
|
540
|
+
(e) =>
|
|
541
|
+
e.path.startsWith('app/policies/') && e.path.endsWith('_policy.rb'),
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
for (const entry of policyEntries) {
|
|
545
|
+
const content = provider.readFile(entry.path)
|
|
546
|
+
if (!content) continue
|
|
547
|
+
|
|
548
|
+
const classMatch = content.match(AUTHORIZATION_PATTERNS.policyClass)
|
|
549
|
+
if (!classMatch) continue
|
|
550
|
+
|
|
551
|
+
const policy = {
|
|
552
|
+
class: classMatch[1] + 'Policy',
|
|
553
|
+
resource: classMatch[1],
|
|
554
|
+
permitted_actions: [],
|
|
555
|
+
has_scope: false,
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const methodRe = new RegExp(
|
|
559
|
+
AUTHORIZATION_PATTERNS.policyMethod.source,
|
|
560
|
+
'g',
|
|
561
|
+
)
|
|
562
|
+
let m
|
|
563
|
+
while ((m = methodRe.exec(content))) {
|
|
564
|
+
policy.permitted_actions.push(m[1])
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (AUTHORIZATION_PATTERNS.policyScopeClass.test(content)) {
|
|
568
|
+
policy.has_scope = true
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
result.policies.push(policy)
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// CanCanCan
|
|
576
|
+
if (hasCanCan) {
|
|
577
|
+
if (!result.strategy) result.strategy = 'cancancan'
|
|
578
|
+
const abilityContent = provider.readFile('app/models/ability.rb')
|
|
579
|
+
if (
|
|
580
|
+
abilityContent &&
|
|
581
|
+
AUTHORIZATION_PATTERNS.abilityClass.test(abilityContent)
|
|
582
|
+
) {
|
|
583
|
+
const abilities = []
|
|
584
|
+
const canRe = new RegExp(AUTHORIZATION_PATTERNS.canDef.source, 'gm')
|
|
585
|
+
let m
|
|
586
|
+
while ((m = canRe.exec(abilityContent))) {
|
|
587
|
+
abilities.push({ type: 'can', definition: m[1].trim() })
|
|
588
|
+
}
|
|
589
|
+
const cannotRe = new RegExp(AUTHORIZATION_PATTERNS.cannotDef.source, 'gm')
|
|
590
|
+
while ((m = cannotRe.exec(abilityContent))) {
|
|
591
|
+
abilities.push({ type: 'cannot', definition: m[1].trim() })
|
|
592
|
+
}
|
|
593
|
+
result.abilities = abilities
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Action Policy
|
|
598
|
+
if (hasActionPolicy) {
|
|
599
|
+
if (!result.strategy) result.strategy = 'action_policy'
|
|
600
|
+
const policyEntries = entries.filter(
|
|
601
|
+
(e) =>
|
|
602
|
+
e.path.startsWith('app/policies/') && e.path.endsWith('_policy.rb'),
|
|
603
|
+
)
|
|
604
|
+
for (const entry of policyEntries) {
|
|
605
|
+
const content = provider.readFile(entry.path)
|
|
606
|
+
if (!content) continue
|
|
607
|
+
const classMatch = content.match(AUTHORIZATION_PATTERNS.policyClass)
|
|
608
|
+
if (!classMatch) continue
|
|
609
|
+
const policy = {
|
|
610
|
+
class: classMatch[1] + 'Policy',
|
|
611
|
+
resource: classMatch[1],
|
|
612
|
+
permitted_actions: [],
|
|
613
|
+
has_scope: false,
|
|
614
|
+
}
|
|
615
|
+
const methodRe = new RegExp(
|
|
616
|
+
AUTHORIZATION_PATTERNS.policyMethod.source,
|
|
617
|
+
'g',
|
|
618
|
+
)
|
|
619
|
+
let m
|
|
620
|
+
while ((m = methodRe.exec(content))) {
|
|
621
|
+
policy.permitted_actions.push(m[1])
|
|
622
|
+
}
|
|
623
|
+
result.policies.push(policy)
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Rolify
|
|
628
|
+
if (hasRolify) {
|
|
629
|
+
if (!result.strategy) result.strategy = 'rolify'
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Custom policies (no gem but app/policies/ exists)
|
|
633
|
+
if (!result.strategy) {
|
|
634
|
+
const policyEntries = entries.filter(
|
|
635
|
+
(e) =>
|
|
636
|
+
e.path.startsWith('app/policies/') && e.path.endsWith('_policy.rb'),
|
|
637
|
+
)
|
|
638
|
+
if (policyEntries.length > 0) {
|
|
639
|
+
result.strategy = 'custom'
|
|
640
|
+
for (const entry of policyEntries) {
|
|
641
|
+
const content = provider.readFile(entry.path)
|
|
642
|
+
if (!content) continue
|
|
643
|
+
const classMatch = content.match(AUTHORIZATION_PATTERNS.policyClass)
|
|
644
|
+
if (classMatch) {
|
|
645
|
+
result.policies.push({
|
|
646
|
+
class: classMatch[1] + 'Policy',
|
|
647
|
+
resource: classMatch[1],
|
|
648
|
+
permitted_actions: [],
|
|
649
|
+
has_scope: false,
|
|
650
|
+
})
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Role detection from models
|
|
657
|
+
const modelEntries = entries.filter(
|
|
658
|
+
(e) => e.category === 'model' || e.categoryName === 'models',
|
|
659
|
+
)
|
|
660
|
+
for (const entry of modelEntries) {
|
|
661
|
+
const content = provider.readFile(entry.path)
|
|
662
|
+
if (!content) continue
|
|
663
|
+
if (AUTHORIZATION_PATTERNS.enumRole.test(content)) {
|
|
664
|
+
const className = entry.path
|
|
665
|
+
.split('/')
|
|
666
|
+
.pop()
|
|
667
|
+
.replace('.rb', '')
|
|
668
|
+
.split('_')
|
|
669
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
670
|
+
.join('')
|
|
671
|
+
result.roles = { source: 'enum', model: className }
|
|
672
|
+
break
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// -------------------------------------------------------
|
|
677
|
+
// Deep custom RBAC extraction (when no standard library found)
|
|
678
|
+
// -------------------------------------------------------
|
|
679
|
+
// Detect authorization concern in controller concerns
|
|
680
|
+
const authzConcernPaths = [
|
|
681
|
+
'app/controllers/concerns/authorization.rb',
|
|
682
|
+
'app/controllers/concerns/authorizable.rb',
|
|
683
|
+
]
|
|
684
|
+
let authzConcernContent = null
|
|
685
|
+
let authzConcernFile = null
|
|
686
|
+
for (const p of authzConcernPaths) {
|
|
687
|
+
const c = provider.readFile(p)
|
|
688
|
+
if (c) {
|
|
689
|
+
authzConcernContent = c
|
|
690
|
+
authzConcernFile = p
|
|
691
|
+
break
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// Fallback: search entries for a concern with "authorization" in path
|
|
695
|
+
if (!authzConcernContent) {
|
|
696
|
+
const concernEntry = entries.find(
|
|
697
|
+
(e) =>
|
|
698
|
+
(e.categoryName === 'controllers' || e.category === 'controller') &&
|
|
699
|
+
e.path.includes('concerns') &&
|
|
700
|
+
e.path.toLowerCase().includes('authoriz'),
|
|
701
|
+
)
|
|
702
|
+
if (concernEntry) {
|
|
703
|
+
authzConcernContent = provider.readFile(concernEntry.path)
|
|
704
|
+
authzConcernFile = concernEntry.path
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (authzConcernContent) {
|
|
709
|
+
if (!result.strategy) result.strategy = 'custom_rbac'
|
|
710
|
+
|
|
711
|
+
// Parse the concern
|
|
712
|
+
const concern = parseConcern(authzConcernContent, authzConcernFile)
|
|
713
|
+
|
|
714
|
+
// Check where it's included
|
|
715
|
+
const appCtrlContent = provider.readFile(
|
|
716
|
+
'app/controllers/application_controller.rb',
|
|
717
|
+
)
|
|
718
|
+
if (appCtrlContent && /include\s+Authorization/.test(appCtrlContent)) {
|
|
719
|
+
concern.included_in =
|
|
720
|
+
'ApplicationController (via app/controllers/application_controller.rb)'
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
result.concern = concern
|
|
724
|
+
|
|
725
|
+
// Extract role definition and predicates from User model
|
|
726
|
+
const userContent = provider.readFile('app/models/user.rb')
|
|
727
|
+
if (userContent) {
|
|
728
|
+
const roleDefinition = extractRoleDefinition(userContent, schemaData)
|
|
729
|
+
if (roleDefinition) result.role_definition = roleDefinition
|
|
730
|
+
|
|
731
|
+
const predicates = extractRolePredicates(userContent)
|
|
732
|
+
if (predicates) result.predicates = predicates
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Build controller enforcement map
|
|
736
|
+
const guardMethodNames = Object.keys(concern.guard_methods || {})
|
|
737
|
+
const enforcementMap = buildEnforcementMap(
|
|
738
|
+
provider,
|
|
739
|
+
entries,
|
|
740
|
+
guardMethodNames,
|
|
741
|
+
)
|
|
742
|
+
if (enforcementMap) result.controller_enforcement_map = enforcementMap
|
|
743
|
+
|
|
744
|
+
// Disambiguate domain roles
|
|
745
|
+
const domainRoles = detectDomainRoles(
|
|
746
|
+
provider,
|
|
747
|
+
entries,
|
|
748
|
+
result.roles?.model,
|
|
749
|
+
)
|
|
750
|
+
if (domainRoles) result.domain_roles_not_auth = domainRoles
|
|
751
|
+
|
|
752
|
+
// Build related files list
|
|
753
|
+
const relatedFiles = [authzConcernFile]
|
|
754
|
+
if (appCtrlContent)
|
|
755
|
+
relatedFiles.push('app/controllers/application_controller.rb')
|
|
756
|
+
if (userContent) relatedFiles.push('app/models/user.rb')
|
|
757
|
+
// Add admin base controller if it exists
|
|
758
|
+
const adminBaseContent = provider.readFile(
|
|
759
|
+
'app/controllers/admin/base_controller.rb',
|
|
760
|
+
)
|
|
761
|
+
if (adminBaseContent)
|
|
762
|
+
relatedFiles.push('app/controllers/admin/base_controller.rb')
|
|
763
|
+
// Add auth concern for cross-reference
|
|
764
|
+
const authConcernContent = provider.readFile(
|
|
765
|
+
'app/controllers/concerns/authentication.rb',
|
|
766
|
+
)
|
|
767
|
+
if (authConcernContent)
|
|
768
|
+
relatedFiles.push('app/controllers/concerns/authentication.rb')
|
|
769
|
+
result.related_files = [...new Set(relatedFiles)]
|
|
770
|
+
|
|
771
|
+
// Add description
|
|
772
|
+
if (!hasPundit && !hasCanCan && !hasActionPolicy && !hasRolify) {
|
|
773
|
+
result.description =
|
|
774
|
+
'Fully custom role-based access control via controller concerns. No Pundit, CanCanCan, or Rolify.'
|
|
775
|
+
result.library = null
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Always include searched-and-not-found
|
|
780
|
+
if (searchedNotFound.length > 0) {
|
|
781
|
+
result.searched_libraries_not_found = searchedNotFound
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return result
|
|
785
|
+
}
|