permission_forge 0.14.1 → 0.15.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.
Files changed (347) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2372 -0
  3. data/README.md +76 -25
  4. data/app/controllers/concerns/permission_forge/api/crud_actions.rb +3 -2
  5. data/app/controllers/concerns/permission_forge/api/input_shapes.rb +200 -0
  6. data/app/controllers/concerns/permission_forge/api/pagination.rb +12 -5
  7. data/app/controllers/concerns/permission_forge/api/response_renderer.rb +37 -1
  8. data/app/controllers/concerns/permission_forge/api_authentication.rb +13 -2
  9. data/app/controllers/concerns/permission_forge/cache_confirmation.rb +42 -11
  10. data/app/controllers/concerns/permission_forge/change_review.rb +42 -0
  11. data/app/controllers/concerns/permission_forge/copy_refusal.rb +39 -0
  12. data/app/controllers/concerns/permission_forge/param_shapes.rb +207 -7
  13. data/app/controllers/concerns/permission_forge/save_notice.rb +69 -0
  14. data/app/controllers/permission_forge/api/audit_events_controller.rb +1 -1
  15. data/app/controllers/permission_forge/api/base_controller.rb +163 -12
  16. data/app/controllers/permission_forge/api/dashboard_controller.rb +29 -4
  17. data/app/controllers/permission_forge/api/permission_set_group_users_controller.rb +5 -2
  18. data/app/controllers/permission_forge/api/permission_set_groups_controller.rb +5 -4
  19. data/app/controllers/permission_forge/api/permission_set_rules_controller.rb +7 -5
  20. data/app/controllers/permission_forge/api/permission_set_users_controller.rb +80 -19
  21. data/app/controllers/permission_forge/api/permission_sets_controller.rb +12 -6
  22. data/app/controllers/permission_forge/api/resource_actions_controller.rb +1 -1
  23. data/app/controllers/permission_forge/api/resource_fields_controller.rb +3 -4
  24. data/app/controllers/permission_forge/api/resources_controller.rb +26 -12
  25. data/app/controllers/permission_forge/api/unmatched_routes_controller.rb +20 -0
  26. data/app/controllers/permission_forge/application_controller.rb +39 -5
  27. data/app/controllers/permission_forge/assignments_controller.rb +36 -34
  28. data/app/controllers/permission_forge/bulk_assignments_controller.rb +8 -4
  29. data/app/controllers/permission_forge/field_lookups_controller.rb +1 -1
  30. data/app/controllers/permission_forge/maintenance_controller.rb +6 -2
  31. data/app/controllers/permission_forge/people_controller.rb +14 -14
  32. data/app/controllers/permission_forge/permission_set_editor.rb +20 -6
  33. data/app/controllers/permission_forge/permission_set_group_users_controller.rb +3 -1
  34. data/app/controllers/permission_forge/permission_set_groups_controller.rb +23 -9
  35. data/app/controllers/permission_forge/permission_set_rules_controller.rb +20 -10
  36. data/app/controllers/permission_forge/permission_sets_controller.rb +23 -24
  37. data/app/controllers/permission_forge/resource_actions_controller.rb +11 -6
  38. data/app/controllers/permission_forge/resource_fields_controller.rb +37 -19
  39. data/app/controllers/permission_forge/resources_controller.rb +12 -11
  40. data/app/frontend/permission_forge/pf_admin.css +99 -17
  41. data/app/frontend/permission_forge/pf_admin.js +36 -0
  42. data/app/helpers/permission_forge/application_helper.rb +22 -72
  43. data/app/helpers/permission_forge/audit_attribution_helper.rb +77 -0
  44. data/app/helpers/permission_forge/audit_helper.rb +57 -119
  45. data/app/helpers/permission_forge/audit_records_helper.rb +103 -0
  46. data/app/helpers/permission_forge/audit_sentences_helper.rb +56 -2
  47. data/app/helpers/permission_forge/badges_helper.rb +74 -17
  48. data/app/helpers/permission_forge/changes_helper.rb +24 -6
  49. data/app/helpers/permission_forge/conditions_helper.rb +119 -0
  50. data/app/helpers/permission_forge/labels_helper.rb +134 -0
  51. data/app/helpers/permission_forge/people_helper.rb +11 -19
  52. data/app/helpers/permission_forge/rule_problems_helper.rb +73 -0
  53. data/app/helpers/permission_forge/sentences_helper.rb +119 -98
  54. data/app/helpers/permission_forge/settings_helper.rb +2 -2
  55. data/app/helpers/permission_forge/ui_helper.rb +65 -4
  56. data/app/models/permission_forge/audit_event.rb +5 -1
  57. data/app/models/permission_forge/permission_set.rb +208 -18
  58. data/app/models/permission_forge/permission_set_group.rb +10 -4
  59. data/app/models/permission_forge/permission_set_group_user.rb +3 -1
  60. data/app/models/permission_forge/permission_set_rule.rb +254 -17
  61. data/app/models/permission_forge/permission_set_user.rb +38 -3
  62. data/app/models/permission_forge/resource.rb +18 -8
  63. data/app/models/permission_forge/resource_action.rb +15 -6
  64. data/app/models/permission_forge/resource_field.rb +97 -11
  65. data/app/views/permission_forge/assignments/confirm_destroy.html.erb +4 -4
  66. data/app/views/permission_forge/assignments/new.html.erb +10 -6
  67. data/app/views/permission_forge/audit_events/index.html.erb +1 -1
  68. data/app/views/permission_forge/audit_events/show.html.erb +16 -9
  69. data/app/views/permission_forge/bulk_assignments/confirm_revoke.html.erb +3 -3
  70. data/app/views/permission_forge/bulk_assignments/new.html.erb +3 -3
  71. data/app/views/permission_forge/field_lookups/show.html.erb +2 -2
  72. data/app/views/permission_forge/home/_broken_rules_card.html.erb +35 -0
  73. data/app/views/permission_forge/home/_conditions_card.html.erb +28 -0
  74. data/app/views/permission_forge/home/_ending_card.html.erb +11 -5
  75. data/app/views/permission_forge/home/_expired_card.html.erb +1 -1
  76. data/app/views/permission_forge/home/_mask_strategies_card.html.erb +27 -0
  77. data/app/views/permission_forge/home/_privacy_card.html.erb +21 -7
  78. data/app/views/permission_forge/home/_rules_card.html.erb +1 -1
  79. data/app/views/permission_forge/home/_secret_rules_card.html.erb +33 -0
  80. data/app/views/permission_forge/home/_stale_card.html.erb +7 -5
  81. data/app/views/permission_forge/home/_tidy_card.html.erb +1 -1
  82. data/app/views/permission_forge/home/index.html.erb +5 -6
  83. data/app/views/permission_forge/people/_access_tab.html.erb +26 -19
  84. data/app/views/permission_forge/people/_audit_tab.html.erb +8 -8
  85. data/app/views/permission_forge/people/_effective_field_source.html.erb +13 -7
  86. data/app/views/permission_forge/people/_effective_tab.html.erb +16 -15
  87. data/app/views/permission_forge/people/_effective_why.html.erb +20 -16
  88. data/app/views/permission_forge/people/_why_item.html.erb +1 -1
  89. data/app/views/permission_forge/people/confirm_remove_all.html.erb +3 -3
  90. data/app/views/permission_forge/people/erasure_preview.html.erb +1 -1
  91. data/app/views/permission_forge/permission_set_groups/confirm_destroy.html.erb +3 -3
  92. data/app/views/permission_forge/permission_set_groups/index.html.erb +3 -3
  93. data/app/views/permission_forge/permission_set_groups/show.html.erb +28 -19
  94. data/app/views/permission_forge/permission_set_rules/_builder.html.erb +35 -15
  95. data/app/views/permission_forge/permission_set_rules/_preview.html.erb +17 -0
  96. data/app/views/permission_forge/permission_set_rules/confirm_destroy.html.erb +3 -3
  97. data/app/views/permission_forge/permission_set_rules/preview.html.erb +12 -0
  98. data/app/views/permission_forge/permission_sets/_assignments_tab.html.erb +37 -22
  99. data/app/views/permission_forge/permission_sets/_audit_tab.html.erb +4 -4
  100. data/app/views/permission_forge/permission_sets/_condition_problems.html.erb +15 -0
  101. data/app/views/permission_forge/permission_sets/_conditions_tab.html.erb +14 -8
  102. data/app/views/permission_forge/permission_sets/_header.html.erb +6 -5
  103. data/app/views/permission_forge/permission_sets/_records_summary.html.erb +2 -2
  104. data/app/views/permission_forge/permission_sets/_resources_tab.html.erb +4 -4
  105. data/app/views/permission_forge/permission_sets/confirm_destroy.html.erb +4 -4
  106. data/app/views/permission_forge/permission_sets/confirm_switch_mode.html.erb +3 -3
  107. data/app/views/permission_forge/permission_sets/index.html.erb +6 -6
  108. data/app/views/permission_forge/permission_sets/new.html.erb +2 -2
  109. data/app/views/permission_forge/permission_sets/review.html.erb +13 -3
  110. data/app/views/permission_forge/permission_sets/show.html.erb +7 -5
  111. data/app/views/permission_forge/resource_actions/_across_sets.html.erb +7 -10
  112. data/app/views/permission_forge/resource_actions/_effect.html.erb +5 -5
  113. data/app/views/permission_forge/resource_actions/_form.html.erb +1 -1
  114. data/app/views/permission_forge/resource_actions/_managed_note.html.erb +5 -3
  115. data/app/views/permission_forge/resource_actions/confirm_destroy.html.erb +1 -1
  116. data/app/views/permission_forge/resource_actions/new.html.erb +1 -1
  117. data/app/views/permission_forge/resource_actions/show.html.erb +2 -2
  118. data/app/views/permission_forge/resource_fields/_explain.html.erb +19 -15
  119. data/app/views/permission_forge/resource_fields/show.html.erb +21 -12
  120. data/app/views/permission_forge/resources/_actions_tab.html.erb +4 -4
  121. data/app/views/permission_forge/resources/_fields_tab.html.erb +17 -9
  122. data/app/views/permission_forge/resources/_rules_tab.html.erb +9 -6
  123. data/app/views/permission_forge/resources/_sync.html.erb +8 -5
  124. data/app/views/permission_forge/resources/confirm_destroy.html.erb +3 -3
  125. data/app/views/permission_forge/resources/new.html.erb +3 -2
  126. data/app/views/permission_forge/resources/show.html.erb +6 -6
  127. data/app/views/permission_forge/settings/_row.html.erb +2 -2
  128. data/app/views/permission_forge/settings/show.html.erb +10 -1
  129. data/app/views/permission_forge/shared/_across_sets.html.erb +8 -9
  130. data/app/views/permission_forge/shared/_audit_events.html.erb +2 -2
  131. data/app/views/permission_forge/shared/_audit_footer.html.erb +13 -2
  132. data/app/views/permission_forge/shared/_crud_cell.html.erb +6 -6
  133. data/app/views/permission_forge/shared/_mode_banners.html.erb +1 -1
  134. data/app/views/permission_forge/shared/_savebar.html.erb +4 -2
  135. data/config/locales/permission_forge.en.yml +480 -69
  136. data/config/locales/permission_forge.mk.yml +499 -79
  137. data/config/locales/permission_forge.sq.yml +509 -80
  138. data/config/routes.rb +38 -23
  139. data/lib/generators/permission_forge/install/install_generator.rb +21 -0
  140. data/lib/generators/permission_forge/install/templates/initializer.rb.tt +35 -34
  141. data/lib/generators/permission_forge/install/templates/migration.rb.tt +5 -5
  142. data/lib/permission_forge/activation_conditions.rb +200 -0
  143. data/lib/permission_forge/admin/action_label.rb +33 -0
  144. data/lib/permission_forge/admin/action_rows.rb +3 -1
  145. data/lib/permission_forge/admin/actor_label.rb +52 -0
  146. data/lib/permission_forge/admin/attention_items.rb +152 -31
  147. data/lib/permission_forge/admin/audit_log.rb +4 -7
  148. data/lib/permission_forge/admin/audit_trail.rb +33 -17
  149. data/lib/permission_forge/admin/bulk_people_matcher.rb +32 -14
  150. data/lib/permission_forge/admin/chain_entries.rb +42 -0
  151. data/lib/permission_forge/admin/change_set.rb +12 -1
  152. data/lib/permission_forge/admin/change_set_parts/base.rb +5 -4
  153. data/lib/permission_forge/admin/change_set_parts/context.rb +3 -1
  154. data/lib/permission_forge/admin/change_set_parts/field_edit.rb +4 -1
  155. data/lib/permission_forge/admin/change_set_parts/field_levels.rb +1 -1
  156. data/lib/permission_forge/admin/change_set_parts/new_actions.rb +1 -1
  157. data/lib/permission_forge/admin/change_set_parts/new_fields.rb +38 -11
  158. data/lib/permission_forge/admin/change_set_parts/resource_edit.rb +17 -1
  159. data/lib/permission_forge/admin/change_set_parts/resource_flags.rb +1 -1
  160. data/lib/permission_forge/admin/change_set_parts/set_attrs.rb +4 -0
  161. data/lib/permission_forge/admin/change_set_parts/values.rb +10 -4
  162. data/lib/permission_forge/admin/column_fields.rb +22 -21
  163. data/lib/permission_forge/admin/conditions_form.rb +47 -15
  164. data/lib/permission_forge/admin/controller_catalog.rb +42 -14
  165. data/lib/permission_forge/admin/editable_lock.rb +83 -3
  166. data/lib/permission_forge/admin/effective_access.rb +147 -48
  167. data/lib/permission_forge/admin/effective_fields.rb +75 -67
  168. data/lib/permission_forge/admin/error_sentence.rb +47 -0
  169. data/lib/permission_forge/admin/expiry_presets.rb +17 -0
  170. data/lib/permission_forge/admin/exports/holdings.rb +6 -3
  171. data/lib/permission_forge/admin/field_across_sets.rb +7 -4
  172. data/lib/permission_forge/admin/field_chain.rb +109 -0
  173. data/lib/permission_forge/admin/field_detail.rb +24 -11
  174. data/lib/permission_forge/admin/field_label.rb +109 -0
  175. data/lib/permission_forge/admin/field_level.rb +4 -68
  176. data/lib/permission_forge/admin/field_provenance.rb +132 -0
  177. data/lib/permission_forge/admin/field_rows.rb +26 -11
  178. data/lib/permission_forge/admin/last_changes.rb +14 -0
  179. data/lib/permission_forge/admin/list_sentence.rb +32 -0
  180. data/lib/permission_forge/admin/mask_strategy_choices.rb +57 -0
  181. data/lib/permission_forge/admin/model_catalog.rb +83 -39
  182. data/lib/permission_forge/admin/model_label.rb +66 -0
  183. data/lib/permission_forge/admin/people_candidates.rb +21 -9
  184. data/lib/permission_forge/admin/people_directory.rb +23 -5
  185. data/lib/permission_forge/admin/permission_set_factory.rb +6 -15
  186. data/lib/permission_forge/admin/person_label.rb +105 -16
  187. data/lib/permission_forge/admin/prefill.rb +92 -0
  188. data/lib/permission_forge/admin/record_name.rb +45 -0
  189. data/lib/permission_forge/admin/rule_builder.rb +77 -3
  190. data/lib/permission_forge/admin/rule_form.rb +44 -10
  191. data/lib/permission_forge/admin/rule_form_input.rb +27 -11
  192. data/lib/permission_forge/admin/rule_notes.rb +43 -0
  193. data/lib/permission_forge/admin/rule_preview.rb +96 -0
  194. data/lib/permission_forge/admin/sensitive_columns.rb +10 -62
  195. data/lib/permission_forge/admin/settings_catalog.rb +64 -12
  196. data/lib/permission_forge/admin/state_label.rb +97 -0
  197. data/lib/permission_forge/admin_method_override.rb +44 -0
  198. data/lib/permission_forge/api/schema.rb +322 -0
  199. data/lib/permission_forge/api/serializers/base_serializer.rb +87 -1
  200. data/lib/permission_forge/api/serializers/permission_set_group_user_serializer.rb +1 -1
  201. data/lib/permission_forge/api/serializers/permission_set_rule_serializer.rb +1 -1
  202. data/lib/permission_forge/api/serializers/permission_set_serializer.rb +5 -2
  203. data/lib/permission_forge/api/serializers/permission_set_user_serializer.rb +1 -1
  204. data/lib/permission_forge/api/serializers/resource_action_serializer.rb +1 -1
  205. data/lib/permission_forge/api/serializers/resource_field_serializer.rb +1 -1
  206. data/lib/permission_forge/api/serializers/resource_serializer.rb +1 -1
  207. data/lib/permission_forge/api_authorizations.rb +59 -0
  208. data/lib/permission_forge/api_only_admin.rb +81 -0
  209. data/lib/permission_forge/audit/actor_codes.rb +32 -0
  210. data/lib/permission_forge/audit/actor_resolver.rb +1 -2
  211. data/lib/permission_forge/audit/boot_warnings.rb +29 -9
  212. data/lib/permission_forge/audit/event_catalog.rb +16 -6
  213. data/lib/permission_forge/audit/event_query.rb +3 -3
  214. data/lib/permission_forge/audit/reason_codes.rb +25 -0
  215. data/lib/permission_forge/audit/runtime.rb +9 -2
  216. data/lib/permission_forge/audit/settings_observer.rb +2 -2
  217. data/lib/permission_forge/audit/unmasked_without_user.rb +169 -0
  218. data/lib/permission_forge/audit.rb +24 -10
  219. data/lib/permission_forge/boot_checks.rb +180 -63
  220. data/lib/permission_forge/cache/base.rb +10 -0
  221. data/lib/permission_forge/cache/null_cache.rb +5 -0
  222. data/lib/permission_forge/cache/outcome.rb +119 -0
  223. data/lib/permission_forge/cache/rails_cache.rb +6 -0
  224. data/lib/permission_forge/cache/report.rb +150 -0
  225. data/lib/permission_forge/cache/user_keys.rb +8 -4
  226. data/lib/permission_forge/cache_warnings.rb +140 -0
  227. data/lib/permission_forge/concerns/auto_permission_scoping.rb +19 -2
  228. data/lib/permission_forge/concerns/has_tenant_scoping.rb +1 -1
  229. data/lib/permission_forge/concerns/has_uuid.rb +1 -1
  230. data/lib/permission_forge/concerns/keeps_copied_values.rb +48 -0
  231. data/lib/permission_forge/concerns/masked_attributes/alias_access.rb +12 -2
  232. data/lib/permission_forge/concerns/masked_attributes/virtual_readers.rb +6 -4
  233. data/lib/permission_forge/concerns/masked_attributes.rb +150 -34
  234. data/lib/permission_forge/concerns/normalizes_label.rb +78 -0
  235. data/lib/permission_forge/concerns/permission_cache_accessor.rb +3 -3
  236. data/lib/permission_forge/concerns/permission_scoped_findable.rb +121 -29
  237. data/lib/permission_forge/concerns/permissioned_user.rb +1 -1
  238. data/lib/permission_forge/concerns/records_audit_events.rb +13 -6
  239. data/lib/permission_forge/concerns/requires_name.rb +37 -0
  240. data/lib/permission_forge/concerns/validates_column_lengths.rb +24 -0
  241. data/lib/permission_forge/concerns/validates_dependency.rb +5 -2
  242. data/lib/permission_forge/concerns/validates_required_columns.rb +74 -0
  243. data/lib/permission_forge/conditional_sets.rb +13 -6
  244. data/lib/permission_forge/configuration/admin_ui_settings.rb +18 -4
  245. data/lib/permission_forge/configuration/audit_settings.rb +2 -1
  246. data/lib/permission_forge/configuration/controller_action_settings.rb +5 -4
  247. data/lib/permission_forge/configuration/masked_attributes_settings.rb +73 -19
  248. data/lib/permission_forge/configuration/presets.rb +20 -11
  249. data/lib/permission_forge/configuration/query_scoping_settings.rb +55 -0
  250. data/lib/permission_forge/configuration/strategy_values.rb +2 -1
  251. data/lib/permission_forge/configuration.rb +41 -18
  252. data/lib/permission_forge/controller_actions/managed_index.rb +8 -7
  253. data/lib/permission_forge/database_reach.rb +93 -0
  254. data/lib/permission_forge/deprecation.rb +1 -1
  255. data/lib/permission_forge/docs/api_samples.rb +250 -0
  256. data/lib/permission_forge/docs/configuration_reference.rb +172 -0
  257. data/lib/permission_forge/encrypted_attributes.rb +150 -0
  258. data/lib/permission_forge/engine.rb +46 -14
  259. data/lib/permission_forge/execution_state/restore.rb +74 -0
  260. data/lib/permission_forge/execution_state.rb +8 -1
  261. data/lib/permission_forge/field_level.rb +123 -0
  262. data/lib/permission_forge/field_names.rb +145 -0
  263. data/lib/permission_forge/field_variants.rb +96 -0
  264. data/lib/permission_forge/generated_text.rb +95 -0
  265. data/lib/permission_forge/inspection_filters.rb +83 -0
  266. data/lib/permission_forge/managed_fields.rb +66 -0
  267. data/lib/permission_forge/masked_models.rb +25 -19
  268. data/lib/permission_forge/masking_sync.rb +100 -25
  269. data/lib/permission_forge/model_bindings.rb +30 -19
  270. data/lib/permission_forge/model_lookup.rb +42 -0
  271. data/lib/permission_forge/operators/encrypted_alias.rb +67 -0
  272. data/lib/permission_forge/operators/registry.rb +6 -0
  273. data/lib/permission_forge/operators/rule_column.rb +242 -27
  274. data/lib/permission_forge/operators/rule_path.rb +179 -0
  275. data/lib/permission_forge/operators/rule_problem.rb +309 -0
  276. data/lib/permission_forge/operators/rule_sources.rb +96 -0
  277. data/lib/permission_forge/operators/value_casters/postgresql_types.rb +237 -0
  278. data/lib/permission_forge/operators/value_casters/temporal.rb +9 -20
  279. data/lib/permission_forge/operators/value_casters.rb +61 -13
  280. data/lib/permission_forge/operators/value_typing.rb +83 -7
  281. data/lib/permission_forge/query_scoping_warnings.rb +22 -17
  282. data/lib/permission_forge/rake_audit.rb +5 -4
  283. data/lib/permission_forge/rake_dry_run.rb +32 -0
  284. data/lib/permission_forge/rake_output.rb +189 -0
  285. data/lib/permission_forge/record_read_access.rb +51 -0
  286. data/lib/permission_forge/resource_chain.rb +14 -2
  287. data/lib/permission_forge/schema_read/ordered_types.rb +72 -0
  288. data/lib/permission_forge/schema_read.rb +335 -0
  289. data/lib/permission_forge/secret_columns.rb +49 -0
  290. data/lib/permission_forge/sensitive_columns.rb +114 -0
  291. data/lib/permission_forge/services/attribute_access_guard.rb +22 -14
  292. data/lib/permission_forge/services/bulk_permission_set_assignment.rb +16 -8
  293. data/lib/permission_forge/services/concerns/clonable.rb +9 -0
  294. data/lib/permission_forge/services/concerns/settles_cache.rb +58 -0
  295. data/lib/permission_forge/services/consent_withdrawal_handler.rb +37 -19
  296. data/lib/permission_forge/services/data_subject_erasure.rb +83 -45
  297. data/lib/permission_forge/services/expired_assignment_cleaner.rb +62 -25
  298. data/lib/permission_forge/services/extra_field_defaults.rb +1 -1
  299. data/lib/permission_forge/services/field_permission_merger.rb +44 -17
  300. data/lib/permission_forge/services/filters_response_attributes.rb +20 -5
  301. data/lib/permission_forge/services/gdpr_field_auditor/text_report.rb +33 -5
  302. data/lib/permission_forge/services/gdpr_field_auditor.rb +68 -48
  303. data/lib/permission_forge/services/host_key_guard.rb +8 -5
  304. data/lib/permission_forge/services/mask_value.rb +72 -11
  305. data/lib/permission_forge/services/permission_cache_clearer.rb +36 -15
  306. data/lib/permission_forge/services/permission_hash_builder.rb +48 -19
  307. data/lib/permission_forge/services/permission_rule_applier.rb +131 -68
  308. data/lib/permission_forge/services/permission_set_cloner.rb +32 -8
  309. data/lib/permission_forge/services/permission_set_condition_evaluator.rb +38 -36
  310. data/lib/permission_forge/services/permission_set_group_cloner.rb +2 -1
  311. data/lib/permission_forge/services/permits_attributes/result.rb +31 -0
  312. data/lib/permission_forge/services/permits_attributes.rb +48 -27
  313. data/lib/permission_forge/services/resource_field_generator/row_builder.rb +96 -0
  314. data/lib/permission_forge/services/resource_field_generator.rb +71 -42
  315. data/lib/permission_forge/services/resource_with_fields_builder.rb +2 -1
  316. data/lib/permission_forge/services/rls_operator_sql.rb +83 -24
  317. data/lib/permission_forge/services/rls_policy_generator.rb +83 -39
  318. data/lib/permission_forge/services/rls_read_access_sql.rb +131 -0
  319. data/lib/permission_forge/services/security_audit/action_check.rb +49 -5
  320. data/lib/permission_forge/services/security_audit/assignment_check.rb +2 -1
  321. data/lib/permission_forge/services/security_audit/audit_trail_check.rb +1 -3
  322. data/lib/permission_forge/services/security_audit/conditions_check.rb +92 -0
  323. data/lib/permission_forge/services/security_audit/config_check.rb +41 -18
  324. data/lib/permission_forge/services/security_audit/integrity_check.rb +71 -4
  325. data/lib/permission_forge/services/security_audit/masking_without_user.rb +66 -0
  326. data/lib/permission_forge/services/security_audit/personal_data_check.rb +7 -11
  327. data/lib/permission_forge/services/security_audit/rule_check.rb +180 -50
  328. data/lib/permission_forge/services/security_audit/text_report.rb +14 -2
  329. data/lib/permission_forge/services/security_audit/unmanaged_actions.rb +145 -0
  330. data/lib/permission_forge/services/security_audit/user_model_check.rb +4 -3
  331. data/lib/permission_forge/services/security_audit.rb +16 -4
  332. data/lib/permission_forge/services/stale_assignment_auditor.rb +8 -5
  333. data/lib/permission_forge/timestamp_fields.rb +60 -0
  334. data/lib/permission_forge/unique_conflict.rb +117 -0
  335. data/lib/permission_forge/version.rb +1 -1
  336. data/lib/permission_forge/virtual_fields/discovery.rb +2 -1
  337. data/lib/permission_forge/virtual_fields.rb +15 -5
  338. data/lib/permission_forge/warnings.rb +263 -0
  339. data/lib/permission_forge/web_request.rb +248 -0
  340. data/lib/permission_forge.rb +54 -0
  341. data/lib/tasks/permission_forge.rake +26 -6
  342. data/lib/tasks/permission_forge_audit_events.rake +8 -4
  343. data/lib/tasks/permission_forge_cache.rake +12 -12
  344. data/lib/tasks/permission_forge_docs.rake +48 -0
  345. data/lib/tasks/permission_forge_expiration.rake +11 -5
  346. data/lib/tasks/permission_forge_gdpr.rake +36 -20
  347. metadata +84 -1
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/core_ext/module/delegation'
4
+
3
5
  module PermissionForge
