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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # Whether a signed-in user's record reads of a model are refused whole,
5
+ # before any row-level rule: the one definition behind
6
+ # `config.no_read_access_strategy`, asked by find_by / find_by! /
7
+ # permission_scoped (PermissionScopedFindable) and the default scope
8
+ # (AutoPermissionScoping) before they hand the rules to
9
+ # Services::PermissionRuleApplier.
10
+ #
11
+ # Under :rules_only (the default) never: the reads apply the rules of the
12
+ # user's entries, and a user with no entry, or one that does not grant
13
+ # read_access, has no rules and gets every row (can_read? is the check).
14
+ # Under :deny (the strict preset) whenever the user's entries along the
15
+ # class's Resource chain do not grant read_access
16
+ # (ResourceChain.readable?): no entry at all, or one of them without read
17
+ # — whatever took it away (no Permission Set grants it, a Muting
18
+ # Permission Set, a Resource renamed off its model, a granting set whose
19
+ # activation conditions do not pass or cannot be judged). View All and
20
+ # Modify All skip the rules of a readable entry only.
21
+ #
22
+ # With nobody signed in nil_user_strategy decides; the rules are lifted
23
+ # where they always were (the host's without_permission_scope, the
24
+ # gem's own suspension of the default scope), and this with them.
25
+ #
26
+ # @api private
27
+ module RecordReadAccess
28
+ # The reason access.denied carries.
29
+ REASON = :no_read_access
30
+
31
+ module_function
32
+
33
+ # @param user [#permissions_hash] the signed-in user
34
+ # @param klass [Class] the model class read
35
+ # @param configuration [PermissionForge::Configuration]
36
+ # @return [Boolean]
37
+ def denied?(user, klass, configuration: PermissionForge.configuration)
38
+ configuration.deny_record_reads_without_read_access? &&
39
+ !ResourceChain.readable?(user, ResourceChain.names(klass))
40
+ end
41
+
42
+ # What NotAuthorizedError#failure_messages says for such a denial, in
43
+ # the current locale.
44
+ #
45
+ # @param klass [Class]
46
+ # @return [String]
47
+ def failure_message(klass)
48
+ I18n.t('permission_forge.denials.no_read_access', resource: ResourceChain.names(klass).first || klass.name)
49
+ end
50
+ end
51
+ end
@@ -42,8 +42,8 @@ module PermissionForge
42
42
  # @param resource_name [String, Symbol]
43
43
  # @return [Array<Symbol>]
44
44
  def names_for(resource_name)
45
- klass = resource_name.to_s.safe_constantize
46
- klass.is_a?(Class) && klass.respond_to?(:base_class) ? names(klass) : [resource_name.to_sym]
45
+ klass = ModelLookup.model_for(resource_name)
46
+ klass ? names(klass) : [resource_name.to_sym]
47
47
  end
48
48
 
49
49
  # The user's resource entries along the chain, nearest first.
@@ -92,6 +92,18 @@ module PermissionForge
92
92
  entries(user, names).first&.dig(:permissions, flag) == true
93
93
  end
94
94
 
95
+ # Whether the user may read the class's records at all: an entry along
96
+ # the chain, and every one of them grants read_access — a class cannot
97
+ # be more readable than an entry it answers to, as for its fields
98
+ # (#unreadable?). A subclass with no entry of its own reads as its base.
99
+ # Asked through RecordReadAccess.denied?.
100
+ #
101
+ # @return [Boolean]
102
+ def readable?(user, names)
103
+ resource_entries = entries(user, names)
104
+ resource_entries.any? && !unreadable?(resource_entries)
105
+ end
106
+
95
107
  # @return [Symbol, nil] :view_all or :modify_all when one of them lets
96
108
  # the user past the rules of an entry
