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
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Operators
5
+ # The English of RuleProblem#validation_message, by RuleProblem#message_key
6
+ # (translated under permission_forge.validations.permission_set_rule).
7
+ RULE_PROBLEM_MESSAGES = {
8
+ unknown_association: 'names %<association>s, which is not an association of %<model>s',
9
+ empty_association: 'has an empty association name (two dots in a row, or a dot at the start)',
10
+ unjoinable_association: 'names %<association>s, an association of %<model>s that a rule cannot join',
11
+ unjoinable_invalid_definition: 'names %<association>s, an association of %<model>s whose :through, :source ' \
12
+ 'or inverse_of names an association that is not there',
13
+ unjoinable_polymorphic: 'names %<association>s, a polymorphic association of %<model>s: a rule cannot join it',
14
+ unjoinable_record_dependent_scope: 'names %<association>s, an association of %<model>s whose scope (or one ' \
15
+ 'along its :through) takes the record: a rule cannot join it',
16
+ unjoinable_class_not_loadable: 'names %<association>s, an association of %<model>s whose class cannot be loaded',
17
+ unknown_column: 'names %<name>s, which is not a column of %<model>s',
18
+ unknown_table: 'names %<name>s, which is no table of this database',
19
+ foreign_table: 'names %<name>s, a table that is not %<model>s\'s: a filter rule compares its own table. For a column ' \
20
+ 'of another table, use a join rule (a join type and the relationship path)',
21
+ invalid_operator: 'is not a valid operator',
22
+ invalid_join_type: 'is not a valid join type',
23
+ encrypted_column: 'names %<name>s, an encrypted attribute of %<model>s: the database holds its ciphertext, so a ' \
24
+ 'rule can only check whether it is empty (is_null, is_not_null)',
25
+ pattern_on_non_text: 'matches text only, and %<model>s.%<name>s is a column of type %<type>s: use a condition ' \
26
+ 'that compares values',
27
+ unusable_value: 'is not a value %<model>s.%<name>s can hold'
28
+ }.freeze
29
+
30
+ # Why a saved rule matches nothing: the one taxonomy of a "broken" rule
31
+ # ({KINDS}), which the model's validations, the applier's `none`
32
+ # (Services::PermissionRuleApplier), the audit (Services::SecurityAudit's
33
+ # rule check), the admin's Home ("Rules that match nothing"), the admin's
34
+ # rule sentences and the RLS policy's `FALSE` all read.
35
+ #
36
+ # - :unknown_join_type — a join type none of PermissionRuleApplier::JOIN_METHODS;
37
+ # `name` is the join type as stored;
38
+ # - :unknown_association / :unjoinable_association — a join rule whose
39
+ # association path cannot be followed ({RulePath#broken?}); `name` is
40
+ # the segment, `model` the model it was looked up in, `cause` why
41
+ # ({RulePath#cause}: :empty_segment, or one of
42
+ # RulePath::UNJOINABLE_CAUSES);
43
+ # - :unknown_operator — an operator the Registry does not have; `name`
44
+ # is the operator as stored;
45
+ # - :missing_column — a field_name that is no column of its table
46
+ # ({RuleColumn#missing?}), or not a name at all ({RuleColumn#malformed?});
47
+ # `name` is the column as written, `model` the model whose table lacks it;
48
+ # - :unknown_table — a field_name qualified with a name that is none of
49
+ # the rule's tables, none its join gives and no table of the database
50
+ # ({RuleColumn#unresolvable?}); `name` is the qualifier;
51
+ # - :foreign_table — a filter rule's field_name qualified with a real
52
+ # table of another model ({RuleColumn#foreign?}): a filter rule compares
53
+ # its model's own table; `name` is the qualifier;
54
+ # - :encrypted_column — a built-in operator that takes a value, on a
55
+ # column that holds ciphertext ({RuleColumn#encrypted?}: an attribute
56
+ # declared with `encrypts`); `name` is the column. Refused for a new
57
+ # rule and when its column, path, join type or model changes or its
58
+ # operator moves off the null checks (on `field_name`);
59
+ # - :pattern_on_non_text — a LIKE operator (contains, starts_with, ...) on
60
+ # a column no pattern can be matched against ({RuleColumn#pattern?}: a
61
+ # json column, and on PostgreSQL every column that is not text);
62
+ # `name` is the column, `column_type` its SQL type. Refused like a
63
+ # value (on `operator`);
64
+ # - :unusable_value — a value the column cannot hold ({ValueTyping::UNUSABLE});
65
+ # `name` is the column, `value` the value as stored. Refused for a new
66
+ # or changed rule (on `field_value`); a stored one is listed and denied.
67
+ #
68
+ # The kinds are checked in the order the applier decides them, so a rule
69
+ # with several problems is named by the one that denies it first. A rule
70
+ # PermissionSetRule refuses on save never gets here; one saved before the
71
+ # check, or written past the model, does. Whatever cannot be told — a
72
+ # Resource named after no model, a model file that raises while it loads
73
+ # — is no column or value problem here (see RuleColumn.for_rule): the
74
+ # admin and the audit describe, they never fail on a model the
75
+ # application cannot load. An operator or join type is judged whatever
76
+ # the model.
77
+ #
78
+ # @example
79
+ # problem = RuleProblem.for(rule)
80
+ # problem&.kind # => :missing_column
81
+ # problem&.name # => "stauts"
82
+ # problem&.key # => :missing_column
83
+ class RuleProblem < Data.define(:kind, :name, :model, :cause, :value, :column_type)
84
+ # Every kind, in the order the applier decides them. A new kind needs a
85
+ # fixture in test/support/rule_problem_fixtures.rb, an entry in
86
+ # RULE_PROBLEM_MESSAGES (or MESSAGE_KEYS), one in REMEDIES, and the
87
+ # message and the `ui.rules.problems` sentence in en, sq and mk.
88
+ #
89
+ # The value kinds come last and in ValueTyping.problem's order: an
90
+ # encrypted column or a LIKE on a column that is not text makes every
91
+ # value UNUSABLE, so they are told apart before :unusable_value.
92
+ KINDS = %i[unknown_join_type unknown_association unjoinable_association unknown_operator missing_column
93
+ unknown_table foreign_table encrypted_column pattern_on_non_text unusable_value].freeze
94
+ # What the admin changes to correct the rule, by kind — the rule
95
+ # builder's control (`permission_set_rules.builder.problem_fixes.<remedy>`):
96
+ # the join type (Advanced), the relationship, the condition, the field,
97
+ # or the value.
98
+ REMEDIES = { unknown_join_type: :join, unknown_association: :path, unjoinable_association: :path,
99
+ unknown_operator: :operator, missing_column: :field, unknown_table: :field,
100
+ foreign_table: :path, encrypted_column: :field, pattern_on_non_text: :operator,
101
+ unusable_value: :value }.freeze
102
+ # The kinds that are the rule's association path (not its column or value).
103
+ PATH_KINDS = %i[unknown_association unjoinable_association].freeze
104
+ # The kinds whose `name` is a name that is not there, shown as stored.
105
+ VERBATIM_KINDS = %i[missing_column unknown_association unknown_operator unknown_join_type unknown_table foreign_table].freeze
106
+ # key => the validations.permission_set_rule.* message, where it is not the key itself.
107
+ MESSAGE_KEYS = { missing_column: :unknown_column, unknown_operator: :invalid_operator,
108
+ unknown_join_type: :invalid_join_type }.freeze
109
+ # What `current_user` is typed as when a stored value is judged: an id
110
+ # is a number, a uuid or the text of either, whoever the user is — never
111
+ # a boolean, a date, an address or an enum label. The value is unusable
112
+ # only when it is for every one of them.
113
+ ANY_USER_IDS = [0, '00000000-0000-0000-0000-000000000000'].freeze
114
+
115
+ # `cause`, `value` and `column_type` are optional: a column problem has
116
+ # no cause, only a value problem a value, only a LIKE on a column that
117
+ # is not text a column type.
118
+ OPTIONAL = { cause: nil, value: nil, column_type: nil }.freeze
119
+ private_constant :OPTIONAL
120
+
121
+ def initialize(kind:, name:, model:, **optional)
122
+ super(kind:, name:, model:, **OPTIONAL, **optional)
123
+ end
124
+
125
+ # @param rule [PermissionForge::PermissionSetRule]
126
+ # @return [RuleProblem, nil]
127
+ def self.for(rule)
128
+ of_join_type(rule) || of_path(rule) || of_operator(rule) || of_column_or_value(rule)
129
+ end
130
+
131
+ # A join rule whose join type is none the applier joins by.
132
+ #
133
+ # @param rule [PermissionForge::PermissionSetRule]
134
+ # @return [RuleProblem, nil]
135
+ def self.of_join_type(rule)
136
+ return unless rule.join_rule?
137
+
138
+ join_type = rule.join_type.to_s
139
+ return if Services::PermissionRuleApplier::JOIN_METHODS.key?(join_type)
140
+
141
+ new(kind: :unknown_join_type, name: join_type, model: RuleColumn.rule_model(rule))
142
+ end
143
+
144
+ # The rule's path problem, nil when it has none (a filter rule never
145
+ # has one: its path is not joined).
146
+ #
147
+ # @param rule [PermissionForge::PermissionSetRule]
148
+ # @return [RuleProblem, nil]
149
+ def self.of_path(rule)
150
+ model = RuleColumn.rule_model(rule)
151
+ return unless model && rule.join_rule?
152
+
153
+ path = RulePath.follow(model, RuleColumn.path_segments(rule.field_accessor_path))
154
+ new(kind: path.reason, name: path.segment, model: path.owner, cause: path.cause) if path.broken?
155
+ rescue *RuleColumn::UNTELLABLE
156
+ nil
157
+ end
158
+
159
+ # An operator the Registry does not have.
160
+ #
161
+ # @param rule [PermissionForge::PermissionSetRule]
162
+ # @return [RuleProblem, nil]
163
+ def self.of_operator(rule)
164
+ operator = rule.operator.to_s
165
+ return if Registry.strategies.key?(operator)
166
+
167
+ new(kind: :unknown_operator, name: operator, model: RuleColumn.rule_model(rule))
168
+ end
169
+
170
+ # The rule's column problem, else its value problem — the column looked up once.
171
+ #
172
+ # @param rule [PermissionForge::PermissionSetRule]
173
+ # @return [RuleProblem, nil]
174
+ def self.of_column_or_value(rule)
175
+ column = RuleColumn.for_rule(rule)
176
+ of_column(rule, column) || of_value(rule, column)
177
+ end
178
+
179
+ # @param rule [PermissionForge::PermissionSetRule]
180
+ # @param column [RuleColumn, nil] the rule's column (RuleColumn.for_rule)
181
+ # @return [RuleProblem, nil]
182
+ def self.of_column(rule, column = RuleColumn.for_rule(rule))
183
+ return unless column
184
+
185
+ field_name = rule.field_name.to_s
186
+ return new(kind: :missing_column, name: field_name.split('.').last, model: column.model) if column.missing?
187
+
188
+ kind = column_kind(rule, column)
189
+ new(kind: kind, name: kind == :missing_column ? field_name : field_name.split('.')[-2], model: RuleColumn.rule_model(rule)) if kind
190
+ end
191
+
192
+ # The kind of a column problem named after the rule's own model: a
193
+ # name that is none, a qualifier naming no table, or — on a filter
194
+ # rule — another model's table.
195
+ def self.column_kind(rule, column)
196
+ if column.malformed? then :missing_column
197
+ elsif column.unresolvable? then :unknown_table
198
+ elsif column.foreign? && !rule.join_rule? then :foreign_table
199
+ end
200
+ end
201
+
202
+ # Why the rule's value is UNUSABLE for its column, as the applier types
203
+ # it (ValueTyping.problem; `current_user` as each of ANY_USER_IDS): an
204
+ # encrypted column, a LIKE operator on a column that is not text, or a
205
+ # value the column cannot hold. Only for a column that was found: the
206
+ # column's own problem comes first. The first two do not depend on the
207
+ # value; a blank value is no value problem (the presence validation's
208
+ # business; a rule being built has none yet).
209
+ #
210
+ # @param rule [PermissionForge::PermissionSetRule]
211
+ # @param column [RuleColumn, nil] the rule's column (RuleColumn.for_rule)
212
+ # @return [RuleProblem, nil]
213
+ def self.of_value(rule, column = RuleColumn.for_rule(rule))
214
+ return unless column_found?(column)
215
+
216
+ kind = value_kind(rule, column) or return
217
+ name = rule.field_name.to_s.split('.').last
218
+ model = column.model || RuleColumn.rule_model(rule)
219
+ return new(kind: kind, name: name, model: model, value: rule.field_value) if kind == :unusable_value
220
+
221
+ new(kind: kind, name: name, model: model, column_type: column.column&.sql_type)
222
+ end
223
+
224
+ def self.column_found?(column)
225
+ !column.nil? && !column.malformed? && !column.missing? && !column.unresolvable?
226
+ end
227
+
228
+ # The kind every reading of the value gives (ValueTyping.problem), or nil.
229
+ def self.value_kind(rule, column)
230
+ operator = rule.operator.to_s
231
+ value = rule.field_value
232
+ return ValueTyping.operator_problem(operator, column) if value.blank?
233
+
234
+ values = ValueTyping.current_user?(operator, value) ? ANY_USER_IDS : [value]
235
+ kinds = values.map { |typed| ValueTyping.problem(operator, typed, column) }
236
+ kinds.first if kinds.all?
237
+ rescue *RuleColumn::UNTELLABLE
238
+ nil
239
+ end
240
+ private_class_method :column_found?, :value_kind, :column_kind
241
+
242
+ # The rule's path and field name as stored (a filter rule's path is not joined).
243
+ #
244
+ # @param rule [PermissionForge::PermissionSetRule]
245
+ # @return [String] e.g. "ownr.owner_id"
246
+ def self.written_field(rule)
247
+ path = rule.field_accessor_path.presence if rule.join_rule?
248
+ [path, rule.field_name].compact.join('.')
249
+ end
250
+
251
+ # Whether the problem is the rule's association path (not its column,
252
+ # operator, join type or value).
253
+ def path? = PATH_KINDS.include?(kind)
254
+
255
+ # What the admin changes to correct it (REMEDIES): :join, :path,
256
+ # :operator, :field or :value.
257
+ #
258
+ # @return [Symbol]
259
+ def remedy = REMEDIES.fetch(kind)
260
+
261
+ # Whether `name` is shown as stored (`onwer_id`, `ownr`, `regex`,
262
+ # `inner`, `nosuch`): a name that is not there has no label — humanized
263
+ # it would read like a real field.
264
+ def verbatim? = VERBATIM_KINDS.include?(kind)
265
+
266
+ # The admin's sentence for it, under `ui.rules.problems`: the key, by
267
+ # cause where it has one; `_html` where the name or the value is drawn
268
+ # as stored (in code).
269
+ #
270
+ # @return [Symbol]
271
+ def ui_key
272
+ return key if cause
273
+
274
+ :"#{kind}#{'_html' if verbatim? || value}"
275
+ end
276
+
277
+ # What the problem is called in the locale files: the kind, keyed by
278
+ # its cause where it has one — :missing_column, :unknown_association,
279
+ # :empty_association, :unjoinable_polymorphic, ... (the admin's
280
+ # `ui.rules.problems.<key>`, the model's validation message).
281
+ #
282
+ # @return [Symbol]
283
+ def key
284
+ return :empty_association if cause == :empty_segment
285
+ return kind unless kind == :unjoinable_association && cause
286
+
287
+ :"unjoinable_#{cause}"
288
+ end
289
+
290
+ # The `permission_forge.validations.permission_set_rule.*` key of the
291
+ # validation message: the key, or the message the attribute's own
292
+ # validator gives (an operator or join type that is not one).
293
+ #
294
+ # @return [Symbol]
295
+ def message_key
296
+ MESSAGE_KEYS.fetch(key, key)
297
+ end
298
+
299
+ # The validation error PermissionSetRule adds for it, in the current locale.
300
+ #
301
+ # @return [String]
302
+ def validation_message
303
+ I18n.t("permission_forge.validations.permission_set_rule.#{message_key}",
304
+ association: name, name: name, model: model&.name, value: value, type: column_type,
305
+ default: RULE_PROBLEM_MESSAGES.fetch(message_key))
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Operators
5
+ # The names a rule's query gives its tables, as a field_name can be
6
+ # qualified with: the FROM table and each JOIN's table — its alias where
7
+ # ActiveRecord assigned one (a table joined twice), as the database then
8
+ # knows it by the alias alone. Read from the relation's Arel join
9
+ # sources, the aliases ActiveRecord actually assigns — never from the SQL
10
+ # text, in which a column of an ON clause (`"customers"."supplier_id"`)
11
+ # reads like a table. A join written as SQL (a host's `joins('INNER JOIN
12
+ # ...')`) gives the table or alias each of its JOINs names.
13
+ #
14
+ # RuleColumn.find compares a qualifier with them: the applier with its
15
+ # real query (the default scope's and the host's joins included), the
16
+ # model's validation and the audit with the rule's own join ({.of_join}).
17
+ # Each name comes with the table it stands for ({.tables}): an alias is
18
+ # resolved to the model it joins, so an encrypted column reached through
19
+ # one is seen (RuleColumn).
20
+ #
21
+ # @example
22
+ # RuleSources.of(Customer.joins(:supplier)) # => ["customers", "suppliers"]
23
+ # RuleSources.tables(PfRulePathRecord.joins(:parent))
24
+ # # => { "pf_rule_path_records" => "pf_rule_path_records", "parents_pf_rule_path_records" => "pf_rule_path_records" }
25
+ #
26
+ # @api private
27
+ module RuleSources
28
+ # A word that ends a JOIN's table where an alias would stand.
29
+ JOIN_KEYWORDS = %w[ON USING INNER LEFT RIGHT FULL CROSS NATURAL JOIN OUTER WHERE LATERAL].freeze
30
+ # One identifier, bare or quoted the way any adapter quotes one.
31
+ IDENTIFIER = /"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*/
32
+ # A JOIN of SQL text: its table (possibly qualified) and its alias.
33
+ STRING_JOIN = /\bJOIN\s+(?<table>(?:#{IDENTIFIER})(?:\s*\.\s*(?:#{IDENTIFIER}))*)(?:\s+(?:AS\s+)?(?<alias>#{IDENTIFIER}))?/i
34
+
35
+ class << self
36
+ # @param relation [ActiveRecord::Relation]
37
+ # @return [Array<String>]
38
+ def of(relation)
39
+ tables(relation).keys
40
+ end
41
+
42
+ # Each name the query gives a table, with the table it stands for (a
43
+ # table's own name stands for itself; nil when it cannot be told: a
44
+ # subquery's alias).
45
+ #
46
+ # @param relation [ActiveRecord::Relation]
47
+ # @return [Hash{String => String, nil}]
48
+ def tables(relation)
49
+ arel = relation.arel
50
+ [arel.source.left, *arel.join_sources].flat_map { |node| sources_of(node) }.uniq(&:first).to_h
51
+ end
52
+
53
+ # The names a rule's own join gives (its model's query, the rule's
54
+ # path joined as the applier joins it), with their tables ({.tables}).
55
+ #
56
+ # @param model [Class] the rule's model
57
+ # @param join_type [String] the rule's join type (an unknown one reads as `joins`)
58
+ # @param association_chain [Array<String>] the rule's path, split
59
+ # @return [Hash{String => String, nil}]
60
+ def of_join(model, join_type, association_chain)
61
+ join_method = Services::PermissionRuleApplier::JOIN_METHODS.fetch(join_type.to_s, :joins)
62
+ tables(RulePath.join(model.unscoped, join_method, association_chain))
63
+ end
64
+
65
+ private
66
+
67
+ # [[name, table]] for one node of the FROM or a JOIN.
68
+ def sources_of(node)
69
+ case node
70
+ when Arel::Nodes::StringJoin then string_join_sources(node.left.to_s)
71
+ when Arel::Nodes::Join then sources_of(node.left)
72
+ when Arel::Table then [[(node.table_alias || node.name).to_s, node.name.to_s]]
73
+ when Arel::Nodes::TableAlias then [[node.name.to_s, aliased_table(node.relation)]]
74
+ else []
75
+ end
76
+ end
77
+
78
+ def aliased_table(relation)
79
+ relation.name.to_s if relation.is_a?(Arel::Table)
80
+ end
81
+
82
+ def string_join_sources(sql)
83
+ sql.scan(STRING_JOIN).map do |table, alias_name|
84
+ name = unquote(table.split('.').last.strip)
85
+ named = alias_name && !JOIN_KEYWORDS.include?(alias_name.upcase) ? unquote(alias_name.strip) : name
86
+ [named, name]
87
+ end
88
+ end
89
+
90
+ def unquote(identifier)
91
+ identifier.match?(/\A(["`\[]).*["`\]]\z/) ? identifier[1..-2] : identifier
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+ require 'concurrent/map'
5
+
6
+ module PermissionForge
7
+ module Operators
8
+ module ValueCasters
9
+ # Casters for the column types only PostgreSQL has: `uuid`, `inet`,
10
+ # `cidr` and native enum types (`CREATE TYPE ... AS ENUM`). PostgreSQL
11
+ # raises on text it cannot read as such a type (`invalid input syntax
12
+ # for type uuid: "banana"`), so a value that is not one is UNUSABLE —
13
+ # the rule matches nothing — instead of failing every holder's query.
14
+ # The notations taken are narrower than PostgreSQL's own input, so that
15
+ # what is typed here is always read by it.
16
+ #
17
+ # None of these types has a LIKE operator in PostgreSQL ({.pattern?}).
18
+ module PostgresqlTypes
19
+ # The text of a UUID as RFC 4122 writes it: 32 hex digits in groups of
20
+ # 8-4-4-4-12, in either case. The version and variant digits are not
21
+ # looked at (the nil UUID and newer versions are UUIDs to PostgreSQL).
22
+ UUID_FORMAT = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
23
+
24
+ # An IPv4 or IPv6 address, then optionally a prefix length — the
25
+ # notation PostgreSQL's inet and cidr input share (no netmask, no zone).
26
+ NETWORK_FORMAT = %r{\A[\h:.]+(?:/\d{1,3})?\z}
27
+
28
+ # The other notations PostgreSQL's uuid input takes, each read as the
29
+ # 32 digits it holds: in braces, after `urn:uuid:`, without hyphens.
30
+ UUID_NOTATIONS = [/\A\{(?<digits>\h{8}-\h{4}-\h{4}-\h{4}-\h{12})\}\z/, /\Aurn:uuid:(?<digits>\h{8}-\h{4}-\h{4}-\h{4}-\h{12})\z/i,
31
+ /\A\{?(?<digits>\h{32})\}?\z/].freeze
32
+
33
+ # A UUID in lower case, in RFC 4122 notation; anything else — text
34
+ # that is no UUID, the Integer id `current_user` stands for — is UNUSABLE.
35
+ UUID = lambda do |value|
36
+ text = value.is_a?(String) ? value.strip : nil
37
+ next UNUSABLE unless text
38
+ next text.downcase if text.match?(UUID_FORMAT)
39
+
40
+ digits = UUID_NOTATIONS.lazy.filter_map { |notation| notation.match(text)&.[](:digits) }.first&.delete('-')&.downcase
41
+ digits ? [digits[0, 8], digits[8, 4], digits[12, 4], digits[16, 4], digits[20, 12]].join('-') : UNUSABLE
42
+ end
43
+
44
+ # An address IPAddr reads, with an optional prefix length, as written.
45
+ INET = ->(value) { PostgresqlTypes.network(value) || UNUSABLE }
46
+
47
+ # A network: no bits set right of the prefix length (PostgreSQL
48
+ # raises on `10.0.0.5/24` for a cidr column).
49
+ CIDR = lambda do |value|
50
+ text = PostgresqlTypes.network(value)
51
+ next UNUSABLE unless text
52
+
53
+ address = text.split('/').first
54
+ IPAddr.new(text).to_i == IPAddr.new(address).to_i ? text : UNUSABLE
55
+ end
56
+
57
+ # The casters a LIKE pattern means nothing to.
58
+ PATTERNLESS = [UUID, INET, CIDR].freeze
59
+
60
+ # @param value [Object] a rule value
61
+ # @return [String, nil] the address as written, when IPAddr reads it
62
+ def self.network(value)
63
+ text = value.is_a?(String) ? value.strip : nil
64
+ return unless text&.match?(NETWORK_FORMAT)
65
+
66
+ IPAddr.new(text)
67
+ text
68
+ rescue IPAddr::Error
69
+ nil
70
+ end
71
+
72
+ # A native enum type's caster: one of the type's labels, exactly as
73
+ # the database has it (surrounding spaces aside), else UNUSABLE. The
74
+ # labels are read from the database (PermissionForge::SchemaRead,
75
+ # read again once before a label is called missing: one added by a
76
+ # migration while the process runs). Without the model the labels
77
+ # cannot be read, and no value is usable.
78
+ class NativeEnum
79
+ # @param column [ActiveRecord::ConnectionAdapters::Column] a column of a native enum type
80
+ # @param model [Class, nil] the model whose table the column is in
81
+ # @return [NativeEnum]
82
+ def self.of(column, model)
83
+ new(column.sql_type, model)
84
+ end
85
+
86
+ # @param type [String] the column's SQL type: the enum type's name
87
+ # @param model [Class, nil]
88
+ def initialize(type, model)
89
+ @type = type
90
+ @model = model
91
+ end
92
+
93
+ def call(value)
94
+ return UNUSABLE unless @model && value.is_a?(String)
95
+
96
+ [value, value.strip].uniq.find { |label| SchemaRead.enum_label?(@model, @type, label) } || UNUSABLE
97
+ end
98
+ end
99
+
100
+ # Text PostgreSQL reads itself: a column type no caster here knows
101
+ # (hstore, money, interval, macaddr, bit, ...) and date and time
102
+ # text in a notation Temporal does not recognise (`today`, `31 Jan
103
+ # 2026`). PostgreSQL raises on text it cannot read as the column's
104
+ # type — for every holder, on every query — so the text is asked of
105
+ # PostgreSQL once (a CAST in a savepoint) and the answer kept for the
106
+ # process and database: what it reads is handed over as written,
107
+ # anything else is UNUSABLE and the rule matches nothing. The caster
108
+ # wrapped, when there is one, is tried first (a recognised date costs
109
+ # no query).
110
+ #
111
+ # Only a type PostgreSQL can compare is read back: one with a default
112
+ # btree operator class whose family has `=` and `<`
113
+ # (SchemaRead.ordered_type?). Reading a value is not comparing it —
114
+ # `CAST('<a/>' AS xml)` succeeds, but xml, point, polygon and path
115
+ # have no `=`, hstore no `<` — so a value of any other type is
116
+ # UNUSABLE (the null checks take none and still work).
117
+ #
118
+ # Only a refusal of the value itself is kept: an error of SQLSTATE
119
+ # class 22 (data exception: invalid input). Any other error — a
120
+ # statement or lock timeout, a lost connection, an aborted
121
+ # transaction — denies this read only and is asked again next time.
122
+ class ReadBack
123
+ # Answers kept per process before the memo starts over.
124
+ LIMIT = 4096
125
+
126
+ # Builds the caster for one column (ValueCasters.for calls `of`).
127
+ Factory = Struct.new(:inner) do
128
+ # @param column [ActiveRecord::ConnectionAdapters::Column]
129
+ # @param model [Class, nil]
130
+ # @return [ReadBack]
131
+ def of(column, model) = ReadBack.new(inner, column.sql_type, model)
132
+ end
133
+
134
+ @answers = Concurrent::Map.new
135
+
136
+ class << self
137
+ # @param inner [#call, nil] the caster tried first
138
+ # @return [Factory]
139
+ def wrapping(inner) = Factory.new(inner)
140
+
141
+ # Whether PostgreSQL reads `text` as a value of `sql_type`; asked
142
+ # once per process, database and type.
143
+ #
144
+ # @param model [Class] a model on the connection
145
+ # @param sql_type [String] the column's SQL type, from the schema
146
+ # @param text [String]
147
+ # @return [Boolean]
148
+ def reads?(model, sql_type, text)
149
+ key = [model.connection_db_config.name, sql_type, text]
150
+ known = @answers[key]
151
+ return known unless known.nil?
152
+
153
+ answer = cast(model, sql_type, text)
154
+ return false if answer.nil?
155
+
156
+ @answers.clear if @answers.size >= LIMIT
157
+ @answers[key] = answer
158
+ end
159
+
160
+ # @param value [Object] a rule value
161
+ # @param numbers [Boolean] whether a number is asked as its text
162
+ # @return [String, nil] the text PostgreSQL is asked to read
163
+ def text_of(value, numbers:)
164
+ return value if value.is_a?(String)
165
+
166
+ value.to_s if numbers && value.is_a?(Numeric)
167
+ end
168
+
169
+ # Whether the error is PostgreSQL refusing the value itself
170
+ # (SQLSTATE class 22, PG::DataException and its subclasses).
171
+ #
172
+ # @param error [ActiveRecord::StatementInvalid]
173
+ # @return [Boolean]
174
+ def refused_value?(error)
175
+ defined?(::PG::DataException) ? error.cause.is_a?(::PG::DataException) : false
176
+ end
177
+
178
+ # Forgets the answers (tests).
179
+ #
180
+ # @return [void]
181
+ def reset!
182
+ @answers.clear
183
+ end
184
+
185
+ private
186
+
187
+ # true, false (the value refused: kept), or nil (any other
188
+ # failure: not kept). A failed statement aborts a PostgreSQL
189
+ # transaction: the CAST runs in a savepoint of its own.
190
+ def cast(model, sql_type, text)
191
+ model.connection_pool.with_connection do |connection|
192
+ connection.transaction(requires_new: true) do
193
+ connection.select_value("SELECT CAST(#{connection.quote(text)} AS #{sql_type})")
194
+ end
195
+ end
196
+ true
197
+ rescue ActiveRecord::StatementInvalid => e
198
+ refused_value?(e) ? false : nil
199
+ end
200
+ end
201
+
202
+ # @param inner [#call, nil]
203
+ # @param sql_type [String]
204
+ # @param model [Class, nil] without one PostgreSQL cannot be asked: UNUSABLE
205
+ def initialize(inner, sql_type, model)
206
+ @inner = inner
207
+ @sql_type = sql_type
208
+ @model = model
209
+ end
210
+
211
+ def call(value)
212
+ typed = @inner ? @inner.call(value) : UNUSABLE
213
+ typed.equal?(UNUSABLE) ? read_back(value) : typed
214
+ end
215
+
216
+ private
217
+
218
+ # Unless a caster is wrapped (what it refuses as no text stays
219
+ # refused), a number — the id `current_user` stands for — is asked as its text.
220
+ def read_back(value)
221
+ text = ReadBack.text_of(value, numbers: @inner.nil?)
222
+ return UNUSABLE unless text && @model && SchemaRead.ordered_type?(@model, @sql_type)
223
+
224
+ ReadBack.reads?(@model, @sql_type, text) ? value : UNUSABLE
225
+ end
226
+ end
227
+
228
+ # @param caster [#call] a caster of ValueCasters
229
+ # @return [Boolean] whether a LIKE pattern can be matched against a
230
+ # column of the caster's type
231
+ def self.pattern?(caster)
232
+ PATTERNLESS.none? { |patternless| patternless.equal?(caster) } && !caster.is_a?(NativeEnum)
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end