4
6
  module Operators
5
7
  # The model column a rule's field_name names, as far as it can be told:
@@ -16,58 +18,212 @@ module PermissionForge
16
18
  # text after all. A lookup that is more generous than the database costs
17
19
  # nothing: the database then rejects the name itself.
18
20
  #
19
- # When the table is none of the rule's, or has no such column, nothing is
20
- # known and the value is left as it is. A field_name that is not a name
21
- # ({.name?}) is {#malformed?}: such a rule matches nothing.
21
+ # When the table is none of the rule's, what the name is depends on the
22
+ # names the rule's query gives its tables ({RuleSources}: the FROM and
23
+ # JOINs, with the aliases ActiveRecord assigns — never a name that merely
24
+ # appears in a join condition): a name the join gives that is no model
25
+ # table (`parents_customers` on a self-join) is {UNKNOWN} and left to the
26
+ # database — unless the column it names holds ciphertext
27
+ # ({EncryptedAlias}: the name is resolved to the table it stands for and
28
+ # every loaded model on that table — a `has_many :through`'s join table
29
+ # and a table a default scope joins too; one that cannot be resolved
30
+ # counts as encrypted when any model along the path encrypts a column of
31
+ # that name), when it is that model's column, {#encrypted?}; a real table
32
+ # that is none of the rule's is {FOREIGN} (a filter
33
+ # rule compares its model's own table: it matches nothing; a join rule's
34
+ # query has to join that table itself); anything else is {#unresolvable?}
35
+ # ({UNRESOLVABLE}) and the rule matches nothing — the database would
36
+ # reject the query (`no such column: supplier_id.id`), and every holder
37
+ # would get an error instead of a denial. A table of the rule's that has
38
+ # no such column is {#missing?}: the database would reject the query, so
39
+ # a built-in operator matches nothing for it (ValueTyping.no_column?). A
40
+ # field_name that is not a name ({.name?}) is {#malformed?}: such a rule
41
+ # matches nothing. A column of an attribute declared with `encrypts`
42
+ # holds ciphertext ({#encrypted?}): no built-in operator that takes a
43
+ # value can compare it (ValueTyping).
22
44
  class RuleColumn