97
109
  def bypass_reason(permissions)
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent/map'
4
+
5
+ module PermissionForge
6
+ module SchemaRead
7
+ # Whether PostgreSQL can compare values of a type as a rule does (`=`,
8
+ # `<>`, `<`, `>`, `IN`, `BETWEEN`): the type — a domain's base type, a
9
+ # range through `anyrange` — has a default btree operator class whose
10
+ # family holds `=` and `<` for it. `xml`, `point`, `polygon` and `path`
11
+ # have no `=`, `hstore` no `<` (its btree operators are `#<#`...). Asked
12
+ # once per process, database and type (one catalog query, in a savepoint);
13
+ # a type the database does not know (`to_regtype` answers NULL) has none.
14
+ # A failed query answers no for that call only and is asked again next
15
+ # time.
16
+ #
17
+ # @api private
18
+ module OrderedTypes
19
+ @answers = Concurrent::Map.new
20
+
21
+ class << self
22
+ # @param model [Class] a model on the connection
23
+ # @param sql_type [String] the column's SQL type, from the schema
24
+ # @return [Boolean]
25
+ def ordered?(model, sql_type)
26
+ key = [model.connection_db_config.name, sql_type.to_s]
27
+ known = @answers[key]
28
+ return known unless known.nil?
29
+
30
+ answer = ask(model, sql_type)
31
+ answer.nil? ? false : (@answers[key] = answer)
32
+ end
33
+
34
+ # Forgets the answers (tests).
35
+ #
36
+ # @return [void]
37
+ def reset!
38
+ @answers.clear
39
+ end
40
+
41
+ private
42
+
43
+ # true / false, or nil when the catalog could not be asked (not kept).
44
+ def ask(model, sql_type)
45
+ model.connection_pool.with_connection do |connection|
46
+ connection.transaction(requires_new: true) do
47
+ connection.select_value(sql(connection, sql_type), 'SCHEMA') == true
48
+ end
49
+ end
50
+ rescue ActiveRecord::StatementInvalid
51
+ nil
52
+ end
53
+
54
+ def sql(connection, sql_type)
55
+ <<~SQL.squish
56
+ WITH given AS (SELECT COALESCE(NULLIF(t.typbasetype, 0), t.oid) AS oid FROM pg_catalog.pg_type t
57
+ WHERE t.oid = to_regtype(#{connection.quote(sql_type.to_s)})),
58
+ input AS (SELECT CASE b.typtype WHEN 'r' THEN to_regtype('anyrange')::oid
59
+ WHEN 'm' THEN to_regtype('anymultirange')::oid ELSE b.oid END AS oid
60
+ FROM given JOIN pg_catalog.pg_type b ON b.oid = given.oid)
61
+ SELECT COUNT(DISTINCT o.oprname) = 2 FROM input
62
+ JOIN pg_catalog.pg_opclass c ON c.opcintype = input.oid AND c.opcdefault
63
+ JOIN pg_catalog.pg_am a ON a.oid = c.opcmethod AND a.amname = 'btree'
64
+ JOIN pg_catalog.pg_amop ao ON ao.amopfamily = c.opcfamily AND ao.amoplefttype = c.opcintype
65
+ AND ao.amoprighttype = c.opcintype
66
+ JOIN pg_catalog.pg_operator o ON o.oid = ao.amopopr AND o.oprname IN ('=', '<')
67
+ SQL
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,335 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent/map'
4
+ require 'concurrent/atomic/atomic_fixnum'
5
+
6
+ module PermissionForge
7
+ # The one place the gem reads a model's columns, and whether a table is
8
+ # there. Rails keeps both in its schema cache, read once per process (or
9
+ # loaded from a schema cache dump), so a column a migration added while
10
+ # the process was up — a running development server, an old process in a
11
+ # rolling deploy — is not in it: a rule naming it would be refused, Sync
12
+ # fields would not offer it, its timestamps would not be read-only.
13
+ #
14
+ # So a read that answers "no" — the name is not a column of the table, the
15
+ # table is not there — reads the schema again first: the table's column
16
+ # names are asked of the database (one metadata query) and, only when they
17
+ # differ from what the cache holds, the model's column information is reset
18
+ # (reset_column_information) and the answer asked again. A process whose
19
+ # schema did not change never resets a model on the request path (a reset
20
+ # drops the model's generated attribute methods while other threads use
21
+ # it); the question the builder asks for every `_id` field's `_uuid`
22
+ # variant costs one query per model and name per process. A name is read
23
+ # again at most once per process while the table's columns stay as they
24
+ # were read: a name found missing by a re-read is
25
+ # *confirmed* missing and costs nothing afterwards, until something else
26
+ # reads the table again (a reload in development, a migration in the
27
+ # process, another re-read). A column that is there costs nothing extra:
28
+ # no query, no lock.
29
+ #
30
+ # A list of columns cannot tell what it is missing. The reads that build
31
+ # rows or reports from one — the field generator, Sync fields, the
32
+ # admin's model catalog, the GDPR audit, the rule builder's columns — pass
33
+ # `verify: true`: the column names are asked of the database (one
34
+ # metadata query, no reset) and the schema is read again when they differ.
35
+ # That is also how a removed column leaves a process: until such a read, a
36
+ # column the cache still lists is answered as there (asking the database
37
+ # on every "yes" would cost a query on the request path); after it, every
38
+ # reader agrees it is gone, without reading again.
39
+ #
40
+ # Columns are the table's, as the database has them (`columns(model)`,
41
+ # `ignored_columns` included, as a rule's column is resolved) or the
42
+ # model's (`column_names`, `ignored_columns` excluded), as each caller says.
43
+ #
44
+ # @example
45
+ # PermissionForge::SchemaRead.column?(Customer, 'region') # read again once if the cache says no
46
+ # PermissionForge::SchemaRead.columns(Customer, verify: true).keys
47
+ # PermissionForge::SchemaRead.table?(Customer, 'suppliers')
48
+ #
49
+ # @api private
50
+ module SchemaRead
51
+ # What the last re-read of a table found: `names` confirmed missing in
52
+ # the columns identified by `token` (the cached columns hash's
53
+ # object_id), and the `columns` it read.
54
+ Confirmed = Data.define(:token, :names, :columns)
55
+
56
+ # Seconds a table found missing stays missing before table? asks the
57
+ # database again.
58
+ TABLE_RECHECK = 30
59
+
60
+ @confirmed = Concurrent::Map.new
61
+ @claims = Concurrent::Map.new
62
+ @tables = Concurrent::Map.new
63
+ @enum_labels = Concurrent::Map.new
64
+ @enum_claims = Concurrent::Map.new
65
+ @rereads = Concurrent::AtomicFixnum.new
66
+
67
+ class << self
68
+ # The table's columns by name (`ignored_columns` included).
69
+ #
70
+ # @param model [Class] an Active Record model with a table
71
+ # @param verify [Boolean] compare with the database first (see the module comment)
72
+ # @return [Hash{String => ActiveRecord::ConnectionAdapters::Column}]
73
+ def columns(model, verify: false)
74
+ verify!(model) if verify
75
+ table_columns(model)
76
+ end
77
+
78
+ # The model's column names (`ignored_columns` excluded).
79
+ #
80
+ # @param model [Class]
81
+ # @param verify [Boolean] compare with the database first
82
+ # @return [Array<String>]
83
+ def column_names(model, verify: false)
84
+ verify!(model) if verify
85
+ model.column_names
86
+ end
87
+
88
+ # The table's column of that name, read again once when there is none.
89
+ # A block picks the column out of the table's columns instead of the
90
+ # exact name (a lookup that ignores case where the database does).
91
+ #
92
+ # @param model [Class]
93
+ # @param name [String, Symbol]
94
+ # @yieldparam columns [Hash{String => ActiveRecord::ConnectionAdapters::Column}]
95
+ # @yieldreturn [ActiveRecord::ConnectionAdapters::Column, nil]
96
+ # @return [ActiveRecord::ConnectionAdapters::Column, nil]
97
+ def column(model, name, &pick)
98
+ name = name.to_s
99
+ pick ||= ->(columns) { columns[name] }
100
+ pick.call(table_columns(model)) || (pick.call(table_columns(model)) if reread_missing?(model, [name]))
101
+ end
102
+
103
+ # @param model [Class]
104
+ # @param name [String, Symbol]
105
+ # @param ignored [Boolean] whether a column the model ignores (`ignored_columns`) counts
106
+ # @return [Boolean] whether the table has the column (read again once before "no")
107
+ def column?(model, name, ignored: true)
108
+ name = name.to_s
109
+ has?(model, name) { ignored ? table_columns(model).key?(name) : model.column_names.include?(name) }
110
+ end
111
+
112
+ # The names among `names` that are columns of the model
113
+ # (`ignored_columns` excluded), the table read again once when some are
114
+ # not.
115
+ #
116
+ # @param model [Class]
117
+ # @param names [Array<String>]
118
+ # @return [Array<String>] in the order of `names`
119
+ def columns_among(model, names)
120
+ names = names.map(&:to_s)
121
+ found = names & model.column_names
122
+ return found if found.size == names.size || !reread_missing?(model, names - found)
123
+
124
+ names & model.column_names
125
+ end
126
+
127
+ # The block's answer about `name`; when it is "no" and the table has no
128
+ # such column, the table is read again once and the block asked again.
129
+ # For a predicate that also counts what is not a column (an attribute,
130
+ # an alias, a method, a virtual field).
131
+ #
132
+ # @param model [Class]
133
+ # @param name [String, Symbol]
134
+ # @yieldreturn [Boolean]
135
+ # @return [Boolean]
136
+ def has?(model, name)
137
+ return true if yield
138
+
139
+ reread_missing?(model, [name.to_s]) && yield ? true : false
140
+ end
141
+
142
+ # Whether the connection has a data source (a table or a view) named
143
+ # `table`; a "no" is asked of the database again, at most once every
144
+ # TABLE_RECHECK seconds per model and name (a table a migration
145
+ # creates later is found within that interval, and a name that stays
146
+ # missing — a rule qualified with an alias — costs one query per
147
+ # interval, never one per call).
148
+ #
149
+ # @param model [Class] a model on the connection
150
+ # @param table [String] the name as written
151
+ # @return [Boolean]
152
+ def table?(model, table)
153
+ table = table.to_s
154
+ cache = schema_cache(model)
155
+ return true if cache.data_source_exists?(table)
156
+
157
+ claim = [slot(model), table]
158
+ return false unless table_recheck_due?(claim)
159
+
160
+ reread_table?(cache, table, claim)
161
+ end
162
+
163
+ # Whether `label` is one of the labels of the native enum type `type`
164
+ # (PostgreSQL: `CREATE TYPE ... AS ENUM`), as the database has them.
165
+ # The labels are read once per process and type (one catalog query);
166
+ # a label they lack is asked of the database again once, while the
167
+ # labels stay as they were read: one a migration added while the
168
+ # process runs (`ALTER TYPE ... ADD VALUE`). A type the database does
169
+ # not know has no labels.
170
+ #
171
+ # @param model [Class] a model on the connection whose table has the column
172
+ # @param type [String] the column's SQL type: the enum type's name
173
+ # @param label [String]
174
+ # @return [Boolean]
175
+ def enum_label?(model, type, label)
176
+ key = [model.connection_db_config.name, type.to_s]
177
+ labels = @enum_labels.compute_if_absent(key) { enum_labels_of(model, type) }
178
+ return true if labels.include?(label)
179
+
180
+ claim = [key, labels.object_id, label]
181
+ return false unless @enum_claims.put_if_absent(claim, true).nil?
182
+
183
+ @rereads.increment
184
+ (@enum_labels[key] = enum_labels_of(model, type)).include?(label)
185
+ rescue ActiveRecord::StatementInvalid
186
+ # The labels could not be read (a timeout, a lost connection): no
187
+ # for this call only — nothing kept, asked again next time.
188
+ @enum_claims.delete(claim) if claim
189
+ false
190
+ end
191
+
192
+ # Whether PostgreSQL can compare values of the type (SchemaRead::OrderedTypes).
193
+ #
194
+ # @param model [Class] a model on the connection
195
+ # @param sql_type [String] the column's SQL type, from the schema
196
+ # @return [Boolean]
197
+ def ordered_type?(model, sql_type) = OrderedTypes.ordered?(model, sql_type)
198
+
199
+ # @return [Integer] how many times this process read a table's schema again
200
+ def rereads
201
+ @rereads.value
202
+ end
203
+
204
+ # Forgets what was confirmed missing (tests).
205
+ #
206
+ # @return [void]
207
+ def reset!
208
+ [@confirmed, @claims, @tables, @enum_labels, @enum_claims].each(&:clear)
209
+ OrderedTypes.reset!
210
+ end
211
+
212
+ private
213
+
214
+ # The type's labels in their order; none for a type the database does
215
+ # not know (to_regtype answers NULL where a cast would raise and fail
216
+ # the statement, and with it the caller's transaction).
217
+ def enum_labels_of(model, type)
218
+ model.connection_pool.with_connection do |connection|
219
+ connection.transaction(requires_new: true) do
220
+ connection.select_values(<<~SQL.squish, 'SCHEMA').freeze
221
+ SELECT enumlabel::text FROM pg_catalog.pg_enum
222
+ WHERE enumtypid = to_regtype(#{connection.quote(type.to_s)}) ORDER BY enumsortorder
223
+ SQL
224
+ end
225
+ end
226
+ end
227
+
228
+ # Claims the re-read of a missing table when none ran in the last
229
+ # TABLE_RECHECK seconds (one thread wins; the others answer "no").
230
+ def table_recheck_due?(key)
231
+ now = Time.now.to_f
232
+ last = @tables[key]
233
+ return @tables.put_if_absent(key, now).nil? if last.nil?
234
+ return false if now - last < TABLE_RECHECK
235
+
236
+ @tables.replace_pair(key, last, now)
237
+ end
238
+
239
+ # The table asked of the database again, once per claim. A read that
240
+ # fails (a timeout, a lost connection) answers no for this call only:
241
+ # the claim is let go, so the next call asks again.
242
+ def reread_table?(cache, table, claim)
243
+ cache.clear_data_source_cache!(table)
244
+ @rereads.increment
245
+ cache.data_source_exists?(table)
246
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
247
+ @tables.delete(claim)
248
+ false
249
+ end
250
+
251
+ def table_columns(model)
252
+ schema_cache(model).columns_hash(model.table_name)
253
+ end
254
+
255
+ # The pool's schema cache, never `model.connection`'s: that reader
256
+ # leases a connection to the thread for good and, from Rails 7.2,
257
+ # raises under ActiveRecord.permanent_connection_checkout =
258
+ # :disallowed. The pool's cache takes a connection only for the read
259
+ # it makes, and is the one the connection's own cache is. Rails 7.1
260
+ # has no pool cache (nor :disallowed): the connection's.
261
+ def schema_cache(model)
262
+ pool = model.connection_pool
263
+ pool.respond_to?(:schema_cache) ? pool.schema_cache : model.connection.schema_cache
264
+ end
265
+
266
+ # Reads the table again when one of `names` is no column of it and was
267
+ # not confirmed missing by the read the cache holds. true when it did.
268
+ # Two threads asking at once: one reads, the other answers from what
269
+ # the cache holds (no lock is held while Rails resets the model).
270
+ def reread_missing?(model, names)
271
+ columns = table_columns(model)
272
+ missing = names - columns.keys
273
+ return false if missing.empty? || confirmed?(model, columns, missing)
274
+
275
+ claim = [slot(model), columns.object_id, missing.sort]
276
+ return false unless @claims.put_if_absent(claim, true).nil?
277
+
278
+ begin
279
+ reread!(model, missing)
280
+ ensure
281
+ @claims.delete(claim)
282
+ end
283
+ true
284
+ end
285
+
286
+ def confirmed?(model, columns, missing)
287
+ entry = @confirmed[slot(model)]
288
+ !entry.nil? && entry.token == columns.object_id && (missing - entry.names).empty?
289
+ end
290
+
291
+ # Confirmed missing after the read: the names asked for now, the ones
292
+ # confirmed before, and every column an earlier read had (the cache
293
+ # being dropped, the last re-read) — a removed column among them — that
294
+ # the new read lacks.
295
+ def reread!(model, missing, live: live_names(model))
296
+ known = missing | known_names(model)
297
+ @rereads.increment
298
+ model.reset_column_information unless current?(model, live)
299
+ fresh = table_columns(model)
300
+ @confirmed[slot(model)] = Confirmed.new(token: fresh.object_id, names: (known - fresh.keys).freeze, columns: fresh.keys.freeze)
301
+ end
302
+
303
+ # The columns the cache and the model hold and, from the last re-read,
304
+ # the columns it read and the names it confirmed missing.
305
+ def known_names(model)
306
+ before = @confirmed[slot(model)]
307
+ table_columns(model).keys | model.columns_hash.keys | (before ? before.names + before.columns : [])
308
+ end
309
+
310
+ # What the memo is keyed by: the class's object_id, so that a class
311
+ # reloaded in development (another class) is read again, and the memo
312
+ # does not keep the old one alive.
313
+ def slot(model) = model.object_id
314
+
315
+ def verify!(model)
316
+ live = live_names(model)
317
+ reread!(model, [], live: live) unless current?(model, live)
318
+ end
319
+
320
+ # Whether both reads Rails keeps — the schema cache's columns and the
321
+ # model's own (`columns_hash`, which DDL run in the process does not
322
+ # clear) — are the database's `live` names.
323
+ def current?(model, live)
324
+ live.sort == table_columns(model).keys.sort &&
325
+ model.columns_hash.keys.sort == (live - model.ignored_columns.map(&:to_s)).sort
326
+ end
327
+
328
+ # The table's column names as the database has them now (a metadata
329
+ # query).
330
+ def live_names(model)
331
+ model.connection_pool.with_connection { |connection| connection.columns(model.table_name).map(&:name) }
332
+ end
333
+ end
334
+ end
335
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # The one definition of "a column that holds a secret", which a record rule
5
+ # never compares: config.rule_secret_column_patterns (default
6
+ # Configuration::QueryScopingSettings::DEFAULT_RULE_SECRET_COLUMN_PATTERNS —
7
+ # `*_digest`, `*token*`, `encrypted_*`, `*_secret*`, `*passw*`, `secret*`,
8
+ # `api_key` / `private_key` / `secret_key` / `access_key`, `crypted_*`,
9
+ # `*salt`, `otp_*`; `[]` opts out). A rule on such a column turns the rule into an oracle on
10
+ # the secret (who may read which records tells its value apart), and the
11
+ # rule's value (stored, shown in the admin and the API) would be a guess at it.
12
+ #
13
+ # Unlike SensitiveColumns (personal data, which a rule may well compare:
14
+ # an owner's email) it judges the column a rule names: the name after the
15
+ # last dot, in any case. Readers: PermissionSetRule's validation (a new
16
+ # rule, a change of its column or path, and on a rule stored before a
17
+ # change of its value or operator), the admin's rule builder (not
18
+ # offered), the security audit's secret_rule_columns finding and Home's
19
+ # card for the rules stored before (they keep evaluating as before).
20
+ #
21
+ # @api private
22
+ class SecretColumns
23
+ # @param configuration [Configuration]
24
+ def initialize(configuration: PermissionForge.configuration)
25
+ @configuration = configuration
26
+ end
27
+
28
+ # @param name [String, Symbol, nil] a column name, qualified or not (`table.column`)
29
+ # @return [Boolean]
30
+ def secret?(name)
31
+ !pattern_for(name).nil?
32
+ end
33
+
34
+ # @param name [String, Symbol, nil]
35
+ # @return [Regexp, nil] the first pattern the column matches
36
+ def pattern_for(name)
37
+ column = name.to_s.split('.').last.to_s.downcase
38
+ return if column.empty?
39
+
40
+ Array(@configuration.rule_secret_column_patterns).find { |pattern| column.match?(pattern) }
41
+ end
42
+
43
+ # Whether the column a rule compares holds a secret.
44
+ #
45
+ # @param rule [PermissionSetRule]
46
+ # @return [Boolean]
47
+ def rule?(rule) = secret?(rule.field_name)
48
+ end
49
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # The one definition of "this column looks like personal data, and this is
5
+ # the mask for it". Two sources, in this order: config.gdpr_field_patterns
6
+ # (pattern => strategy; the alternative that matched names the match) and,
7
+ # while filter_parameters_integration is on, the host application's
8
+ # Rails filter_parameters (recommended strategy 'full': what the host hides
9
+ # from its logs it does not show in the clear either).
10
+ #
11
+ # Every reader asks here: the admin's presets, "Sync fields", the Fields
12
+ # tab and Home's privacy card; the field generator (ResourceFieldGenerator
13
+ # and so the API's generate_fields and grant_full_access!) under
14
+ # gdpr_defaults; GdprFieldAuditor (rake permission_forge:audit_masks) and
15
+ # the security audit's personal_data check; the boot warning about
16
+ # unmasked personal data. A column one of them calls personal data, they
17
+ # all do (release_readiness/decision_parity_test.rb).
18
+ #
19
+ # @api private
20
+ class SensitiveColumns
21
+ # A match: `pattern` names what matched (the alternative of the GDPR
22
+ # pattern, or 'filter_parameters'), `strategy` the recommended mask,
23
+ # `source` :gdpr_pattern or :filter_parameters.
24
+ Match = Struct.new(:pattern, :strategy, :source, keyword_init: true) do
25
+ def filter_parameter? = source == :filter_parameters
26
+ end
27
+
28
+ FILTER_PARAMETERS = 'filter_parameters'
29
+
30
+ # @param configuration [Configuration]
31
+ # @param filter_parameters [Array, nil] the host's filter_parameters
32
+ # (default: Rails.application.config.filter_parameters while the
33
+ # integration is on)
34
+ def initialize(configuration: PermissionForge.configuration, filter_parameters: nil)
35
+ @configuration = configuration
36
+ @filter_parameters = filter_parameters
37
+ end
38
+
39
+ # @param column_name [String, Symbol]
40
+ # @return [Match, nil]
41
+ def match(column_name)
42
+ name = column_name.to_s
43
+ regex, strategy = @configuration.gdpr_field_patterns.find { |pattern, _| name.match?(pattern) }
44
+ return Match.new(pattern: pattern_label(regex, name), strategy: strategy, source: :gdpr_pattern) if regex
45
+ return Match.new(pattern: FILTER_PARAMETERS, strategy: 'full', source: :filter_parameters) if filtered_parameter?(name)
46
+
47
+ nil
48
+ end
49
+
50
+ # @param column_name [String, Symbol]
51
+ # @return [Boolean]
52
+ def sensitive?(column_name)
53
+ !match(column_name).nil?
54
+ end
55
+
56
+ private
57
+
58
+ # Human-ish pattern name for "Matches privacy pattern "email"": the
59
+ # alternative of the regex that actually matched, stripped of anchors.
60
+ def pattern_label(regex, name)
61
+ source = regex.source
62
+ hit = matching_alternative(source, name) || source.split('|').first
63
+ hit.to_s.gsub(/\\[Azb]|\.\*|\[[^\]]*\]/, '').delete('^a-z0-9_').delete_prefix('_').delete_suffix('_').presence || source
64
+ end
65
+
66
+ def matching_alternative(source, name)
67
+ source.split('|').find { |alt| safe_match?(alt, name) }
68
+ end
69
+
70
+ def safe_match?(source, name)
71
+ name.match?(Regexp.new(source))
72
+ rescue RegexpError
73
+ false
74
+ end
75
+
76
+ # A column name is not a request parameter: ActiveSupport::
77
+ # ParameterFilter matches a String / Symbol entry anywhere in the key,
78
+ # so Rails' generated `:otp`, `:ssn`, `:salt` would call `footprint`,
79
+ # `classname`, `persalted` personal data. Here such an entry names a
80
+ # column when one of the name's segments (split on `_`) starts with it;
81
+ # a leading or trailing underscore ties it to a segment boundary
82
+ # (`_key`: a segment after the first starts with `key` — `api_key`, and
83
+ # also `sort_key`). A Regexp entry matches as it is written; a Proc
84
+ # entry (it rewrites values) names no column.
85
+ def filtered_parameter?(name)
86
+ @filter_patterns ||= host_filter_parameters.filter_map { |entry| filter_pattern(entry) }
87
+ @filter_patterns.any? { |pattern| name.match?(pattern) }
88
+ end
89
+
90
+ def filter_pattern(entry)
91
+ case entry
92
+ when Regexp then entry
93
+ when String, Symbol then segment_pattern(entry.to_s)
94
+ end
95
+ end
96
+
97
+ def segment_pattern(entry)
98
+ core = entry.delete_prefix('_').delete_suffix('_')
99
+ return if core.empty?
100
+
101
+ start = entry.start_with?('_') ? '(?<=_)' : '(?:\\A|(?<=_))'
102
+ finish = entry.end_with?('_') ? '(?=_)' : ''
103
+ Regexp.new("#{start}#{Regexp.escape(core)}#{finish}", Regexp::IGNORECASE)
104
+ end
105
+
106
+ def host_filter_parameters
107
+ return [] unless @configuration.filter_parameters_integration
108
+ return Array(@filter_parameters) if @filter_parameters
109
+ return [] unless defined?(Rails) && Rails.application
110
+
111
+ Array(Rails.application.config.filter_parameters)
112
+ end
113
+ end
114
+ end