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
@@ -8,6 +8,14 @@ module PermissionForge
8
8
  # They do NOT constitute GDPR-compliant anonymization or pseudonymization.
9
9
  # See docs/gdpr_masking_disclaimer.md for details.
10
10
  #
11
+ # A strategy name this app does not know — a field row written past the
12
+ # model, a custom strategy since removed from `config.custom_mask_strategies`,
13
+ # a blank — never raises on a read path: {.call} masks fully
14
+ # ({FALLBACK_STRATEGY}) and reports the name once per process (a warning
15
+ # and the {UNKNOWN_STRATEGY_EVENT} notification); `rake permission_forge:audit`
16
+ # lists the rows (`unknown_mask_strategies`). {.call!} is the strict form
17
+ # for the admin preview and the validation.
18
+ #
11
19
  # @api public
12
20
  class MaskValue
13
21
  BUILT_IN_STRATEGIES = {
@@ -19,42 +27,95 @@ module PermissionForge
19
27
 
20
28
  STRATEGIES = BUILT_IN_STRATEGIES.keys.freeze
21
29
  MASK_CHAR = '*'.freeze
30
+ # The strategy applied in place of one this app does not know: the strictest built-in.
31
+ FALLBACK_STRATEGY = 'full'.freeze
32
+ # Published once per process for each strategy name this app does not know
33
+ # (payload: `strategy`, the name as stored; `fallback`, what was applied).
34
+ UNKNOWN_STRATEGY_EVENT = 'mask_strategy_unknown.permission_forge'.freeze
35
+
36
+ REPORTED_MUTEX = Mutex.new
37
+ @reported = Set.new
22
38
 
23
39
  # @return [Hash{String => #call}] the built-in strategies merged with `config.custom_mask_strategies`
24
40
  def self.strategies
25
41
  custom = PermissionForge.configuration.custom_mask_strategies
26
42
  return BUILT_IN_STRATEGIES if custom.blank?
27
43
 
28
- BUILT_IN_STRATEGIES.merge(custom)
44
+ BUILT_IN_STRATEGIES.merge(custom.transform_keys(&:to_s))
29
45
  end
30
46
 
31
- # Masks a value with a built-in or custom strategy.
47
+ # Whether `strategy` names a built-in or configured custom strategy.
48
+ # @param strategy [String, Symbol, nil]
49
+ # @return [Boolean]
50
+ def self.known?(strategy)
51
+ !strategy.nil? && strategies.key?(strategy.to_s)
52
+ end
53
+
54
+ # Masks a value with a built-in or custom strategy. A strategy this app
55
+ # does not know masks fully and is reported once per process per name:
56
+ # a read never raises.
32
57
  # @param value [Object]
33
58
  # @param strategy [String] `full`, `email`, `last4`, `first1_last4` or a custom one
34
59
  # @return [Object] the masked value (nil stays nil)
35
- def self.call(value, strategy: 'full')
60
+ def self.call(value, strategy: FALLBACK_STRATEGY)
36
61
  return value if value.nil?
37
62
 
38
63
  str = value.to_s
39
64
  return str if str.empty?
40
65
 
41
- new(str, strategy).call
66
+ new(str, resolve(strategy)).call
67
+ end
68
+
69
+ # Masks a value, strictly: a strategy this app does not know raises (the
70
+ # admin preview, the validation — never a read path).
71
+ # @param value [Object]
72
+ # @param strategy [String]
73
+ # @return [Object] the masked value (nil stays nil)
74
+ # @raise [ArgumentError] for a strategy this app does not know
75
+ def self.call!(value, strategy: FALLBACK_STRATEGY)
76
+ raise ArgumentError, unknown_message(strategy) unless known?(strategy)
77
+
78
+ call(value, strategy: strategy)
79
+ end
80
+
81
+ # Forgets which unknown strategies were reported (for tests).
82
+ # @return [void]
83
+ def self.reset_reports!
84
+ REPORTED_MUTEX.synchronize { @reported.clear }
85
+ end
86
+
87
+ # The strategy to apply: the name when this app knows it, else the
88
+ # fallback, the name reported once.
89
+ def self.resolve(strategy)
90
+ return strategy.to_s if known?(strategy)
91
+
92
+ report_unknown(strategy.to_s)
93
+ FALLBACK_STRATEGY
94
+ end
95
+
96
+ def self.report_unknown(name)
97
+ return unless REPORTED_MUTEX.synchronize { @reported.add?(name) }
98
+
99
+ Rails.logger&.warn("[PermissionForge] #{unknown_message(name)} Values are masked fully until the field row or " \
100
+ 'config.custom_mask_strategies is fixed (rake permission_forge:audit lists the rows).')
101
+ ActiveSupport::Notifications.instrument(UNKNOWN_STRATEGY_EVENT, strategy: name, fallback: FALLBACK_STRATEGY)
42
102
  end
43
103
 
104
+ def self.unknown_message(strategy)
105
+ "Unknown mask strategy: #{strategy.inspect}. Valid strategies: #{strategies.keys.join(', ')}."
106
+ end
107
+ private_class_method :resolve, :report_unknown, :unknown_message
108
+
109
+ # @param value [String] a non-empty String
110
+ # @param strategy [String] a known strategy (see .resolve)
44
111
  def initialize(value, strategy)
45
112
  @value = value
46
113
  @strategy = strategy
47
- all_strategies = self.class.strategies
48
- return if all_strategies.key?(@strategy)
49
-
50
- raise ArgumentError,
51
- "Unknown mask strategy: #{strategy.inspect}. Valid strategies: #{all_strategies.keys.join(', ')}"
52
114
  end
53
115
 
54
116
  # @return [Object] the masked value
55
117
  def call
56
- all_strategies = self.class.strategies
57
- strategy_ref = all_strategies[@strategy]
118
+ strategy_ref = self.class.strategies[@strategy]
58
119
 
59
120
  if strategy_ref.is_a?(Symbol)
60
121
  send(strategy_ref)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../cache/report'
4
+
3
5
  module PermissionForge
4
6
  module Services
5
7
  # Removes every cached permission hash — this version's entries, the
@@ -34,17 +36,22 @@ module PermissionForge
34
36
  #
35
37
  # @example
36
38
  # result = PermissionCacheClearer.call(actor: 'ops: TCK-12', reason: 'deploy 0.14')
37
- # # => { strategy: :enumerated, users: 240, tenants: 2, unconfirmed: 0 }
38
- # # => { strategy: :pattern, users: nil, tenants: 2, unconfirmed: 0 }
39
+ # # => { strategy: :enumerated, users: 240, tenants: 2, unconfirmed: 0,
40
+ # # cache: { confirmed: true, process_local: false, unconfirmed: 0 } }
41
+ # # => { strategy: :pattern, users: nil, tenants: 2, unconfirmed: 0, cache: { ... } }
39
42
  #
40
43
  # `users` counts visits (a user once per tenant); `unconfirmed` the ones
41
44
  # the cache store did not confirm (each also reported through
42
- # `config.on_cache_failure`, and owed). The audit log: one `cache.cleared`
43
- # event per tenant, in that tenant with that tenant's counts, plus one
44
- # without a tenant with the totals (`across_tenants`); under :pattern
45
- # the counts are not known and the events say `counted: false`.
46
- # `actor:` and `reason:` as the other housekeeping services (rake:
47
- # ACTOR=, REASON=).
45
+ # `config.on_cache_failure`, and owed); `cache` is the one Cache::Outcome
46
+ # every cache-touching operation answers (Cache::Report). The audit log:
47
+ # one `cache.cleared` event per tenant, in that tenant with that tenant's
48
+ # counts, plus one without a tenant with the totals (`across_tenants`);
49
+ # under :pattern the counts are not known and the events say
50
+ # `counted: false`. Every event carries the outcome's keys, and says
51
+ # `process_local: true` when the store lives in this process only (a
52
+ # memory store, Cache::Base#process_local?): the clear then did not reach
53
+ # the web and job processes, and the audit log says so. `actor:` and
54
+ # `reason:` as the other housekeeping services (rake: ACTOR=, REASON=).
48
55
  #
49
56
  # @api public
50
57
  class PermissionCacheClearer
@@ -57,7 +64,7 @@ module PermissionForge
57
64
  #
58
65
  # @param actor [Object, nil] who runs it, for the cache.cleared events
59
66
  # @param reason [String, nil] why
60
- # @return [Hash] strategy:, users:, tenants:, unconfirmed:
67
+ # @return [Hash] strategy:, users:, tenants:, unconfirmed:, cache:
61
68
  def self.call(...) = new(...).call
62
69
 
63
70
  def initialize(actor: nil, reason: nil)
@@ -65,18 +72,21 @@ module PermissionForge
65
72
  @reason = reason
66
73
  end
67
74
 
68
- # @return [Hash] strategy:, users:, tenants:, unconfirmed:
75
+ # @return [Hash] strategy:, users:, tenants:, unconfirmed:, cache:
69
76
  def call
70
77
  audited(actor: @actor, reason: @reason) { clear }
71
78
  end
72
79
 
73
80
  private
74
81
 
82
+ # The whole clear is one measured operation (Cache::Report); the
83
+ # unconfirmed count is this service's own (by slice).
75
84
  def clear
76
- result = clear_by_pattern || clear_by_enumeration
85
+ @report = Cache::Report.new
86
+ result = @report.measure { clear_by_pattern || clear_by_enumeration }.value
77
87
  # retire every memo in this process, as an invalidation does
78
88
  PermissionForge.bump_permissions_cache_version!
79
- result
89
+ result.merge(cache: @report.outcome(unconfirmed: result[:unconfirmed]).to_h)
80
90
  end
81
91
 
82
92
  # @return [Hash, nil] nil when the store cannot remove by pattern
@@ -160,9 +170,20 @@ module PermissionForge
160
170
  defined?(ActsAsTenant) ? ActsAsTenant.without_tenant(&) : yield
161
171
  end
162
172
 
163
- def record(tenant_id, **data)
164
- PermissionForge::Audit.record(EVENT, tenant_id: tenant_id,
165
- data: data.transform_keys(&:to_s).merge('operation' => 'cache_clear'))
173
+ # With the outcome of its rows (Cache::Outcome#event_data:
174
+ # `process_local` says the store lives in this process only, so the
175
+ # clear did not reach the web and job processes).
176
+ def record(tenant_id, **counts)
177
+ PermissionForge::Audit.record(EVENT, tenant_id: tenant_id, data: event_data(**counts))
178
+ end
179
+
180
+ def event_data(unconfirmed:, **counts)
181
+ { 'operation' => 'cache_clear', **counts.transform_keys(&:to_s), **outcome_data(unconfirmed) }
182
+ end
183
+
184
+ # The outcome's keys, `unconfirmed` among them.
185
+ def outcome_data(unconfirmed)
186
+ @report.outcome(unconfirmed: unconfirmed).event_data
166
187
  end
167
188
 
168
189
  # The event without a tenant — recorded with none ambient, so it is one.
@@ -28,10 +28,12 @@ module PermissionForge
28
28
 
29
29
  # Resources and their rows in id order, in place. The database returns
30
30
  # them in whatever order its plan gives, and order decides: the first row
31
- # of a name wins a tie (two fields mapping to one variant — "first
32
- # mask_strategy wins"), and rules and actions keep it in the hash. The one
33
- # definition: Admin::EffectiveAccess sorts the rows it shows with it. An
34
- # unsaved row (no id yet) sorts first.
31
+ # of a name wins a tie between derived variants (two fields mapping to
32
+ # one name — "first mask_strategy wins"; an explicit row for the name
33
+ # decides it whatever the order, FieldPermissionMerger.targets_of), and
34
+ # rules and actions keep it in the hash. The one definition:
35
+ # Admin::EffectiveAccess sorts the rows it shows with it. An unsaved row
36
+ # (no id yet) sorts first.
35
37
  def self.sort_rows(permission_set)
36
38
  resources = sorted(permission_set.association(:resources))
37
39
  resources.each { |resource| RESOURCE_ROWS.each { |rows| sorted(resource.association(rows)) } }
@@ -42,6 +44,23 @@ module PermissionForge
42
44
  end
43
45
  private_class_method :sorted
44
46
 
47
+ # The flags a granting Resource row grants, each only with the
48
+ # permission it depends on (ValidatesDependency: edit and delete need
49
+ # read, Modify All needs View All) — a row written past the validation
50
+ # grants no more than the dependency allows, so `can_edit?` is never
51
+ # true where `can_read?` is false. The one definition; the admin's
52
+ # effective-access view follows it.
53
+ #
54
+ # @param resource [Resource, #read_access]
55
+ # @return [Hash{Symbol => Boolean}] RESOURCE_PERMISSION_OPTIONS
56
+ def self.granted_flags(resource)
57
+ read = resource.read_access == true
58
+ view_all = resource.view_all == true
59
+ { read_access: read, create_access: resource.create_access == true,
60
+ edit_access: read && resource.edit_access == true, delete_access: read && resource.delete_access == true,
61
+ view_all: view_all, modify_all: view_all && resource.modify_all == true }
62
+ end
63
+
45
64
  # Builds a user's permission hash from their permission sets.
46
65
  # @param user [Object]
47
66
  # @param permission_sets [Enumerable<PermissionSet>]
@@ -123,7 +142,7 @@ module PermissionForge
123
142
 
124
143
  add_source_permission_set(resource_data[:source_permission_sets], permission_set)
125
144
  merge_resource_permissions(resource_data[:permissions], resource)
126
- FieldPermissionMerger.call(fields: resource_data[:fields], resource: resource)
145
+ FieldPermissionMerger.call(fields: resource_data[:fields], resource: resource, variants: field_variants(resource.name))
127
146
  merge_resource_actions(resource_data[:actions], resource)
128
147
  merge_permission_set_rules(resource_data[:permission_set_rules], resource)
129
148
  end
@@ -172,8 +191,8 @@ module PermissionForge
172
191
  end
173
192
 
174
193
  def merge_resource_permissions(permissions, resource)
175
- RESOURCE_PERMISSION_OPTIONS.each do |action|
176
- permissions[action] ||= resource.send(action)
194
+ self.class.granted_flags(resource).each do |action, granted|
195
+ permissions[action] ||= granted
177
196
  end
178
197
  end
179
198
 
@@ -216,30 +235,34 @@ module PermissionForge
216
235
  # row naming a subclass reaches that subclass alone.
217
236
  def apply_muting_permission_set(data, permission_set)
218
237
  permission_set.resources.select(&:is_active).each do |resource|
219
- entries_under(data, resource.name).each do |resource_data|
220
- apply_muting_resource(resource_data, permission_set, resource)
238
+ entries_under(data, resource.name).each do |entry|
239
+ apply_muting_resource(entry, permission_set, resource)
221
240
  end
222
241
  end
223
242
  end
224
243
 
225
- # The entries whose chain includes the resource name, in hash order;
226
- # each entry's chain is worked out once per build.
244
+ # [entry name, entry] for the entries whose chain includes the resource
245
+ # name, in hash order; each entry's chain is worked out once per build.
227
246
  def entries_under(data, name)
228
247
  muted = name.to_sym
229
248
  @entry_chains ||= {}
230
- data[:resources].filter_map do |entry_name, resource_data|
231
- chain = (@entry_chains[entry_name] ||= PermissionForge::ResourceChain.names_for(entry_name))
232
- resource_data if chain.include?(muted)
249
+ data[:resources].select do |entry_name, _|
250
+ (@entry_chains[entry_name] ||= PermissionForge::ResourceChain.names_for(entry_name)).include?(muted)
233
251
  end
234
252
  end
235
253
 
236
254
  # The set is listed once per entry: one with rows for a base and its
237
- # subclass reaches the subclass entry twice.
238
- def apply_muting_resource(resource_data, permission_set, resource)
255
+ # subclass reaches the subclass entry twice. `entry`: [entry name,
256
+ # entry]; the variants muted are the entry's own (FieldVariants of the
257
+ # entry's model, not of the muting row's Resource): a base row reaching
258
+ # a subclass entry mutes the variants the subclass has, as the granting
259
+ # phase wrote them.
260
+ def apply_muting_resource(entry, permission_set, resource)
261
+ entry_name, resource_data = entry
239
262
  sources = resource_data[:source_permission_sets]
240
263
  add_source_permission_set(sources, permission_set) if sources.none? { |source| source[:uuid] == permission_set.uuid }
241
264
  mute_resource_permissions(resource_data[:permissions], resource)
242
- mute_field_permissions(resource_data[:fields], resource)
265
+ mute_field_permissions(resource_data[:fields], resource, field_variants(entry_name))
243
266
  mute_fields_of_resource(resource_data[:fields], resource)
244
267
  mute_resource_actions(resource_data[:actions], resource)
245
268
  end
@@ -267,7 +290,7 @@ module PermissionForge
267
290
  end
268
291
  end
269
292
 
270
- def mute_field_permissions(fields, resource)
293
+ def mute_field_permissions(fields, resource, variants)
271
294
  resource.resource_fields.select(&:is_active).each do |field|
272
295
  next unless field.is_active
273
296
 
@@ -275,12 +298,18 @@ module PermissionForge
275
298
  # _cents, etc.), matching the variants the granting phase writes — otherwise
276
299
  # a muting set that revokes `user_id` would leave `user_uuid` readable.
277
300
  mute_single_field(fields, field.name, field)
278
- PermissionForge.configuration.field_variants_for(field.name).each do |variant|
301
+ variants.call(field.name).each do |variant|
279
302
  mute_single_field(fields, variant, field)
280
303
  end
281
304
  end
282
305
  end
283
306
 
307
+ # The field variants of an entry's model (FieldVariants), one lookup
308
+ # per Resource name for the whole build.
309
+ def field_variants(name)
310
+ (@field_variants ||= {})[name.to_s] ||= PermissionForge::FieldVariants.new(name.to_s)
311
+ end
312
+
284
313
  # A row that mutes read mutes edit too: a field nobody may read is not
285
314
  # one they may write (the admin UI's Hide writes both flags; the API
286
315
  # and code can write read alone, which left the field writable through
@@ -8,12 +8,22 @@ module PermissionForge
8
8
  # a malformed field_name can never reach SQL as a literal even if a rule was
9
9
  # created through a path that bypasses model validation.
10
10
  #
11
+ # A stored rule never raises here. A value of a rule row the applier
12
+ # cannot interpret — a join type or operator this version does not know,
13
+ # a path that cannot be followed, a field name that is not a name or
14
+ # names a column its table does not have or a table that is not there, a
15
+ # value the column cannot hold — makes the rule match nothing: every
16
+ # holder is denied every record of the resource, never handed an error
17
+ # (a 500 for every holder, on every query under auto_query_scoping). The
18
+ # same reading describes such a rule to the admin and the audit
19
+ # (Operators::RuleProblem).
20
+ #
11
21
  # @api private
12
22
  class PermissionRuleApplier
13
23
  # join_type => the relation method that makes the join. The model
14
24
  # validates join_type, but a rule row can be written past the model (the
15
25
  # tables are shared with other applications): the value is looked up
16
- # here, never sent to the relation, and anything else raises.
26
+ # here, never sent to the relation, and anything else matches nothing.
17
27
  JOIN_METHODS = {
18
28
  PermissionForge::Operators::Constants::JOINS => :joins,
19
29
  PermissionForge::Operators::Constants::LEFT_JOINS => :left_joins,
@@ -49,7 +59,30 @@ module PermissionForge
49
59
  @operator_registry = operator_registry
50
60
  end
51
61
 
52
- # @return [Array(ActiveRecord::Relation, Array<String>)] query and failure messages
62
+ # The query, the denial's messages (`[Forbidden by Rule: <name>] with
63
+ # message: <failure_message>`, one per denying rule) and the denying
64
+ # rules (the rule hashes themselves, in the same order); both empty when
65
+ # nothing denied.
66
+ #
67
+ # What a rule says when it denies: its `failure_message` as an admin
68
+ # wrote it, or — for a rule left without one, or holding the install
69
+ # migration's English default (PermissionForge::GeneratedText: a rule
70
+ # created before 0.15 without a message) — the gem's own sentence in
71
+ # the current locale (permission_forge.denials.rule_default). The one
72
+ # definition: NotAuthorizedError#failure_messages reads it too. The
73
+ # permission hash keeps the stored text (it is cached per user, not per
74
+ # locale): the reading happens here, at denial time.
75
+ #
76
+ # @param rule [Hash] a rule hash of the permission hash
77
+ # @return [String]
78
+ def self.failure_message_for(rule)
79
+ message = rule[:failure_message].to_s.strip
80
+ return message unless message.empty? || PermissionForge::GeneratedText.failure_message?(message)
81
+
82
+ I18n.t('permission_forge.denials.rule_default')
83
+ end
84
+
85
+ # @return [Array(ActiveRecord::Relation, Array<String>, Array<Hash>)]
53
86
  def call
54
87
  report_no_rules if @rules.empty?
55
88
  query = @rules.reduce(@model_class.where(nil)) { |q, rule| build_rule_query(q, rule) }
@@ -57,11 +90,12 @@ module PermissionForge
57
90
  # Happy path is a single existence check: rule constraints are monotonic
58
91
  # (each only removes rows), so if the fully-constrained query still matches
59
92
  # the target, no individual rule denied it.
60
- return [query, []] if @rules.empty? || query.where(*@args).exists?
93
+ return [query, [], []] if @rules.empty? || matches?(query)
61
94
 
62
- # Denial path only: attribute the denial to specific rules for the error
63
- # message. The extra per-rule queries here run solely when access is denied.
64
- [query, denial_messages]
95
+ # Denial path only: attribute the denial to the rules that deny the
96
+ # record (one query per rule, solely when access is denied).
97
+ denying = denying_rules
98
+ [query, denying.map { |rule| rule_failure_message(rule) }, denying]
65
99
  end
66
100
 
67
101
  private
@@ -73,89 +107,96 @@ module PermissionForge
73
107
  end
74
108
 
75
109
  def rule_failure_message(rule)
76
- "[Forbidden by Rule: #{rule[:name]}] with message: #{rule[:failure_message]}"
110
+ "[Forbidden by Rule: #{rule[:name]}] with message: #{self.class.failure_message_for(rule)}"
111
+ end
112
+
113
+ # The rules that deny the record each on its own: the model's relation,
114
+ # that one rule and the args — rules combine with AND, so a rule that
115
+ # keeps the record on its own did not deny it, whatever the others do.
116
+ # In rule order. Should no single rule deny (an interplay with a default
117
+ # scope, say), the cumulative attribution — the first rule whose
118
+ # addition removes the record, and every rule after it — so a denial
119
+ # never names no rule.
120
+ def denying_rules
121
+ denying = @rules.reject { |rule| matches?(build_rule_query(@model_class.where(nil), rule)) }
122
+ denying.empty? ? cumulatively_denying_rules : denying
77
123
  end
78
124
 
79
- # Rebuilds the query rule-by-rule, blaming each rule whose addition removes
80
- # the target from the result set (the first denying rule and every rule
81
- # after it, matching the original cumulative semantics).
82
- def denial_messages
83
- messages = []
125
+ def cumulatively_denying_rules
126
+ denying = []
84
127
  @rules.reduce(@model_class.where(nil)) do |q, rule|
85
128
  next_query = build_rule_query(q, rule)
86
- messages << rule_failure_message(rule) unless next_query.where(*@args).exists?
129
+ denying << rule if denying.any? || !matches?(next_query)
87
130
  next_query
88
131
  end
89
- messages
132
+ denying
90
133
  end
91
134
 
135
+ def matches?(query)
136
+ query.where(*@args).exists?
137
+ end
138
+
139
+ # A join type that is none of JOIN_METHODS (written past the model, or
140
+ # by a newer version) matches nothing: it is never sent to the relation.
141
+ # Neither is a field_name that is not a name (RuleColumn.name?): quoting
142
+ # one with a NUL byte raises on PostgreSQL.
92
143
  def build_rule_query(query, rule)
144
+ return query.none unless PermissionForge::Operators::RuleColumn.name?(rule[:field_name])
145
+
93
146
  join_type = rule[:join_type].to_s
94
147
  return apply_filter(query, rule) if PermissionForge::PermissionSetRule.filter_join_type?(join_type)
95
148
 
96
- apply_join(query, rule, join_method(join_type))
97
- end
98
-
99
- # @raise [PermissionForge::ConfigurationError] for a join_type that is not one
100
- def join_method(join_type)
101
- JOIN_METHODS.fetch(join_type) do
102
- raise PermissionForge::ConfigurationError,
103
- "Rule join_type #{join_type.inspect} is not a join type. Allowed: #{JOIN_METHODS.keys.join(', ')}"
104
- end
149
+ join_method = JOIN_METHODS[join_type]
150
+ join_method ? apply_join(query, rule, join_method) : query.none
105
151
  end
106
152
 
153
+ # A filter rule compares its model's own table: a field_name
154
+ # qualified with another table (RuleColumn::FOREIGN) matches nothing,
155
+ # whatever the query joins (RuleProblem :foreign_table).
107
156
  def apply_filter(query, rule)
108
- column = quoted_column(@model_class.table_name, rule[:field_name])
109
- build_query(query, column, rule, [@model_class])
157
+ field_name = rule[:field_name]
158
+ target = PermissionForge::Operators::RuleColumn.find(field_name, [@model_class])
159
+ build_query(query, quoted_column(@model_class.table_name, field_name), rule, target)
160
+ end
161
+
162
+ # A path that cannot be followed (Operators::RulePath#broken?: no such
163
+ # association, a polymorphic one, a class that cannot be loaded...)
164
+ # matches nothing: ActiveRecord would raise for the join when the query
165
+ # runs, and every holder would get an error instead of a denial. A
166
+ # qualifier is compared with the names the joined query gives its
167
+ # tables (Operators::RuleSources: the aliases ActiveRecord assigns,
168
+ # the default scope's and the host's joins included); one it does not
169
+ # give matches nothing (RuleColumn::FOREIGN / UNRESOLVABLE).
170
+ def apply_join(query, rule, join_method)
171
+ association_chain = PermissionForge::Operators::RuleColumn.path_segments(rule[:field_accessor_path])
172
+ models = joinable_models(association_chain)
173
+ return query.none unless models
174
+
175
+ query = PermissionForge::Operators::RulePath.join(query, join_method, association_chain)
176
+ field_name = rule[:field_name]
177
+ joined = field_name.to_s.include?('.') ? PermissionForge::Operators::RuleSources.tables(query) : {}
178
+ target = PermissionForge::Operators::RuleColumn.find(field_name, models, joined: joined)
179
+ build_query(query, quoted_column(models.last.table_name, field_name), rule, target)
110
180
  end
111
181
 
112
- # A path that cannot be followed leaves no model to type by: the value
113
- # goes as it is, and ActiveRecord raises for the join when the query is built.
114
- def apply_join(query, rule, join_method)
115
- query, models = resolve_join(query, rule, join_method)
116
- column = quoted_column((models.last || @model_class).table_name, rule[:field_name])
117
- build_query(query, column, rule, models)
182
+ # The models along a path ActiveRecord can join, or nil (RulePath#broken?).
183
+ def joinable_models(association_chain)
184
+ path = PermissionForge::Operators::RulePath.follow(@model_class, association_chain)
185
+ path.models unless path.broken?
118
186
  end
119
187
 
120
188
  # Quotes a column reference with the connection's identifier quoting.
121
189
  # A dotted field_name is treated as already-qualified path segments and
122
- # each segment is quoted independently.
190
+ # each segment is quoted independently. Through the pool, never the
191
+ # model's `connection` (it raises under Rails 7.2+
192
+ # `permanent_connection_checkout = :disallowed`).
123
193
  def quoted_column(table, field_name)
124
- conn = @model_class.connection
125
194
  name = field_name.to_s
126
- return name.split('.').map { |seg| conn.quote_column_name(seg) }.join('.') if name.include?('.')
127
-
128
- "#{conn.quote_table_name(table)}.#{conn.quote_column_name(name)}"
129
- end
130
-
131
- def resolve_join(query, rule, join_method)
132
- return [query, [@model_class]] if rule[:field_accessor_path].blank?
133
-
134
- association_chain = rule[:field_accessor_path].split('.')
135
- query = apply_join_to_query(query, join_method, association_chain)
136
-
137
- [query, PermissionForge::Operators::RuleColumn.models_along(@model_class, association_chain)]
138
- end
139
-
140
- # @param join_method [Symbol] one of JOIN_METHODS — never a rule's own text
141
- def apply_join_to_query(query, join_method, association_chain)
142
- if association_chain.size > 1
143
- query.public_send(join_method, build_nested_joins_structure(association_chain))
144
- else
145
- query.public_send(join_method, association_chain.first.to_sym)
146
- end
147
- end
195
+ @model_class.connection_pool.with_connection do |conn|
196
+ next name.split('.').map { |seg| conn.quote_column_name(seg) }.join('.') if name.include?('.')
148
197
 
149
- def build_nested_joins_structure(association_chain)
150
- structure = {}
151
- current = structure
152
-
153
- association_chain.each_with_index do |assoc, index|
154
- current[assoc.to_sym] = {}
155
- current = current[assoc.to_sym] unless index == association_chain.length - 1
198
+ "#{conn.quote_table_name(table)}.#{conn.quote_column_name(name)}"
156
199
  end
157
-
158
- structure
159
200
  end
160
201
 
161
202
  # A built-in operator gets its value in the type of the column it is
@@ -163,17 +204,39 @@ module PermissionForge
163
204
  # of that column makes the rule match nothing, whatever the database
164
205
  # would have made of the comparison. A custom operator's value is passed
165
206
  # as it is and its strategy runs. A field_name that is not a name
166
- # (Operators::RuleColumn.name?) matches nothing under any operator.
167
- def build_query(query, quoted_column, rule, models)
207
+ # (Operators::RuleColumn.name?) matches nothing under any operator, and
208
+ # one naming a column its table does not have matches nothing under a
209
+ # built-in operator (Operators::ValueTyping.no_column?) — the database
210
+ # would reject the query, and every holder would get an error instead
211
+ # of a denial. So does one qualified with a table that is not there
212
+ # (RuleColumn#unresolvable?) or that is none of the rule's query
213
+ # (RuleColumn#foreign?), and a rule whose operator the registry
214
+ # does not have (written past the model, or by a newer version).
215
+ def build_query(query, quoted_column, rule, target)
168
216
  operator = rule[:operator]
169
- strategy = @operator_registry.fetch(operator)
170
- target = PermissionForge::Operators::RuleColumn.find(rule[:field_name], models)
217
+ strategy = strategy_for(operator)
218
+ return query.none if strategy.nil? || target.unresolvable? || target.foreign?
219
+
171
220
  value = PermissionForge::Operators::ValueTyping.call(operator, formatted_field_value(rule), target)
172
221
  return query.none if value.equal?(PermissionForge::Operators::ValueTyping::UNUSABLE)
173
222
 
174
223
  strategy.call(query, quoted_column, rule, value)
175
224
  end
176
225
 
226
+ # The operator's strategy from the registry — Operators::Registry, or an
227
+ # injected one: anything answering `strategies` (a Hash of them) or
228
+ # `fetch(name)` (a Hash, an object of the host's) — and nil for an
229
+ # operator it does not have, whether it answers nil or raises for one
230
+ # (ArgumentError as Registry.fetch does, KeyError as a Hash does).
231
+ def strategy_for(operator)
232
+ registry = @operator_registry
233
+ return registry.strategies[operator] if registry.respond_to?(:strategies)
234
+
235
+ registry.fetch(operator)
236
+ rescue ArgumentError, KeyError
237
+ nil
238
+ end
239
+
177
240
  # The only magic value is 'current_user', substituted with the current
178
241
  # user's id where one value is compared (in a list it is an element like
179
242
  # any other). Every other field_value reaches ValueTyping as it is