23
45
  # Adapters whose database compares a quoted name as it is written. On
24
46
  # every other one — MySQL, SQLite — `Flag` is the column `flag`.
25
47
  EXACT_NAMES = %w[PostgreSQL PostGIS].freeze
26
48
 
49
+ # What makes a rule's column or path "cannot tell" for a caller that
50
+ # only describes it (for_rule, RuleProblem.of_path — the admin, the
51
+ # audit, the model's checks): anything raised while the models are
52
+ # loaded or asked, a model file's SyntaxError or LoadError included.
53
+ # The applier's own walk does not rescue them (RulePath).
54
+ UNTELLABLE = [StandardError, ScriptError].freeze
55
+ # A character no name has (see .name?).
56
+ CONTROL_CHARACTER = /[[:cntrl:]]/
57
+
27
58
  # @return [ActiveRecord::ConnectionAdapters::Column, nil]
28
59
  attr_reader :column
29
60
  # @return [Hash, nil] label => stored value, when the model declares the column an enum
30
61
  attr_reader :enum
62
+ # @return [Class, nil] the model whose table the name was looked up in; nil when no table was
63
+ attr_reader :model
31
64
 
32
65
  class << self
33
66
  # A field name is one name, or several joined by dots. An empty one
34
67
  # among them — a leading, trailing or doubled dot, or nothing at all —
