permission_forge 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (406) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +1138 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +356 -0
  5. data/app/controllers/concerns/permission_forge/acting_admin.rb +17 -0
  6. data/app/controllers/concerns/permission_forge/api/crud_actions.rb +84 -0
  7. data/app/controllers/concerns/permission_forge/api/nested_resource_lookup.rb +18 -0
  8. data/app/controllers/concerns/permission_forge/api/pagination.rb +50 -0
  9. data/app/controllers/concerns/permission_forge/api/response_renderer.rb +65 -0
  10. data/app/controllers/concerns/permission_forge/api/serializer_resolver.rb +30 -0
  11. data/app/controllers/concerns/permission_forge/api_authentication.rb +40 -0
  12. data/app/controllers/concerns/permission_forge/assignment_authorization.rb +97 -0
  13. data/app/controllers/concerns/permission_forge/audit_log_gate.rb +55 -0
  14. data/app/controllers/concerns/permission_forge/basic_authentication.rb +77 -0
  15. data/app/controllers/concerns/permission_forge/cache_confirmation.rb +32 -0
  16. data/app/controllers/concerns/permission_forge/forgery_protection.rb +29 -0
  17. data/app/controllers/concerns/permission_forge/html_format.rb +40 -0
  18. data/app/controllers/concerns/permission_forge/param_shapes.rb +87 -0
  19. data/app/controllers/concerns/permission_forge/tenant_required.rb +42 -0
  20. data/app/controllers/permission_forge/api/audit_events_controller.rb +33 -0
  21. data/app/controllers/permission_forge/api/base_controller.rb +220 -0
  22. data/app/controllers/permission_forge/api/dashboard_controller.rb +30 -0
  23. data/app/controllers/permission_forge/api/permission_set_group_users_controller.rb +51 -0
  24. data/app/controllers/permission_forge/api/permission_set_groups_controller.rb +71 -0
  25. data/app/controllers/permission_forge/api/permission_set_rules_controller.rb +37 -0
  26. data/app/controllers/permission_forge/api/permission_set_users_controller.rb +105 -0
  27. data/app/controllers/permission_forge/api/permission_sets_controller.rb +87 -0
  28. data/app/controllers/permission_forge/api/resource_actions_controller.rb +58 -0
  29. data/app/controllers/permission_forge/api/resource_fields_controller.rb +30 -0
  30. data/app/controllers/permission_forge/api/resources_controller.rb +56 -0
  31. data/app/controllers/permission_forge/application_controller.rb +198 -0
  32. data/app/controllers/permission_forge/assets_controller.rb +97 -0
  33. data/app/controllers/permission_forge/assignments_controller.rb +266 -0
  34. data/app/controllers/permission_forge/audit_events_controller.rb +94 -0
  35. data/app/controllers/permission_forge/bulk_assignments_controller.rb +108 -0
  36. data/app/controllers/permission_forge/field_lookups_controller.rb +38 -0
  37. data/app/controllers/permission_forge/home_controller.rb +24 -0
  38. data/app/controllers/permission_forge/maintenance_controller.rb +13 -0
  39. data/app/controllers/permission_forge/people_controller.rb +190 -0
  40. data/app/controllers/permission_forge/permission_set_editor.rb +71 -0
  41. data/app/controllers/permission_forge/permission_set_group_users_controller.rb +57 -0
  42. data/app/controllers/permission_forge/permission_set_groups_controller.rb +111 -0
  43. data/app/controllers/permission_forge/permission_set_rules_controller.rb +97 -0
  44. data/app/controllers/permission_forge/permission_sets_controller.rb +227 -0
  45. data/app/controllers/permission_forge/preferences_controller.rb +26 -0
  46. data/app/controllers/permission_forge/resource_actions_controller.rb +106 -0
  47. data/app/controllers/permission_forge/resource_fields_controller.rb +129 -0
  48. data/app/controllers/permission_forge/resources_controller.rb +189 -0
  49. data/app/controllers/permission_forge/settings_controller.rb +33 -0
  50. data/app/frontend/permission_forge/brand/apple-touch-icon.png +0 -0
  51. data/app/frontend/permission_forge/brand/chevron.svg +1 -0
  52. data/app/frontend/permission_forge/brand/favicon.ico +0 -0
  53. data/app/frontend/permission_forge/brand/favicon.svg +1 -0
  54. data/app/frontend/permission_forge/brand/logo-horizontal-dark.svg +1 -0
  55. data/app/frontend/permission_forge/brand/logo-horizontal-light.svg +1 -0
  56. data/app/frontend/permission_forge/brand/mark-small.svg +1 -0
  57. data/app/frontend/permission_forge/brand/mark.svg +1 -0
  58. data/app/frontend/permission_forge/pf_admin.css +766 -0
  59. data/app/frontend/permission_forge/pf_admin.js +482 -0
  60. data/app/helpers/permission_forge/application_helper.rb +167 -0
  61. data/app/helpers/permission_forge/audit_helper.rb +230 -0
  62. data/app/helpers/permission_forge/audit_sentences_helper.rb +84 -0
  63. data/app/helpers/permission_forge/badges_helper.rb +134 -0
  64. data/app/helpers/permission_forge/changes_helper.rb +117 -0
  65. data/app/helpers/permission_forge/people_helper.rb +118 -0
  66. data/app/helpers/permission_forge/sentences_helper.rb +175 -0
  67. data/app/helpers/permission_forge/settings_helper.rb +152 -0
  68. data/app/helpers/permission_forge/ui_helper.rb +184 -0
  69. data/app/models/permission_forge/audit_event.rb +159 -0
  70. data/app/models/permission_forge/permission_set.rb +100 -0
  71. data/app/models/permission_forge/permission_set_group.rb +83 -0
  72. data/app/models/permission_forge/permission_set_group_user.rb +43 -0
  73. data/app/models/permission_forge/permission_set_rule.rb +99 -0
  74. data/app/models/permission_forge/permission_set_user.rb +158 -0
  75. data/app/models/permission_forge/resource.rb +100 -0
  76. data/app/models/permission_forge/resource_action.rb +58 -0
  77. data/app/models/permission_forge/resource_field.rb +69 -0
  78. data/app/views/layouts/permission_forge/application.html.erb +30 -0
  79. data/app/views/layouts/permission_forge/fragment.html.erb +2 -0
  80. data/app/views/permission_forge/assignments/_person_options.html.erb +26 -0
  81. data/app/views/permission_forge/assignments/confirm_destroy.html.erb +24 -0
  82. data/app/views/permission_forge/assignments/new.html.erb +99 -0
  83. data/app/views/permission_forge/audit_events/index.html.erb +161 -0
  84. data/app/views/permission_forge/audit_events/show.html.erb +146 -0
  85. data/app/views/permission_forge/bulk_assignments/confirm_revoke.html.erb +21 -0
  86. data/app/views/permission_forge/bulk_assignments/new.html.erb +69 -0
  87. data/app/views/permission_forge/field_lookups/show.html.erb +32 -0
  88. data/app/views/permission_forge/home/_audit_card.html.erb +19 -0
  89. data/app/views/permission_forge/home/_ending_card.html.erb +32 -0
  90. data/app/views/permission_forge/home/_expired_card.html.erb +34 -0
  91. data/app/views/permission_forge/home/_privacy_card.html.erb +67 -0
  92. data/app/views/permission_forge/home/_rules_card.html.erb +23 -0
  93. data/app/views/permission_forge/home/_stale_card.html.erb +39 -0
  94. data/app/views/permission_forge/home/_tidy_card.html.erb +18 -0
  95. data/app/views/permission_forge/home/index.html.erb +69 -0
  96. data/app/views/permission_forge/people/_access_tab.html.erb +58 -0
  97. data/app/views/permission_forge/people/_audit_tab.html.erb +93 -0
  98. data/app/views/permission_forge/people/_effective_field_source.html.erb +11 -0
  99. data/app/views/permission_forge/people/_effective_tab.html.erb +118 -0
  100. data/app/views/permission_forge/people/_effective_why.html.erb +48 -0
  101. data/app/views/permission_forge/people/_why_item.html.erb +19 -0
  102. data/app/views/permission_forge/people/confirm_remove_all.html.erb +27 -0
  103. data/app/views/permission_forge/people/consent_preview.html.erb +39 -0
  104. data/app/views/permission_forge/people/erasure_preview.html.erb +28 -0
  105. data/app/views/permission_forge/people/index.html.erb +78 -0
  106. data/app/views/permission_forge/people/show.html.erb +54 -0
  107. data/app/views/permission_forge/permission_set_groups/confirm_destroy.html.erb +12 -0
  108. data/app/views/permission_forge/permission_set_groups/index.html.erb +55 -0
  109. data/app/views/permission_forge/permission_set_groups/show.html.erb +100 -0
  110. data/app/views/permission_forge/permission_set_rules/_builder.html.erb +145 -0
  111. data/app/views/permission_forge/permission_set_rules/_value_input.html.erb +42 -0
  112. data/app/views/permission_forge/permission_set_rules/confirm_destroy.html.erb +13 -0
  113. data/app/views/permission_forge/permission_set_rules/edit.html.erb +3 -0
  114. data/app/views/permission_forge/permission_set_rules/new.html.erb +3 -0
  115. data/app/views/permission_forge/permission_sets/_assignments_tab.html.erb +74 -0
  116. data/app/views/permission_forge/permission_sets/_audit_tab.html.erb +80 -0
  117. data/app/views/permission_forge/permission_sets/_conditions_tab.html.erb +83 -0
  118. data/app/views/permission_forge/permission_sets/_header.html.erb +36 -0
  119. data/app/views/permission_forge/permission_sets/_records_summary.html.erb +16 -0
  120. data/app/views/permission_forge/permission_sets/_resource_summary.html.erb +11 -0
  121. data/app/views/permission_forge/permission_sets/_resources_tab.html.erb +49 -0
  122. data/app/views/permission_forge/permission_sets/confirm_destroy.html.erb +23 -0
  123. data/app/views/permission_forge/permission_sets/confirm_switch_mode.html.erb +15 -0
  124. data/app/views/permission_forge/permission_sets/index.html.erb +83 -0
  125. data/app/views/permission_forge/permission_sets/new.html.erb +63 -0
  126. data/app/views/permission_forge/permission_sets/review.html.erb +26 -0
  127. data/app/views/permission_forge/permission_sets/show.html.erb +50 -0
  128. data/app/views/permission_forge/resource_actions/_across_sets.html.erb +24 -0
  129. data/app/views/permission_forge/resource_actions/_add_select.html.erb +12 -0
  130. data/app/views/permission_forge/resource_actions/_effect.html.erb +25 -0
  131. data/app/views/permission_forge/resource_actions/_endpoints.html.erb +16 -0
  132. data/app/views/permission_forge/resource_actions/_form.html.erb +44 -0
  133. data/app/views/permission_forge/resource_actions/_managed_note.html.erb +11 -0
  134. data/app/views/permission_forge/resource_actions/confirm_destroy.html.erb +13 -0
  135. data/app/views/permission_forge/resource_actions/new.html.erb +31 -0
  136. data/app/views/permission_forge/resource_actions/show.html.erb +39 -0
  137. data/app/views/permission_forge/resource_fields/_explain.html.erb +24 -0
  138. data/app/views/permission_forge/resource_fields/show.html.erb +186 -0
  139. data/app/views/permission_forge/resources/_actions_tab.html.erb +88 -0
  140. data/app/views/permission_forge/resources/_fields_tab.html.erb +89 -0
  141. data/app/views/permission_forge/resources/_rules_tab.html.erb +55 -0
  142. data/app/views/permission_forge/resources/_sync.html.erb +57 -0
  143. data/app/views/permission_forge/resources/confirm_destroy.html.erb +17 -0
  144. data/app/views/permission_forge/resources/new.html.erb +57 -0
  145. data/app/views/permission_forge/resources/show.html.erb +117 -0
  146. data/app/views/permission_forge/settings/_ladder.html.erb +15 -0
  147. data/app/views/permission_forge/settings/_row.html.erb +38 -0
  148. data/app/views/permission_forge/settings/show.html.erb +67 -0
  149. data/app/views/permission_forge/shared/_across_sets.html.erb +31 -0
  150. data/app/views/permission_forge/shared/_audit_events.html.erb +36 -0
  151. data/app/views/permission_forge/shared/_audit_footer.html.erb +16 -0
  152. data/app/views/permission_forge/shared/_audit_off_note.html.erb +15 -0
  153. data/app/views/permission_forge/shared/_audit_pagination.html.erb +19 -0
  154. data/app/views/permission_forge/shared/_audit_refused_note.html.erb +7 -0
  155. data/app/views/permission_forge/shared/_audit_timeline.html.erb +29 -0
  156. data/app/views/permission_forge/shared/_crud_cell.html.erb +18 -0
  157. data/app/views/permission_forge/shared/_dialog_frame.html.erb +11 -0
  158. data/app/views/permission_forge/shared/_expiry_picker.html.erb +27 -0
  159. data/app/views/permission_forge/shared/_head_extra.html.erb +3 -0
  160. data/app/views/permission_forge/shared/_help_banner.html.erb +10 -0
  161. data/app/views/permission_forge/shared/_level_control.html.erb +18 -0
  162. data/app/views/permission_forge/shared/_mode_banners.html.erb +22 -0
  163. data/app/views/permission_forge/shared/_nav.html.erb +12 -0
  164. data/app/views/permission_forge/shared/_preferences.html.erb +24 -0
  165. data/app/views/permission_forge/shared/_reason_field.html.erb +20 -0
  166. data/app/views/permission_forge/shared/_savebar.html.erb +13 -0
  167. data/app/views/permission_forge/shared/_sheet_frame.html.erb +22 -0
  168. data/app/views/permission_forge/shared/_sidebar.html.erb +18 -0
  169. data/app/views/permission_forge/shared/_tech_details.html.erb +20 -0
  170. data/app/views/permission_forge/shared/_toasts.html.erb +27 -0
  171. data/app/views/permission_forge/shared/_topbar.html.erb +15 -0
  172. data/app/views/permission_forge/shared/_user_select.html.erb +16 -0
  173. data/app/views/permission_forge/shared/audit_refused.html.erb +7 -0
  174. data/config/locales/permission_forge.en.yml +2115 -0
  175. data/config/locales/permission_forge.mk.yml +2166 -0
  176. data/config/locales/permission_forge.sq.yml +2164 -0
  177. data/config/routes.rb +143 -0
  178. data/lib/generators/permission_forge/install/install_generator.rb +105 -0
  179. data/lib/generators/permission_forge/install/templates/initializer.rb.tt +148 -0
  180. data/lib/generators/permission_forge/install/templates/migration.rb.tt +198 -0
  181. data/lib/generators/permission_forge/rls/rls_generator.rb +57 -0
  182. data/lib/generators/permission_forge/rls/templates/enable_permission_forge_rls.rb.tt +15 -0
  183. data/lib/generators/permission_forge/upgrade/templates/add_admin_ui_columns.rb.tt +8 -0
  184. data/lib/generators/permission_forge/upgrade/templates/add_audit_events.rb.tt +98 -0
  185. data/lib/generators/permission_forge/upgrade/templates/add_resource_fields_unique_index.rb.tt +69 -0
  186. data/lib/generators/permission_forge/upgrade/upgrade_generator.rb +66 -0
  187. data/lib/permission_forge/admin/action_across_sets.rb +76 -0
  188. data/lib/permission_forge/admin/action_rows.rb +156 -0
  189. data/lib/permission_forge/admin/attention_items.rb +207 -0
  190. data/lib/permission_forge/admin/audit_event_detail.rb +75 -0
  191. data/lib/permission_forge/admin/audit_log.rb +193 -0
  192. data/lib/permission_forge/admin/audit_log_days.rb +41 -0
  193. data/lib/permission_forge/admin/audit_lookups.rb +105 -0
  194. data/lib/permission_forge/admin/audit_name_history.rb +85 -0
  195. data/lib/permission_forge/admin/audit_trail.rb +169 -0
  196. data/lib/permission_forge/admin/bulk_people_matcher.rb +109 -0
  197. data/lib/permission_forge/admin/change_set.rb +123 -0
  198. data/lib/permission_forge/admin/change_set_parts/actions.rb +27 -0
  199. data/lib/permission_forge/admin/change_set_parts/base.rb +58 -0
  200. data/lib/permission_forge/admin/change_set_parts/context.rb +50 -0
  201. data/lib/permission_forge/admin/change_set_parts/field_edit.rb +67 -0
  202. data/lib/permission_forge/admin/change_set_parts/field_levels.rb +71 -0
  203. data/lib/permission_forge/admin/change_set_parts/new_actions.rb +91 -0
  204. data/lib/permission_forge/admin/change_set_parts/new_fields.rb +63 -0
  205. data/lib/permission_forge/admin/change_set_parts/resource_edit.rb +55 -0
  206. data/lib/permission_forge/admin/change_set_parts/resource_flags.rb +19 -0
  207. data/lib/permission_forge/admin/change_set_parts/set_attrs.rb +45 -0
  208. data/lib/permission_forge/admin/change_set_parts/values.rb +20 -0
  209. data/lib/permission_forge/admin/column_fields.rb +48 -0
  210. data/lib/permission_forge/admin/conditions_form.rb +78 -0
  211. data/lib/permission_forge/admin/controller_catalog.rb +166 -0
  212. data/lib/permission_forge/admin/csv.rb +41 -0
  213. data/lib/permission_forge/admin/editable_lock.rb +157 -0
  214. data/lib/permission_forge/admin/effective_access.rb +158 -0
  215. data/lib/permission_forge/admin/effective_fields.rb +176 -0
  216. data/lib/permission_forge/admin/expiry_presets.rb +51 -0
  217. data/lib/permission_forge/admin/exports/document.rb +89 -0
  218. data/lib/permission_forge/admin/exports/events.rb +51 -0
  219. data/lib/permission_forge/admin/exports/holdings.rb +118 -0
  220. data/lib/permission_forge/admin/exports.rb +37 -0
  221. data/lib/permission_forge/admin/field_across_sets.rb +51 -0
  222. data/lib/permission_forge/admin/field_detail.rb +112 -0
  223. data/lib/permission_forge/admin/field_level.rb +74 -0
  224. data/lib/permission_forge/admin/field_rows.rb +137 -0
  225. data/lib/permission_forge/admin/last_changes.rb +94 -0
  226. data/lib/permission_forge/admin/model_catalog.rb +216 -0
  227. data/lib/permission_forge/admin/people_candidates.rb +108 -0
  228. data/lib/permission_forge/admin/people_directory.rb +158 -0
  229. data/lib/permission_forge/admin/permission_set_factory.rb +48 -0
  230. data/lib/permission_forge/admin/person_label.rb +95 -0
  231. data/lib/permission_forge/admin/record_id.rb +19 -0
  232. data/lib/permission_forge/admin/rule_builder.rb +58 -0
  233. data/lib/permission_forge/admin/rule_form.rb +175 -0
  234. data/lib/permission_forge/admin/rule_form_input.rb +74 -0
  235. data/lib/permission_forge/admin/sensitive_columns.rb +68 -0
  236. data/lib/permission_forge/admin/settings_catalog.rb +261 -0
  237. data/lib/permission_forge/admin/settings_values.rb +130 -0
  238. data/lib/permission_forge/api/serializers/audit_event_serializer.rb +27 -0
  239. data/lib/permission_forge/api/serializers/base_serializer.rb +36 -0
  240. data/lib/permission_forge/api/serializers/permission_set_group_serializer.rb +12 -0
  241. data/lib/permission_forge/api/serializers/permission_set_group_user_serializer.rb +11 -0
  242. data/lib/permission_forge/api/serializers/permission_set_rule_serializer.rb +25 -0
  243. data/lib/permission_forge/api/serializers/permission_set_serializer.rb +13 -0
  244. data/lib/permission_forge/api/serializers/permission_set_user_serializer.rb +11 -0
  245. data/lib/permission_forge/api/serializers/resource_action_serializer.rb +13 -0
  246. data/lib/permission_forge/api/serializers/resource_field_serializer.rb +14 -0
  247. data/lib/permission_forge/api/serializers/resource_serializer.rb +15 -0
  248. data/lib/permission_forge/audit/actor.rb +144 -0
  249. data/lib/permission_forge/audit/actor_resolver.rb +103 -0
  250. data/lib/permission_forge/audit/audited_views.rb +208 -0
  251. data/lib/permission_forge/audit/boot_warnings.rb +58 -0
  252. data/lib/permission_forge/audit/column_values.rb +56 -0
  253. data/lib/permission_forge/audit/context.rb +98 -0
  254. data/lib/permission_forge/audit/detached.rb +62 -0
  255. data/lib/permission_forge/audit/event_catalog.rb +110 -0
  256. data/lib/permission_forge/audit/event_query.rb +192 -0
  257. data/lib/permission_forge/audit/failure.rb +47 -0
  258. data/lib/permission_forge/audit/field_access.rb +186 -0
  259. data/lib/permission_forge/audit/gate.rb +167 -0
  260. data/lib/permission_forge/audit/job_context.rb +32 -0
  261. data/lib/permission_forge/audit/log_access.rb +36 -0
  262. data/lib/permission_forge/audit/recorder.rb +168 -0
  263. data/lib/permission_forge/audit/refusals.rb +61 -0
  264. data/lib/permission_forge/audit/request_context.rb +152 -0
  265. data/lib/permission_forge/audit/runtime.rb +178 -0
  266. data/lib/permission_forge/audit/runtime_emission.rb +135 -0
  267. data/lib/permission_forge/audit/settings_observer.rb +70 -0
  268. data/lib/permission_forge/audit/source.rb +54 -0
  269. data/lib/permission_forge/audit/subscribers/base.rb +69 -0
  270. data/lib/permission_forge/audit/subscribers/legacy_hooks.rb +97 -0
  271. data/lib/permission_forge/audit/subscribers/log.rb +45 -0
  272. data/lib/permission_forge/audit/subscribers.rb +58 -0
  273. data/lib/permission_forge/audit.rb +302 -0
  274. data/lib/permission_forge/boot_checks.rb +184 -0
  275. data/lib/permission_forge/cache/backlog.rb +142 -0
  276. data/lib/permission_forge/cache/base.rb +200 -0
  277. data/lib/permission_forge/cache/bulk_invalidation.rb +131 -0
  278. data/lib/permission_forge/cache/failure.rb +171 -0
  279. data/lib/permission_forge/cache/handled_errors.rb +82 -0
  280. data/lib/permission_forge/cache/holder_invalidation.rb +85 -0
  281. data/lib/permission_forge/cache/invalidation.rb +180 -0
  282. data/lib/permission_forge/cache/memory_cache.rb +77 -0
  283. data/lib/permission_forge/cache/null_cache.rb +40 -0
  284. data/lib/permission_forge/cache/rails_cache.rb +231 -0
  285. data/lib/permission_forge/cache/rebuild.rb +149 -0
  286. data/lib/permission_forge/cache/settlement.rb +63 -0
  287. data/lib/permission_forge/cache/user_keys.rb +206 -0
  288. data/lib/permission_forge/cache/variant_removal.rb +55 -0
  289. data/lib/permission_forge/cache/withheld_notice.rb +60 -0
  290. data/lib/permission_forge/concerns/audited_collection.rb +34 -0
  291. data/lib/permission_forge/concerns/auto_permission_scoping.rb +158 -0
  292. data/lib/permission_forge/concerns/controller_action_guard.rb +106 -0
  293. data/lib/permission_forge/concerns/field_mask_checker.rb +45 -0
  294. data/lib/permission_forge/concerns/has_active_scope.rb +12 -0
  295. data/lib/permission_forge/concerns/has_audit_trail.rb +59 -0
  296. data/lib/permission_forge/concerns/has_permission_set_users.rb +115 -0
  297. data/lib/permission_forge/concerns/has_tenant_scoping.rb +86 -0
  298. data/lib/permission_forge/concerns/has_uuid.rb +24 -0
  299. data/lib/permission_forge/concerns/invalidates_in_batch.rb +207 -0
  300. data/lib/permission_forge/concerns/invalidates_managed_actions.rb +80 -0
  301. data/lib/permission_forge/concerns/masked_attributes/alias_access.rb +280 -0
  302. data/lib/permission_forge/concerns/masked_attributes/store_access.rb +100 -0
  303. data/lib/permission_forge/concerns/masked_attributes/validation_reads.rb +153 -0
  304. data/lib/permission_forge/concerns/masked_attributes/virtual_readers.rb +242 -0
  305. data/lib/permission_forge/concerns/masked_attributes.rb +576 -0
  306. data/lib/permission_forge/concerns/permission_cache_accessor.rb +416 -0
  307. data/lib/permission_forge/concerns/permission_scoped_findable.rb +337 -0
  308. data/lib/permission_forge/concerns/permissioned_user.rb +78 -0
  309. data/lib/permission_forge/concerns/permissions_memo.rb +160 -0
  310. data/lib/permission_forge/concerns/permissions_per_context.rb +288 -0
  311. data/lib/permission_forge/concerns/permissions_regenerator.rb +43 -0
  312. data/lib/permission_forge/concerns/records_audit_events.rb +286 -0
  313. data/lib/permission_forge/concerns/resource_permission_checker.rb +116 -0
  314. data/lib/permission_forge/concerns/validates_column_lengths.rb +69 -0
  315. data/lib/permission_forge/concerns/validates_dependency.rb +54 -0
  316. data/lib/permission_forge/conditional_sets.rb +361 -0
  317. data/lib/permission_forge/configuration/admin_ui_settings.rb +108 -0
  318. data/lib/permission_forge/configuration/audit_settings.rb +184 -0
  319. data/lib/permission_forge/configuration/cache_settings.rb +76 -0
  320. data/lib/permission_forge/configuration/controller_action_settings.rb +112 -0
  321. data/lib/permission_forge/configuration/gdpr_settings.rb +70 -0
  322. data/lib/permission_forge/configuration/masked_attributes_settings.rb +83 -0
  323. data/lib/permission_forge/configuration/permits_attributes_settings.rb +26 -0
  324. data/lib/permission_forge/configuration/presets.rb +139 -0
  325. data/lib/permission_forge/configuration/query_scoping_settings.rb +107 -0
  326. data/lib/permission_forge/configuration/strategy_values.rb +57 -0
  327. data/lib/permission_forge/configuration/virtual_attributes_settings.rb +65 -0
  328. data/lib/permission_forge/configuration.rb +624 -0
  329. data/lib/permission_forge/controller_actions/authorizer.rb +171 -0
  330. data/lib/permission_forge/controller_actions/managed_index.rb +213 -0
  331. data/lib/permission_forge/controller_actions/mapping.rb +167 -0
  332. data/lib/permission_forge/controller_actions.rb +122 -0
  333. data/lib/permission_forge/deprecation.rb +86 -0
  334. data/lib/permission_forge/engine.rb +113 -0
  335. data/lib/permission_forge/execution_state.rb +318 -0
  336. data/lib/permission_forge/invalidation_batch.rb +206 -0
  337. data/lib/permission_forge/masked_models.rb +226 -0
  338. data/lib/permission_forge/masking_sync.rb +271 -0
  339. data/lib/permission_forge/model_bindings.rb +90 -0
  340. data/lib/permission_forge/open_transaction.rb +303 -0
  341. data/lib/permission_forge/operators/constants.rb +41 -0
  342. data/lib/permission_forge/operators/registry.rb +300 -0
  343. data/lib/permission_forge/operators/rule_column.rb +120 -0
  344. data/lib/permission_forge/operators/validations.rb +111 -0
  345. data/lib/permission_forge/operators/value_casters/number.rb +68 -0
  346. data/lib/permission_forge/operators/value_casters/temporal.rb +69 -0
  347. data/lib/permission_forge/operators/value_casters.rb +80 -0
  348. data/lib/permission_forge/operators/value_typing.rb +115 -0
  349. data/lib/permission_forge/query_scoping_warnings.rb +68 -0
  350. data/lib/permission_forge/rake_audit.rb +65 -0
  351. data/lib/permission_forge/resource_chain.rb +190 -0
  352. data/lib/permission_forge/rls.rb +209 -0
  353. data/lib/permission_forge/services/attribute_access_guard.rb +138 -0
  354. data/lib/permission_forge/services/audit_event_purger.rb +150 -0
  355. data/lib/permission_forge/services/bulk_permission_set_assignment.rb +108 -0
  356. data/lib/permission_forge/services/concerns/across_tenants.rb +69 -0
  357. data/lib/permission_forge/services/concerns/audited_operation.rb +43 -0
  358. data/lib/permission_forge/services/concerns/clonable.rb +29 -0
  359. data/lib/permission_forge/services/consent_withdrawal_handler.rb +135 -0
  360. data/lib/permission_forge/services/dashboard_stats.rb +43 -0
  361. data/lib/permission_forge/services/data_subject_erasure.rb +306 -0
  362. data/lib/permission_forge/services/expired_assignment_cleaner.rb +152 -0
  363. data/lib/permission_forge/services/extra_field_defaults.rb +71 -0
  364. data/lib/permission_forge/services/field_permission_merger.rb +83 -0
  365. data/lib/permission_forge/services/filters_response_attributes.rb +144 -0
  366. data/lib/permission_forge/services/gdpr_field_auditor/text_report.rb +63 -0
  367. data/lib/permission_forge/services/gdpr_field_auditor.rb +223 -0
  368. data/lib/permission_forge/services/host_key_guard.rb +110 -0
  369. data/lib/permission_forge/services/mask_value.rb +93 -0
  370. data/lib/permission_forge/services/permission_cache_clearer.rb +174 -0
  371. data/lib/permission_forge/services/permission_hash_builder.rb +305 -0
  372. data/lib/permission_forge/services/permission_rule_applier.rb +192 -0
  373. data/lib/permission_forge/services/permission_set_cloner.rb +89 -0
  374. data/lib/permission_forge/services/permission_set_condition_evaluator.rb +102 -0
  375. data/lib/permission_forge/services/permission_set_group_cloner.rb +52 -0
  376. data/lib/permission_forge/services/permits_attributes/nested_records.rb +106 -0
  377. data/lib/permission_forge/services/permits_attributes.rb +270 -0
  378. data/lib/permission_forge/services/resource_field_generator.rb +93 -0
  379. data/lib/permission_forge/services/resource_with_fields_builder.rb +47 -0
  380. data/lib/permission_forge/services/rls_operator_sql.rb +131 -0
  381. data/lib/permission_forge/services/rls_policy_generator.rb +365 -0
  382. data/lib/permission_forge/services/security_audit/action_check.rb +157 -0
  383. data/lib/permission_forge/services/security_audit/assignment_check.rb +120 -0
  384. data/lib/permission_forge/services/security_audit/audit_trail_check.rb +166 -0
  385. data/lib/permission_forge/services/security_audit/base_check.rb +57 -0
  386. data/lib/permission_forge/services/security_audit/config_check.rb +212 -0
  387. data/lib/permission_forge/services/security_audit/integrity_check.rb +105 -0
  388. data/lib/permission_forge/services/security_audit/personal_data_check.rb +79 -0
  389. data/lib/permission_forge/services/security_audit/privilege_check.rb +67 -0
  390. data/lib/permission_forge/services/security_audit/rule_check.rb +141 -0
  391. data/lib/permission_forge/services/security_audit/text_report.rb +46 -0
  392. data/lib/permission_forge/services/security_audit/user_model_check.rb +67 -0
  393. data/lib/permission_forge/services/security_audit.rb +75 -0
  394. data/lib/permission_forge/services/stale_assignment_auditor.rb +68 -0
  395. data/lib/permission_forge/services/unscoped_query_detector.rb +132 -0
  396. data/lib/permission_forge/version.rb +3 -0
  397. data/lib/permission_forge/virtual_fields/discovery.rb +65 -0
  398. data/lib/permission_forge/virtual_fields.rb +102 -0
  399. data/lib/permission_forge.rb +464 -0
  400. data/lib/tasks/permission_forge.rake +31 -0
  401. data/lib/tasks/permission_forge_audit_events.rake +28 -0
  402. data/lib/tasks/permission_forge_cache.rake +26 -0
  403. data/lib/tasks/permission_forge_expiration.rake +30 -0
  404. data/lib/tasks/permission_forge_gdpr.rake +69 -0
  405. data/lib/tasks/permission_forge_rls.rake +38 -0
  406. metadata +515 -0
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # @api private
5
+ module Operators
6
+ # Strategy Pattern registry mapping operator names to query-building callables.
7
+ #
8
+ # Each operator receives (query, sanitized_column, rule, value) and returns
9
+ # an ActiveRecord::Relation. The value arrives typed for the column it is
10
+ # compared with (see {ValueTyping}): one value for the comparisons and the
11
+ # text of a LIKE pattern, an Array for `in` / `not_in` (the list) and
12
+ # `between` (the two bounds). To add a new operator, register it in
13
+ # {.strategies} and define the corresponding class method.
14
+ #
15
+ # @example Fetching and calling an operator
16
+ # strategy = PermissionForge::Operators::Registry.fetch('equal')
17
+ # strategy.call(query, '"customers"."status"', rule, 'active')
18
+ #
19
+ # @api public
20
+ module Registry
21
+ module_function
22
+
23
+ # @param name [String] operator constant from PermissionSetRule (e.g., 'equal')
24
+ # @return [#call] callable strategy
25
+ # @raise [ArgumentError] if operator is unknown
26
+ def fetch(name)
27
+ strategies.fetch(name) { raise ArgumentError, "Unknown operator: #{name}" }
28
+ end
29
+
30
+ # @return [Hash<String, #call>] frozen operator lookup table
31
+ def strategies
32
+ @strategies ||= build_strategies.freeze
33
+ end
34
+
35
+ # Resets the memoized strategies hash. Useful in tests after defining custom operators.
36
+ def reset!
37
+ @strategies = nil
38
+ end
39
+
40
+ # Wraps a pre-quoted column reference in Arel.sql so that downstream
41
+ # .where calls treat it as a trusted SQL literal. The column has already
42
+ # been quoted with the connection's real identifier quoting
43
+ # (quote_table_name / quote_column_name) in
44
+ # PermissionForge::Services::PermissionRuleApplier before reaching the
45
+ # Registry — values are still bound as parameters, never interpolated.
46
+ #
47
+ # @param col [String] already-quoted column (e.g. '"table"."column"')
48
+ # @return [Arel::Nodes::SqlLiteral]
49
+ def arel_col(col)
50
+ Arel.sql(col)
51
+ end
52
+
53
+ # Wraps a bare Equality predicate in Grouping. Required for default_scope
54
+ # compatibility: Model.new/create runs scope_for_create, whose
55
+ # WhereClause#to_h(equality_only: true) dereferences `.relation` on
56
+ # top-level Equality nodes — our left operand is a SqlLiteral (no
57
+ # `.relation`), which would raise NoMethodError on every create for a
58
+ # scoped user. `to_h` skips Grouping nodes, so wrapping makes the
59
+ # predicate invisible to create-attribute inference while leaving the
60
+ # read-path SQL semantically identical: (("t"."col" = ?)).
61
+ #
62
+ # @param predicate [Arel::Nodes::Equality]
63
+ # @return [Arel::Nodes::Grouping]
64
+ def grouped(predicate)
65
+ Arel::Nodes::Grouping.new(predicate)
66
+ end
67
+
68
+ # --- Operator implementations ---
69
+
70
+ def op_equal(query, col, rule, val)
71
+ column = arel_col(col)
72
+ return query.where(column.eq(val).or(column.eq(nil))) if rule[:include_nulls]
73
+
74
+ query.where(grouped(column.eq(val)))
75
+ end
76
+
77
+ # The `not_equal` operator strategy: `(query, col, rule, val)` returning a relation.
78
+ # @return [ActiveRecord::Relation]
79
+ # @api private
80
+ def op_not_equal(query, col, rule, val)
81
+ column = arel_col(col)
82
+ base = query.where(column.not_eq(val))
83
+ return base.or(query.where(column.eq(nil))) if rule[:include_nulls]
84
+
85
+ base
86
+ end
87
+
88
+ # The `in` operator strategy: `(query, col, rule, val)` returning a relation.
89
+ # @return [ActiveRecord::Relation]
90
+ # @api private
91
+ def op_in(query, col, rule, val)
92
+ column = arel_col(col)
93
+ values = list_values(rule, val)
94
+ return query.where(column.in(values).or(column.eq(nil))) if rule[:include_nulls]
95
+
96
+ query.where(column.in(values))
97
+ end
98
+
99
+ # The `not_in` operator strategy: `(query, col, rule, val)` returning a relation.
100
+ # @return [ActiveRecord::Relation]
101
+ # @api private
102
+ def op_not_in(query, col, rule, val)
103
+ column = arel_col(col)
104
+ values = list_values(rule, val)
105
+ return query.where(column.not_in(values).or(column.eq(nil))) if rule[:include_nulls]
106
+
107
+ query.where(column.not_in(values))
108
+ end
109
+
110
+ # The `contains` operator strategy: `(query, col, rule, val)` returning a relation.
111
+ # @return [ActiveRecord::Relation]
112
+ # @api private
113
+ def op_contains(query, col, _rule, val)
114
+ column = arel_col(col)
115
+ query.where(column.matches("%#{sanitize_like(val)}%", LIKE_ESCAPE))
116
+ end
117
+
118
+ # The `not_contains` operator strategy: `(query, col, rule, val)` returning a relation.
119
+ # @return [ActiveRecord::Relation]
120
+ # @api private
121
+ def op_not_contains(query, col, _rule, val)
122
+ column = arel_col(col)
123
+ query.where(column.does_not_match("%#{sanitize_like(val)}%", LIKE_ESCAPE))
124
+ end
125
+
126
+ # The `starts_with` operator strategy: `(query, col, rule, val)` returning a relation.
127
+ # @return [ActiveRecord::Relation]
128
+ # @api private
129
+ def op_starts_with(query, col, _rule, val)
130
+ column = arel_col(col)
131
+ query.where(column.matches("#{sanitize_like(val)}%", LIKE_ESCAPE))
132
+ end
133
+
134
+ # The `ends_with` operator strategy: `(query, col, rule, val)` returning a relation.
135
+ # @return [ActiveRecord::Relation]
136
+ # @api private
137
+ def op_ends_with(query, col, _rule, val)
138
+ column = arel_col(col)
139
+ query.where(column.matches("%#{sanitize_like(val)}", LIKE_ESCAPE))
140
+ end
141
+
142
+ # The node is built here, not from a Ruby Range: the bounds are already
143
+ # typed, and not every pair of them makes a Range (two booleans do not).
144
+ def op_between(query, col, rule, val)
145
+ bounds = range_values(rule, val).map { |bound| Arel::Nodes.build_quoted(bound) }
146
+ query.where(Arel::Nodes::Between.new(arel_col(col), Arel::Nodes::And.new(bounds)))
147
+ end
148
+
149
+ # The `less_than` operator strategy: `(query, col, rule, val)` returning a relation.
150
+ # @return [ActiveRecord::Relation]
151
+ # @api private
152
+ def op_less_than(query, col, _rule, val)
153
+ query.where(arel_col(col).lt(val))
154
+ end
155
+
156
+ # The `greater_than` operator strategy: `(query, col, rule, val)` returning a relation.
157
+ # @return [ActiveRecord::Relation]
158
+ # @api private
159
+ def op_greater_than(query, col, _rule, val)
160
+ query.where(arel_col(col).gt(val))
161
+ end
162
+
163
+ # The `less_than_or_equal` operator strategy: `(query, col, rule, val)` returning a relation.
164
+ # @return [ActiveRecord::Relation]
165
+ # @api private
166
+ def op_less_than_or_equal(query, col, _rule, val)
167
+ query.where(arel_col(col).lteq(val))
168
+ end
169
+
170
+ # The `greater_than_or_equal` operator strategy: `(query, col, rule, val)` returning a relation.
171
+ # @return [ActiveRecord::Relation]
172
+ # @api private
173
+ def op_greater_than_or_equal(query, col, _rule, val)
174
+ query.where(arel_col(col).gteq(val))
175
+ end
176
+
177
+ # The `is_null` operator strategy: `(query, col, rule, val)` returning a relation.
178
+ # @return [ActiveRecord::Relation]
179
+ # @api private
180
+ def op_is_null(query, col, _rule, _val)
181
+ query.where(grouped(arel_col(col).eq(nil)))
182
+ end
183
+
184
+ # The `is_not_null` operator strategy: `(query, col, rule, val)` returning a relation.
185
+ # @return [ActiveRecord::Relation]
186
+ # @api private
187
+ def op_is_not_null(query, col, _rule, _val)
188
+ query.where(arel_col(col).not_eq(nil))
189
+ end
190
+
191
+ # --- Helpers ---
192
+
193
+ def parse_list_value(rule)
194
+ rule[:field_value].to_s.split(',').map(&:strip)
195
+ end
196
+
197
+ # The list as PermissionRuleApplier typed it for the column. A strategy
198
+ # called directly with anything else reads the rule's own text, as it
199
+ # did before values were typed.
200
+ def list_values(rule, val)
201
+ val.is_a?(Array) ? val : parse_list_value(rule)
202
+ end
203
+
204
+ # The two bounds, likewise.
205
+ def range_values(rule, val)
206
+ return val if val.is_a?(Array)
207
+
208
+ lower, upper = parse_list_value(rule)
209
+ coerce_between_values(lower, upper)
210
+ end
211
+
212
+ # `%`, `_` and the escape character itself match literally. The pattern
213
+ # only means that with an ESCAPE clause: SQLite has no default escape
214
+ # character, and neither has MySQL under NO_BACKSLASH_ESCAPES. The
215
+ # adapter quotes the clause, which is what keeps it one backslash there
216
+ # (trilogy does; the mysql2 driver cannot quote any text in that mode).
217
+ LIKE_ESCAPE = '\\'
218
+
219
+ # Escapes `%`, `_` and the escape character so a value is matched literally by LIKE.
220
+ # @return [String]
221
+ # @api private
222
+ def sanitize_like(value)
223
+ value.to_s.gsub(/[%_\\]/) { |char| "#{LIKE_ESCAPE}#{char}" }
224
+ end
225
+
226
+ INTEGER_PATTERN = /\A-?\d+\z/
227
+ FLOAT_PATTERN = /\A-?\d+\.\d+\z/
228
+ DATE_PATTERN = /\A\d{4}-\d{2}-\d{2}\z/
229
+
230
+ COERCION_RULES = [
231
+ [INTEGER_PATTERN, lambda(&:to_i)],
232
+ [FLOAT_PATTERN, lambda(&:to_f)],
233
+ [DATE_PATTERN, ->(v) { Date.parse(v) }]
234
+ ].freeze
235
+
236
+ # Types the two bounds of a `between` rule the same way (dates, times, numbers).
237
+ # @return [Array]
238
+ # @api private
239
+ def coerce_between_values(lower, upper)
240
+ COERCION_RULES.each do |pattern, coercer|
241
+ next unless lower.match?(pattern) && upper.match?(pattern)
242
+
243
+ return [coercer.call(lower), coercer.call(upper)]
244
+ end
245
+ [lower, upper]
246
+ rescue Date::Error
247
+ [lower, upper]
248
+ end
249
+
250
+ # Builds the operator-to-strategy mapping by merging categorized sub-groups.
251
+ def build_strategies
252
+ comparison_operators.merge(set_operators).merge(string_operators).merge(range_and_null_operators)
253
+ end
254
+
255
+ # @return [Hash{String => Method}] the equality and ordering operators
256
+ # @api private
257
+ def comparison_operators
258
+ psr = PermissionForge::PermissionSetRule
259
+ {
260
+ psr::EQUAL => method(:op_equal),
261
+ psr::NOT_EQUAL => method(:op_not_equal),
262
+ psr::LESS_THAN => method(:op_less_than),
263
+ psr::GREATER_THAN => method(:op_greater_than),
264
+ psr::LESS_THAN_OR_EQUAL => method(:op_less_than_or_equal),
265
+ psr::GREATER_THAN_OR_EQUAL => method(:op_greater_than_or_equal)
266
+ }
267
+ end
268
+
269
+ # @return [Hash{String => Method}] `in` and `not_in`
270
+ # @api private
271
+ def set_operators
272
+ psr = PermissionForge::PermissionSetRule
273
+ { psr::IN => method(:op_in), psr::NOT_IN => method(:op_not_in) }
274
+ end
275
+
276
+ # @return [Hash{String => Method}] `contains`, `not_contains`, `starts_with` and `ends_with`
277
+ # @api private
278
+ def string_operators
279
+ psr = PermissionForge::PermissionSetRule
280
+ {
281
+ psr::CONTAINS => method(:op_contains),
282
+ psr::NOT_CONTAINS => method(:op_not_contains),
283
+ psr::STARTS_WITH => method(:op_starts_with),
284
+ psr::ENDS_WITH => method(:op_ends_with)
285
+ }
286
+ end
287
+
288
+ # @return [Hash{String => Method}] `between`, `is_null` and `is_not_null`
289
+ # @api private
290
+ def range_and_null_operators
291
+ psr = PermissionForge::PermissionSetRule
292
+ {
293
+ psr::BETWEEN => method(:op_between),
294
+ psr::IS_NULL => method(:op_is_null),
295
+ psr::IS_NOT_NULL => method(:op_is_not_null)
296
+ }
297
+ end
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Operators
5
+ # The model column a rule's field_name names, as far as it can be told:
6
+ # what {ValueTyping} goes by to give the rule's value that column's type.
7
+ #
8
+ # A plain name is a column of the model the rule ends on — its own, or the
9
+ # last one on its association path. A dotted name (`customers.supplier_id`,
10
+ # `public.customers.supplier_id`) says which table: the rule's own or one
11
+ # along the path; a schema or database before it is not looked at. The
12
+ # column is looked up the way the database resolves the name: among the
13
+ # table's columns (a model's `ignored_columns` are still columns), and
14
+ # without regard to case where the database has none ({EXACT_NAMES}) — a
15
+ # name the database resolves has to be typed, or its value is compared as
16
+ # text after all. A lookup that is more generous than the database costs
17
+ # nothing: the database then rejects the name itself.
18
+ #
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.
22
+ class RuleColumn
23
+ # Adapters whose database compares a quoted name as it is written. On
24
+ # every other one — MySQL, SQLite — `Flag` is the column `flag`.
25
+ EXACT_NAMES = %w[PostgreSQL PostGIS].freeze
26
+
27
+ # @return [ActiveRecord::ConnectionAdapters::Column, nil]
28
+ attr_reader :column
29
+ # @return [Hash, nil] label => stored value, when the model declares the column an enum
30
+ attr_reader :enum
31
+
32
+ class << self
33
+ # A field name is one name, or several joined by dots. An empty one
34
+ # among them — a leading, trailing or doubled dot, or nothing at all —
35
+ # makes it no name: quoted segment by segment it would be another
36
+ # name than the one that was written.
37
+ def name?(field_name)
38
+ segments = field_name.to_s.split('.', -1)
39
+ segments.any? && segments.none?(&:empty?)
40
+ end
41
+
42
+ # @param field_name [String] the rule's field_name
43
+ # @param models [Array<Class>] the rule's model, then the model of each
44
+ # association on its path (see {.models_along})
45
+ # @return [RuleColumn]
46
+ def find(field_name, models)
47
+ return MALFORMED unless name?(field_name)
48
+
49
+ *qualifier, name = field_name.to_s.split('.')
50
+ model = table_model(qualifier.last, models)
51
+ model ? on(model, name) : UNKNOWN
52
+ end
53
+
54
+ # 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.
56
+ #
57
+ # @param model [Class] the rule's model
58
+ # @param association_chain [Array<String>] the rule's field_accessor_path, split
59
+ # @return [Array<Class>] empty when the path cannot be followed
60
+ 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
64
+
65
+ models << joined
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ # The model a table name stands for; without one, the model the rule ends on.
72
+ def table_model(table, models)
73
+ return models.last unless table
74
+
75
+ models.find { |model| same_name?(model.table_name.to_s.split('.').last, table, exact_names?(model)) }
76
+ end
77
+
78
+ 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)
82
+ end
83
+
84
+ def column_named(columns, name, exact)
85
+ columns[name] || columns.each_value.find { |column| same_name?(column.name, name, exact) }
86
+ end
87
+
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)
94
+ end
95
+ end
96
+
97
+ def initialize(column, enum, adapter_name)
98
+ @column = column
99
+ @enum = enum
100
+ @adapter_name = adapter_name
101
+ end
102
+
103
+ # Nothing is known about the column: no table of the rule, or no such column.
104
+ UNKNOWN = new(nil, nil, nil).freeze
105
+ # The rule's field_name is not a name.
106
+ MALFORMED = new(nil, nil, nil).freeze
107
+
108
+ def malformed?
109
+ equal?(MALFORMED)
110
+ end
111
+
112
+ # The caster for the column's type, on the database it is in.
113
+ #
114
+ # @return [#call] see ValueCasters.for
115
+ def caster
116
+ ValueCasters.for(column, @adapter_name)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,111 @@
1
+ module PermissionForge
2
+ module Operators
3
+ # Operator-specific checks of a rule's `field_value` (`between` takes exactly two values, `in` at least one).
4
+ # Add a check with one entry in `VALIDATORS`.
5
+ #
6
+ # @api public
7
+ module Validations
8
+ VALUE_REQUIRED_MESSAGE = lambda {
9
+ I18n.t('permission_forge.validations.permission_set_rule.value_required',
10
+ default: 'must not be blank for this operator')
11
+ }
12
+
13
+ NUMERIC_REQUIRED_MESSAGE = lambda {
14
+ I18n.t('permission_forge.validations.permission_set_rule.numeric_or_date_required',
15
+ default: 'must be a numeric or date value for comparison operators')
16
+ }
17
+
18
+ VALUE_NOT_EXPECTED_MESSAGE = lambda {
19
+ I18n.t('permission_forge.validations.permission_set_rule.value_not_expected',
20
+ default: 'does not require a value for this operator')
21
+ }
22
+
23
+ SINGLE_VALUE_VALIDATOR = lambda { |values|
24
+ return unless values.empty?
25
+
26
+ { field: :field_value, message: VALUE_REQUIRED_MESSAGE.call }
27
+ }
28
+
29
+ NUMERIC_VALIDATOR = lambda { |values|
30
+ return if values.empty?
31
+ return if values.all? { |v| v.match?(/\A-?\d+(\.\d+)?\z/) || parseable_date?(v) }
32
+
33
+ { field: :field_value, message: NUMERIC_REQUIRED_MESSAGE.call }
34
+ }
35
+
36
+ NULL_OPERATOR_VALIDATOR = lambda { |values|
37
+ return if values.empty?
38
+
39
+ { field: :field_value, message: VALUE_NOT_EXPECTED_MESSAGE.call }
40
+ }
41
+
42
+ VALIDATORS = {
43
+ 'between' => lambda { |values|
44
+ return if values.size == 2
45
+
46
+ { field: :field_value,
47
+ message: I18n.t('permission_forge.validations.permission_set_rule.between_requires_two_values',
48
+ default: 'must contain exactly 2 comma-separated values for between operator') }
49
+ },
50
+ 'in' => lambda { |values|
51
+ return unless values.empty?
52
+
53
+ { field: :field_value,
54
+ message: I18n.t('permission_forge.validations.permission_set_rule.in_requires_values',
55
+ default: 'must contain at least 1 comma-separated value') }
56
+ },
57
+ 'not_in' => lambda { |values|
58
+ return unless values.empty?
59
+
60
+ { field: :field_value,
61
+ message: I18n.t('permission_forge.validations.permission_set_rule.in_requires_values',
62
+ default: 'must contain at least 1 comma-separated value') }
63
+ },
64
+ 'equal' => SINGLE_VALUE_VALIDATOR,
65
+ 'not_equal' => SINGLE_VALUE_VALIDATOR,
66
+ 'contains' => SINGLE_VALUE_VALIDATOR,
67
+ 'not_contains' => SINGLE_VALUE_VALIDATOR,
68
+ 'starts_with' => SINGLE_VALUE_VALIDATOR,
69
+ 'ends_with' => SINGLE_VALUE_VALIDATOR,
70
+ 'greater_than' => NUMERIC_VALIDATOR,
71
+ 'less_than' => NUMERIC_VALIDATOR,
72
+ 'greater_than_or_equal' => NUMERIC_VALIDATOR,
73
+ 'less_than_or_equal' => NUMERIC_VALIDATOR,
74
+ 'is_null' => NULL_OPERATOR_VALIDATOR,
75
+ 'is_not_null' => NULL_OPERATOR_VALIDATOR
76
+ }.freeze
77
+
78
+ # The operators that take no value (the null checks): the ones whose
79
+ # validator refuses any value.
80
+ VALUELESS = VALIDATORS.select { |_, validator| validator.equal?(NULL_OPERATOR_VALIDATOR) }.keys.freeze
81
+
82
+ # @return [Boolean] whether `operator` takes no value
83
+ def self.valueless?(operator)
84
+ VALUELESS.include?(operator.to_s)
85
+ end
86
+
87
+ # @param operator [String] an operator of PermissionSetRule
88
+ # @param field_value [String] the rule's value
89
+ # @return [Hash, nil] `{ field:, message: }` when the value is invalid for the operator, else nil
90
+ def self.validate(operator, field_value)
91
+ return if operator.blank? || field_value.blank?
92
+ # 'current_user' is a special substitution token valid for all operators
93
+ return if field_value.to_s.strip == 'current_user'
94
+
95
+ validator = VALIDATORS[operator]
96
+ return unless validator
97
+
98
+ parsed = field_value.to_s.split(',').map(&:strip).reject(&:blank?)
99
+ validator.call(parsed)
100
+ end
101
+
102
+ def self.parseable_date?(value)
103
+ Date.parse(value)
104
+ true
105
+ rescue ArgumentError, TypeError
106
+ false
107
+ end
108
+ private_class_method :parseable_date?
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module PermissionForge
6
+ module Operators
7
+ module ValueCasters
8
+ # Casts the text of a number for an integer, decimal or float column.
9
+ module Number
10
+ # A plain decimal, or one in scientific notation (`1e3`): every
11
+ # database reads both.
12
+ FORMAT = /\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d{1,3})?\z/
13
+ BIGINT = (-(2**63)..((2**63) - 1))
14
+ # Past the range of a double the databases part ways again: PostgreSQL
15
+ # raises on a float column, MySQL and SQLite round a tiny number to zero.
16
+ DOUBLE = (Float::MIN..Float::MAX)
17
+ # What a single-precision float holds, a little short of both ends.
18
+ SINGLE = (1.1754944e-38..3.4028234e38)
19
+
20
+ # A whole number that fits 64 bits is an Integer, however it is written
21
+ # (`50`, `50.0`, `5e1`): compared with an integer column it can use the
22
+ # column's index, where a decimal makes PostgreSQL cast every row. Any
23
+ # other number — a fraction, one past that range — is a BigDecimal,
24
+ # which every adapter writes as an exact numeric literal: the database
25
+ # compares numbers (`supplier_id < 5.5`, `supplier_id <
26
+ # 99999999999999999999`) and never casts text to the column's type.
27
+ EXACT = lambda do |value|
28
+ text = value.to_s.strip
29
+ next UNUSABLE unless text.match?(FORMAT)
30
+
31
+ decimal = BigDecimal(text)
32
+ next decimal.to_i if decimal.frac.zero? && BIGINT.cover?(decimal)
33
+
34
+ decimal.zero? || DOUBLE.cover?(decimal.abs) ? decimal : UNUSABLE
35
+ end
36
+
37
+ # For a PostgreSQL `real` column. PostgreSQL compares it with a number
38
+ # in double precision, where 0.1 is not the 0.1 the column stores; text
39
+ # it reads as a `real`. So the rule's text is handed over as the text
40
+ # of the number it was checked to be, and compared in single precision
41
+ # — as it was before values were typed. Two things stay numbers: the
42
+ # id `current_user` stands for, and a number no `real` can hold, which
43
+ # equals none and on which PostgreSQL would raise as text. MySQL
44
+ # compares a FLOAT in double precision whatever it gets, and SQLite
45
+ # has doubles only: there the number goes as a number (EXACT).
46
+ SINGLE_PRECISION = lambda do |value|
47
+ number = EXACT.call(value)
48
+ next number unless value.is_a?(String) && !number.equal?(UNUSABLE)
49
+ next number unless number.zero? || SINGLE.cover?(number.abs)
50
+
51
+ number.is_a?(Integer) ? number.to_s : number.to_s('F')
52
+ end
53
+
54
+ # The two bounds of `between` on a PostgreSQL `real` column, likewise
55
+ # as they always were: two plain integers or two plain decimals went
56
+ # to PostgreSQL as numbers (the reading still made where nothing is
57
+ # known about a column), any other pair as text.
58
+ SINGLE_PRECISION_BOUNDS = lambda do |bounds|
59
+ numbers = bounds.map { |bound| EXACT.call(bound) }
60
+ next UNUSABLE if numbers.include?(UNUSABLE)
61
+
62
+ plain = Registry.coerce_between_values(*bounds).first.is_a?(Numeric)
63
+ plain ? numbers : bounds.map { |bound| SINGLE_PRECISION.call(bound) }
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Operators
5
+ module ValueCasters
6
+ # Casts ISO 8601 text for a date, time or datetime column.
7
+ #
8
+ # The text is checked here (the notation, a real calendar day) and cast by
9
+ # the ActiveRecord type of the column, so the adapter quotes the result the
10
+ # way it stores that column: a date-time without an offset is read in the
11
+ # database's time zone (`ActiveRecord.default_timezone`), one with an
12
+ # offset is converted to it, and SQLite gets the text it compares with.
13
+ # A date column takes the day of a date-time, a time column the time of
14
+ # day. Anything else — another notation, `now`, `tomorrow`, the id that
15
+ # `current_user` stands for — is UNUSABLE, unless the caster is the one
16
+ # for a database that reads such text itself ({#as_written}).
17
+ class Temporal
18
+ DAY = /(?<year>\d{4})-(?<month>\d\d?)-(?<day>\d\d?)/
19
+ CLOCK = /(?:[01]?\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?/
20
+ OFFSET = /Z|[+-]\d\d(?::?\d\d)?/
21
+ DAY_AND_CLOCK = /\A#{DAY}(?:[T ]#{CLOCK}(?: ?#{OFFSET})?)?\z/
22
+ CLOCK_ONLY = /\A(?:\d{4}-\d\d-\d\d[T ])?#{CLOCK}\z/
23
+
24
+ # @param format [Regexp] what the text has to look like
25
+ # @param type [ActiveModel::Type::Value] casts the text and serializes the result
26
+ # @param as_written [Boolean] hand text of another notation over as it is
27
+ def initialize(format, type, as_written: false)
28
+ @format = format
29
+ @type = type
30
+ @as_written = as_written
31
+ end
32
+
33
+ def call(value)
34
+ return UNUSABLE unless value.is_a?(String)
35
+
36
+ recognised(value.strip) || (@as_written ? value : UNUSABLE)
37
+ end
38
+
39
+ # The same caster for a database that reads date and time text itself
40
+ # and raises on what it cannot read (PostgreSQL). Text this caster does
41
+ # not recognise is handed over as it is written, as it was before
42
+ # values were typed: `today`, a date in a local notation. What is not
43
+ # text stays UNUSABLE.
44
+ #
45
+ # @return [Temporal]
46
+ def as_written
47
+ self.class.new(@format, @type, as_written: true)
48
+ end
49
+
50
+ DATE = new(DAY_AND_CLOCK, ActiveRecord::Type::Date.new)
51
+ DATETIME = new(DAY_AND_CLOCK, ActiveRecord::Type::DateTime.new)
52
+ TIME = new(CLOCK_ONLY, ActiveRecord::Type::Time.new)
53
+
54
+ private
55
+
56
+ def recognised(text)
57
+ parts = @format.match(text)
58
+ @type.serialize(@type.cast(text)) if parts && real_day?(parts)
59
+ end
60
+
61
+ # A time of day names no day; a date has to be one of the calendar.
62
+ def real_day?(parts)
63
+ year, month, day = parts.named_captures.values_at('year', 'month', 'day')
64
+ year.nil? || Date.valid_date?(year.to_i, month.to_i, day.to_i)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end