35
68
  # makes it no name: quoted segment by segment it would be another
36
- # name than the one that was written.
69
+ # name than the one that was written. So does a control character (a
70
+ # newline, a NUL byte) in one: no column is called that, and quoted
71
+ # into a statement it reaches the database as written.
37
72
  def name?(field_name)
38
73
  segments = field_name.to_s.split('.', -1)
39
- segments.any? && segments.none?(&:empty?)
74
+ segments.any? && segments.none? { |segment| segment.empty? || segment.match?(CONTROL_CHARACTER) }
40
75
  end
41
76
 
42
77
  # @param field_name [String] the rule's field_name
43
78
  # @param models [Array<Class>] the rule's model, then the model of each
44
79
  # association on its path (see {.models_along})
80
+ # @param joined [Hash{String => String, nil}, Array<String>, nil] the
81
+ # names a join rule's query gives its tables, with the table each
82
+ # stands for ({RuleSources.tables}; a list of names: tables not
83
+ # known); nil for a filter rule, whose query joins nothing of its own
45
84
  # @return [RuleColumn]
46
- def find(field_name, models)
85
+ def find(field_name, models, joined: nil)
47
86
  return MALFORMED unless name?(field_name)
87
+ return UNKNOWN if models.empty?
48
88
 
49
89
  *qualifier, name = field_name.to_s.split('.')
50
- model = table_model(qualifier.last, models)
51
- model ? on(model, name) : UNKNOWN
90
+ table = qualifier.last
91
+ model = table_model(table, models) || EncryptedAlias.new(models, joined).model(table, name)
92
+ model ? on(model, name) : elsewhere(table, models.last, joined)
93
+ end
94
+
95
+ # Whether the connection has a data source (a table or view) named
96
+ # `table`: asked only for a qualifier that is none of the rule's
97
+ # tables. A "no" is asked of the database again once
98
+ # (PermissionForge::SchemaRead.table?): the schema cache may predate
99
+ # a table a migration just created.
100
+ #
101
+ # @param model [Class] a model on the connection
102
+ # @param table [String] the name as written
103
+ def data_source?(model, table)
104
+ PermissionForge::SchemaRead.table?(model, table)
105
+ end
106
+
107
+ # The columns a rule may compare on a model: every column of its
108
+ # table as the database knows them (a model's `ignored_columns`
109
+ # among them, as in .find) — the names the admin's rule builder
110
+ # offers and the model's validation accepts, one list.
111
+ #
112
+ # The list is compared with the database first
113
+ # (PermissionForge::SchemaRead, `verify: true`): a column a migration
114
+ # added since the schema was read is offered.
115
+ #
116
+ # @param model [Class] a model with a table
117
+ # @param verify [Boolean] false when the caller compared the columns already
118
+ # @return [Array<String>]
119
+ def names_for(model, verify: true)
120
+ PermissionForge::SchemaRead.columns(model, verify: verify).keys
121
+ end
122
+
123
+ # The column a rule row names, looked up the way PermissionRuleApplier
124
+ # looks it up: in the model its Resource is named after and along its
125
+ # association path (join rules only). nil when that cannot be told —
126
+ # the Resource names no model, the model cannot be loaded (its file
127
+ # raises anything as it loads: UNTELLABLE), the path cannot be
128
+ # followed, a table along it does not exist (asking a missing table
129
+ # for its columns fails the statement, and with it a transaction) or
130
+ # the database cannot be asked.
131
+ #
132
+ # `verify: true` (a rule being saved) compares the columns of every
133
+ # model along the path with the database first (one metadata query
134
+ # each, PermissionForge::SchemaRead): a name the process confirmed
135
+ # missing before a migration added it is accepted at save. The
136
+ # describing callers (the admin's pages, the audit) ask without it.
137
+ #
138
+ # @param rule [PermissionForge::PermissionSetRule]
139
+ # @param verify [Boolean]
140
+ # @return [RuleColumn, nil]
141
+ def for_rule(rule, verify: false)
142
+ model = rule_model(rule)
143
+ return unless model
144
+
145
+ chain = rule.join_rule? ? path_segments(rule.field_accessor_path) : []
146
+ models = models_along(model, chain)
147
+ find(rule.field_name, models, joined: rule_join_names(rule, model, chain)) if readable?(models, verify)
148
+ rescue *UNTELLABLE
149
+ nil
52
150
  end
53
151
 
54
152
  # The rule's own model, then the model of each association on its path;
55
- # a plain field_name is a column of the last one.
153
+ # a plain field_name is a column of the last one. The path is
154
+ # followed by {RulePath}, the way ActiveRecord joins it.
56
155
  #
57
156
  # @param model [Class] the rule's model
58
157
  # @param association_chain [Array<String>] the rule's field_accessor_path, split
59
- # @return [Array<Class>] empty when the path cannot be followed
158
+ # @return [Array<Class>] empty when the path cannot be followed (RulePath#broken?)
60
159
  def models_along(model, association_chain)
61
- association_chain.reduce([model]) do |models, assoc|
62
- joined = models.last.reflect_on_association(assoc.to_sym)&.klass
63
- break [] unless joined
160
+ RulePath.follow(model, association_chain).models
161
+ end
162
+
163
+ # A rule's association path (its field_accessor_path), split into
164
+ # the association names the applier joins; a blank one is no path.
165
+ #
166
+ # @param path [String, nil]
167
+ # @return [Array<String>]
168
+ def path_segments(path)
169
+ path.blank? ? [] : path.to_s.split('.')
170
+ end
171
+
172
+ # The model the rule's Resource is named after, when it is one with a
173
+ # table (PermissionForge::ModelLookup, the one definition).
174
+ #
175
+ # @param rule [PermissionForge::PermissionSetRule]
176
+ # @return [Class, nil]
177
+ def rule_model(rule)
178
+ PermissionForge::ModelLookup.model_for(rule.resource&.name)
179
+ end
64
180
 
65
- models << joined
66
- end
181
+ # Whether `written` names `own` the way the database compares names
182
+ # (exactly where it compares a quoted name as written: EXACT_NAMES).
183
+ #
184
+ # @param own [String] the name as the schema or the model has it
185
+ # @param written [String] the name as the rule writes it
186
+ # @param exact [Boolean] see #exact_names?
187
+ def same_name?(own, written, exact)
188
+ own == written || (!exact && own.casecmp?(written))
189
+ end
190
+
191
+ # Whether the model's database compares a quoted name as written.
192
+ #
193
+ # @param model [Class]
194
+ def exact_names?(model)
195
+ EXACT_NAMES.include?(adapter_name(model))
67
196
  end
68
197
 
69
198
  private
70
199
 
200
+ # A qualifier that is none of the rule's models: a name the query
201
+ # gives a table (an alias: `joined` is a Hash of name => table, or a
202
+ # list of names), a real table, or nothing at all.
203
+ def elsewhere(table, model, joined)
204
+ return UNKNOWN if joined&.any? { |source, _table| same_name?(source, table, exact_names?(model)) }
205
+
206
+ data_source?(model, table) ? FOREIGN : UNRESOLVABLE
207
+ end
208
+
209
+ # nil for a filter rule (no join of its own); a join rule's join
210
+ # names, read only for a qualified field_name.
211
+ def rule_join_names(rule, model, chain)
212
+ return unless rule.join_rule?
213
+
214
+ rule.field_name.to_s.include?('.') ? RuleSources.of_join(model, rule.join_type, chain) : {}
215
+ end
216
+
217
+ # Every table along the path is there (asking a missing one for its
218
+ # columns fails the statement); compared with the database first when
219
+ # `verify`.
220
+ def readable?(models, verify)
221
+ return false unless models.any? && models.all? { |model| PermissionForge::SchemaRead.table?(model, model.table_name) }
222
+
223
+ models.each { |model| PermissionForge::SchemaRead.columns(model, verify: true) } if verify
224
+ true
225
+ end
226
+
71
227
  # The model a table name stands for; without one, the model the rule ends on.
72
228
  def table_model(table, models)
73
229
  return models.last unless table
@@ -75,45 +231,104 @@ module PermissionForge
75
231
  models.find { |model| same_name?(model.table_name.to_s.split('.').last, table, exact_names?(model)) }
76
232
  end
77
233
 
234
+ # The table's column of that name; a name the schema cache does not
235
+ # have is looked up again once after the schema is read again
236
+ # (PermissionForge::SchemaRead): a column a migration just added.
78
237
  def on(model, name)
79
- connection = model.connection
80
- column = column_named(connection.schema_cache.columns_hash(model.table_name), name, exact_names?(model))
81
- new(column, column && model.defined_enums[column.name], connection.adapter_name)
238
+ exact = exact_names?(model)
239
+ column = PermissionForge::SchemaRead.column(model, name) { |columns| column_named(columns, name, exact) }
240
+ new(column, column && model.defined_enums[column.name], adapter_name(model), model)
82
241
  end
83
242
 
84
243
  def column_named(columns, name, exact)
85
244
  columns[name] || columns.each_value.find { |column| same_name?(column.name, name, exact) }
86
245
  end
87
246
 
88
- def same_name?(own, written, exact)
89
- own == written || (!exact && own.casecmp?(written))
90
- end
91
-
92
- def exact_names?(model)
93
- EXACT_NAMES.include?(model.connection.adapter_name)
247
+ # Through the pool, never `model.connection` (it raises under
248
+ # ActiveRecord.permanent_connection_checkout = :disallowed).
249
+ def adapter_name(model)
250
+ model.connection_pool.with_connection(&:adapter_name)
94
251
  end
95
252
  end
96
253
 
97
- def initialize(column, enum, adapter_name)
254
+ def initialize(column, enum, adapter_name, model = nil)
98
255
  @column = column
99
256
  @enum = enum
100
257
  @adapter_name = adapter_name
258
+ @model = model
101
259
  end
102
260
 
103
- # Nothing is known about the column: no table of the rule, or no such column.
261
+ # Nothing is known about the column: the name's table is none of the
262
+ # rule's models but a name the rule's query gives a table (an alias a
263
+ # join gives), or the path cannot be followed.
104
264
  UNKNOWN = new(nil, nil, nil).freeze
265
+ # The name's table is a real table that is none of the rule's (not its
266
+ # model's, not one along its path or its query's joins): a filter rule
267
+ # matches nothing (RuleProblem :foreign_table); a join rule's query has
268
+ # to join it itself, or it matches nothing too.
269
+ FOREIGN = new(nil, nil, nil).freeze
105
270
  # The rule's field_name is not a name.
106
271
  MALFORMED = new(nil, nil, nil).freeze
272
+ # The name's table is none of the rule's and no table of the database
273
+ # (a rule that joins nothing): the database would reject the query.
274
+ UNRESOLVABLE = new(nil, nil, nil).freeze
107
275
 
108
276
  def malformed?
109
277
  equal?(MALFORMED)
110
278
  end
111
279
 
280
+ # The name is qualified with a real table that is none of the rule's (see FOREIGN).
281
+ def foreign?
282
+ equal?(FOREIGN)
283
+ end
284
+
285
+ # The name is qualified with a table that is not there (see UNRESOLVABLE):
286
+ # such a rule matches nothing.
287
+ def unresolvable?
288
+ equal?(UNRESOLVABLE)
289
+ end
290
+
291
+ # The name's table is one of the rule's ({#model}), and it has no such
292
+ # column: the database would reject a query that compares it.
293
+ def missing?
294
+ !model.nil? && column.nil?
295
+ end
296
+
297
+ # Whether the column holds ciphertext: an attribute of {#model} — the
298
+ # class the rule is applied to (the applier's model class), one along
299
+ # its path, or one on a table its query joins (EncryptedAlias) —
300
+ # declared with `encrypts` by it or a class of its hierarchy on its
301
+ # table (PermissionForge::EncryptedAttributes). Asked with the column's
302
+ # own name (a field name written in another case resolves to it).
303
+ def encrypted?
304
+ return false if model.nil? || column.nil?
305
+
306
+ PermissionForge::EncryptedAttributes.encrypted?(model, column.name)
307
+ end
308
+
309
+ class << self
310
+ # @!method encrypted?(model, name)
311
+ # Whether `name` is a column of `model` that holds ciphertext (an
312
+ # attribute declared with `encrypts`; PermissionForge::EncryptedAttributes,
313
+ # the one definition): a rule can only ask whether it is empty. What
314
+ # the admin's rule builder asks before it offers a column.
315
+ # @param model [Class, nil] the model whose table the column is in
316
+ # @param name [String, Symbol] the column's name, as the model has it
317
+ # @return [Boolean]
318
+ delegate :encrypted?, to: PermissionForge::EncryptedAttributes
319
+ end
320
+
112
321
  # The caster for the column's type, on the database it is in.
113
322
  #
114
323
  # @return [#call] see ValueCasters.for
115
324
  def caster
116
- ValueCasters.for(column, @adapter_name)
325
+ ValueCasters.for(column, @adapter_name, model)
326
+ end
327
+
328
+ # Whether a LIKE pattern can be matched against the column, on the
329
+ # database it is in (ValueCasters.pattern?).
330
+ def pattern?
331
+ ValueCasters.pattern?(caster, @adapter_name)
117
332
  end
118
333
  end
119
334
  end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Operators
5
+ # A join rule's association path (its field_accessor_path, split on
6
+ # dots), followed from the rule's model the way ActiveRecord follows it
7
+ # when it builds the join: the one walk of a path (the applier, the
8
+ # column lookup, the model's validation and the audit all go through it).
9
+ #
10
+ # ActiveRecord raises for a join it cannot build — when the query runs,
11
+ # so in every find_by / permission_scoped of the rule's holders. Before
12
+ # it joins a name (Associations::JoinDependency#build) it looks the
13
+ # association up by that name and asks the reflection check_validity!
14
+ # and check_eager_loadable!; a polymorphic one raises, and the class of
15
+ # every other one is loaded. A segment that fails any of those is where
16
+ # the path {#broken?}:
17
+ #
18
+ # - :unknown_association — the model has no association of that name
19
+ # (a typo: `ownr`), or the segment is empty (`parent..owner`; cause
20
+ # :empty_segment);
21
+ # - :unjoinable_association — the association is there, but no join can
22
+ # be built through it; {#cause} says why (UNJOINABLE_CAUSES).
23
+ #
24
+ # Everything ActiveRecord joins — has_many / has_one / belongs_to,
25
+ # :through, has_and_belongs_to_many, a scoped association — is followed.
26
+ # A rule whose path is broken matches nothing (PermissionRuleApplier):
27
+ # the join would raise.
28
+ #
29
+ # A model file that raises a ScriptError (SyntaxError, LoadError) while
30
+ # it loads is not a broken path: the error propagates, from the applier
31
+ # too (the query raises; it never answers). An admin-facing caller that
32
+ # only describes the path (RuleProblem.of_path, RuleColumn.for_rule)
33
+ # treats it as "cannot tell".
34
+ #
35
+ # @example
36
+ # path = RulePath.follow(Customer, %w[supplier owner])
37
+ # path.models # => [Customer, Supplier, User]
38
+ # RulePath.follow(Customer, %w[ownr]).reason # => :unknown_association
39
+ class RulePath
40
+ # Why an association is there but cannot be joined (#cause of an
41
+ # :unjoinable_association path), in the order they are checked:
42
+ #
43
+ # - :invalid_definition — its :through, :source or inverse_of names an
44
+ # association that is not there (Reflection#check_validity! raises);
45
+ # - :polymorphic — a polymorphic belongs_to: no one class to join;
46
+ # - :record_dependent_scope — its scope, or one along its :through
47
+ # (the through association's or the source's), takes the record
48
+ # (`->(record) { … }`): a join has no record to give it;
49
+ # - :class_not_loadable — its class (or one along its :through) cannot
50
+ # be loaded: no such constant, or its file raised while it loaded.
51
+ UNJOINABLE_CAUSES = %i[invalid_definition polymorphic record_dependent_scope class_not_loadable].freeze
52
+
53
+ # @return [Array<Class>] the rule's model, then the model of each
54
+ # association on the path; empty when the path is broken
55
+ attr_reader :models
56
+ # @return [String, nil] the segment the path breaks at
57
+ attr_reader :segment
58
+ # @return [Class, nil] the model that segment was looked up in
59
+ attr_reader :owner
60
+ # @return [Symbol, nil] :unknown_association or :unjoinable_association
61
+ attr_reader :reason
62
+ # @return [Symbol, nil] :empty_segment for an empty segment, one of
63
+ # UNJOINABLE_CAUSES for an :unjoinable_association, else nil
64
+ attr_reader :cause
65
+
66
+ # @param model [Class] the rule's model
67
+ # @param association_chain [Array<String>] the rule's field_accessor_path, split
68
+ # @return [RulePath]
69
+ def self.follow(model, association_chain)
70
+ association_chain.reduce(new([model])) do |path, name|
71
+ path.through(name.to_s)
72
+ end
73
+ end
74
+
75
+ def initialize(models, segment: nil, owner: nil, reason: nil, cause: nil)
76
+ @models = models
77
+ @segment = segment
78
+ @owner = owner
79
+ @reason = reason
80
+ @cause = cause
81
+ end
82
+
83
+ def broken?
84
+ !@reason.nil?
85
+ end
86
+
87
+ # The path one association further; a broken path stays broken where it broke.
88
+ #
89
+ # @param name [String] the next segment
90
+ # @return [RulePath]
91
+ def through(name)
92
+ return self if broken?
93
+
94
+ owner = models.last
95
+ at = { segment: name, owner: owner }
96
+ return RulePath.new([], **at, reason: :unknown_association, cause: :empty_segment) if name.empty?
97
+
98
+ reflection = owner._reflect_on_association(name)
99
+ return RulePath.new([], **at, reason: :unknown_association) unless reflection
100
+
101
+ cause = RulePath.unjoinable_cause(reflection)
102
+ return RulePath.new([], **at, reason: :unjoinable_association, cause: cause) if cause
103
+
104
+ RulePath.new(models + [reflection.klass])
105
+ end
106
+
107
+ class << self
108
+ # The relation with the path joined, the way every rule's join is
109
+ # made (the applier's query, RuleColumn.join_names): one association
110
+ # by name, a longer path as the nested Hash ActiveRecord joins.
111
+ #
112
+ # @param relation [ActiveRecord::Relation]
113
+ # @param join_method [Symbol] one of PermissionRuleApplier::JOIN_METHODS — never a rule's own text
114
+ # @param association_chain [Array<String>] the rule's path, split
115
+ # @return [ActiveRecord::Relation]
116
+ def join(relation, join_method, association_chain)
117
+ return relation if association_chain.empty?
118
+
119
+ relation.public_send(join_method, join_structure(association_chain))
120
+ end
121
+
122
+ # Where ActiveRecord would raise building a join through the
123
+ # reflection, why (UNJOINABLE_CAUSES); nil when it builds one. A raise
124
+ # of any kind counts: it would be raised in the holders' queries.
125
+ # check_validity! and check_eager_loadable! are what JoinDependency
126
+ # asks of the reflection itself; a :through also evaluates the scopes
127
+ # along its chain (record_dependent?).
128
+ #
129
+ # @param reflection [ActiveRecord::Reflection::AbstractReflection]
130
+ # @return [Symbol, nil] one of UNJOINABLE_CAUSES
131
+ def unjoinable_cause(reflection)
132
+ failure(:invalid_definition) { reflection.check_validity! } ||
133
+ (:polymorphic if reflection.polymorphic?) ||
134
+ failure(:record_dependent_scope) { reflection.check_eager_loadable! } ||
135
+ failure(:class_not_loadable) { reflection.klass } ||
136
+ (:record_dependent_scope if record_dependent?(reflection))
137
+ end
138
+
139
+ private
140
+
141
+ # `supplier` => :supplier; `supplier.owner` => { supplier: :owner }.
142
+ def join_structure(association_chain)
143
+ *outer, last = association_chain.map(&:to_sym)
144
+ outer.reverse.reduce(last) { |inner, name| { name => inner } }
145
+ end
146
+
147
+ # nil when the block raises nothing (what it answers is not looked
148
+ # at). When it raises: `cause` for what a definition or a check raises
149
+ # (an ActiveRecordError, ArgumentError or NoMethodError), else
150
+ # :class_not_loadable — a constant that is not there (NameError), or
151
+ # whatever a model file raised as it loaded.
152
+ def failure(cause)
153
+ yield
154
+ nil
155
+ rescue NoMethodError, ArgumentError, ActiveRecord::ActiveRecordError
156
+ cause
157
+ rescue StandardError
158
+ :class_not_loadable
159
+ end
160
+
161
+ # Whether a scope along the reflection's join chain takes the record:
162
+ # the reflection's own, the :through association's (and so on along
163
+ # the chain), and the source's of each :through. A chain that cannot
164
+ # be walked counts: the join would raise walking it.
165
+ def record_dependent?(reflection)
166
+ reflection.chain.any? do |link|
167
+ takes_record?(link.scope) || (link.through_reflection? && record_dependent?(link.source_reflection))
168
+ end
169
+ rescue StandardError
170
+ true
171
+ end
172
+
173
+ def takes_record?(scope)
174
+ !scope.nil? && !scope.arity.zero?
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end