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,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent/map'
4
+
5
+ module PermissionForge
6
+ module Audit
7
+ # What makes a guarded attribute returned as stored inside a web request
8
+ # with nobody signed in visible (masked_attributes_nil_user_strategy
9
+ # :bypass; Configuration#masked_attributes_without_user_for answers
10
+ # :record): exactly the reads :hide_in_requests would hide — every
11
+ # guarded attribute of a masked model but its primary key and the raw
12
+ # paths, never the user model. The attribute guard (AttributeAccessGuard)
13
+ # calls .note for each such read.
14
+ #
15
+ # - The runtime event field.unmasked_without_user (group :bypass): the
16
+ # model, the attribute and the request (method, path, endpoint; request
17
+ # id and IP as event columns when the audit context has none), once per
18
+ # request per model and attribute (Runtime.once?). Nothing is built
19
+ # while nobody listens and the group is not stored (Audit::Gate).
20
+ # - One SECURITY warning per model and process in the log (Warnings.emit),
21
+ # so an app that stores no runtime event sees the gap too. Models are
22
+ # remembered by name (a development reload does not warn again), at
23
+ # most WARNED_LIMIT of them.
24
+ #
25
+ # Once the model has been warned about and nobody listens (the group not
26
+ # stored), there is nothing left to do: .pending? is false and the
27
+ # record's `attributes` returns the stored hash at once, as outside a
28
+ # request. A debugging read (inspect, MaskedAttributes::INSPECT_KEY) is
29
+ # not recorded. Neither ever raises into the read.
30
+ #
31
+ # @api private
32
+ module UnmaskedWithoutUser
33
+ EVENT = 'field.unmasked_without_user'
34
+ GATE = Gate.for(EVENT)
35
+
36
+ WARNED_LIMIT = 1_000
37
+ # The "Model#attribute" writes refused after a hidden read, logged once.
38
+ REFUSED = Concurrent::Map.new
39
+
40
+ # The names of the models warned about in this process: a frozen Hash
41
+ # replaced whole under WARNED_MUTEX, so the read on every guarded read
42
+ # takes no lock.
43
+ @warned = {}.freeze
44
+ WARNED_MUTEX = Mutex.new
45
+
46
+ class << self
47
+ # @return [Hash{String => true}] the names of the models warned about
48
+ attr_reader :warned
49
+
50
+ # @param model [Class, String, Symbol] the record's class (or the
51
+ # Resource name when no class is known)
52
+ # @param attribute [Symbol, String]
53
+ # @return [void]
54
+ def note(model, attribute)
55
+ heard = GATE.open?
56
+ name = model.is_a?(Class) ? model.name : model.to_s
57
+ first = !@warned.key?(name)
58
+ return unless (first || heard) && !ExecutionState[Concerns::MaskedAttributes::INSPECT_KEY]
59
+
60
+ warn_once(name, attribute) if first
61
+ publish(model, attribute) if heard
62
+ nil
63
+ end
64
+
65
+ # A write refused because the record had read the attribute as hidden
66
+ # with nobody signed in (MaskedAttributes, write.masked_skipped
67
+ # `detected_by: hidden_without_user`): logged once per model and
68
+ # attribute and process, as the change may have been meant (a flow
69
+ # that changes a guarded attribute without a sign-in must sign in a
70
+ # system user).
71
+ #
72
+ # @param model [Class]
73
+ # @param attribute [Symbol, String]
74
+ # @param protection [Symbol] :skip or :raise
75
+ # @return [void]
76
+ def write_refused(model, attribute, protection)
77
+ key = "#{model.name}##{attribute}"
78
+ return if REFUSED.size >= WARNED_LIMIT || !REFUSED.put_if_absent(key, true).nil?
79
+
80
+ Warnings.emit("masked_attributes.write_refused_without_user.#{key}",
81
+ "#{key} was not written (masked_write_protection :#{protection}): the record had read it as " \
82
+ 'hidden with nobody signed in, so the new value may come from the hidden one. A flow that ' \
83
+ 'changes a guarded attribute without a sign-in (a webhook, a token link) must sign in a system ' \
84
+ 'user. Logged once per attribute and process; write.masked_skipped records each refusal.',
85
+ severity: :config)
86
+ rescue StandardError
87
+ nil
88
+ end
89
+
90
+ # Whether a read of `model` returned as stored in a web request still
91
+ # has something to do: the model's warning is still to come, or the
92
+ # event is stored or heard.
93
+ #
94
+ # @param model [Class, String]
95
+ # @return [Boolean]
96
+ def pending?(model)
97
+ !@warned.key?(Runtime.model_name(model)) || (GATE.open? && Audit.instrument?(EVENT))
98
+ end
99
+
100
+ # Forgets which models were warned about (tests; Audit.reset!).
101
+ # @return [void]
102
+ def reset!
103
+ WARNED_MUTEX.synchronize { @warned = {}.freeze }
104
+ REFUSED.clear
105
+ end
106
+
107
+ private
108
+
109
+ def publish(model, attribute)
110
+ return unless Audit.instrument?(EVENT)
111
+
112
+ name = Runtime.model_name(model)
113
+ attribute = attribute.to_s
114
+ return unless Runtime.once?(EVENT, name, attribute, nil)
115
+
116
+ with_request_context do
117
+ Audit.instrument(EVENT) do
118
+ { record_type: Runtime.record_type(model), record_id: nil, subject_user_id: nil,
119
+ data: { 'model' => name, 'attribute' => attribute }.merge(WebRequest.details) }
120
+ end
121
+ end
122
+ end
123
+
124
+ # The request id and IP of the web request, for an event recorded
125
+ # outside the admin's and the API's own audit context.
126
+ def with_request_context(&)
127
+ env = WebRequest.env
128
+ return yield if Context[:request_id] || env.nil?
129
+
130
+ request = ActionDispatch::Request.new(env)
131
+ Context.with(request_id: cut(request.request_id), ip: cut(RequestContext.remote_ip(request).first), &)
132
+ end
133
+
134
+ def cut(value)
135
+ value.presence && value.to_s[0, WebRequest::VALUE_LIMIT]
136
+ end
137
+
138
+ def warn_once(name, attribute)
139
+ return unless remember(name)
140
+
141
+ Warnings.emit("masked_attributes.unmasked_without_user.#{name}", warning(name, attribute), severity: :security)
142
+ rescue StandardError
143
+ nil
144
+ end
145
+
146
+ # Adds the model's name; false when it was there already or the
147
+ # limit is reached.
148
+ def remember(name)
149
+ WARNED_MUTEX.synchronize do
150
+ next false if @warned.key?(name) || @warned.size >= WARNED_LIMIT
151
+
152
+ @warned = @warned.merge(name => true).freeze
153
+ true
154
+ end
155
+ end
156
+
157
+ def warning(name, attribute)
158
+ request = WebRequest.details.values_at('method', 'path', 'endpoint').compact.join(' ')
159
+ "#{name}##{attribute} was read in a web request with nobody signed in " \
160
+ "(#{request.presence || 'a request'}) and returned as stored: masked_attributes_nil_user_strategy is " \
161
+ ':bypass. Set it to :hide_in_requests to hide guarded attributes in web requests with nobody signed in ' \
162
+ '(jobs, rake and the console keep the stored values), or sign the request in before it reads masked ' \
163
+ 'models. Logged once per model and process; the field.unmasked_without_user audit event (runtime ' \
164
+ 'group :bypass) records each request.'
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -164,6 +164,7 @@ module PermissionForge
164
164
  end
165
165
  Context.reset!
166
166
  Runtime.reset!
167
+ UnmaskedWithoutUser.reset!
167
168
  end
168
169
 
169
170
  # Installs the notification subscribers and the executor hook of the
@@ -280,22 +281,35 @@ module PermissionForge
280
281
  end
281
282
  end
282
283
 
284
+ # A database that does not answer or does not exist
285
+ # (DatabaseReach::UNREACHABLE_ERRORS) is "unchecked": nothing is
286
+ # recorded until it answers. Any other error of the check — a
287
+ # configuration error included — raises out of Audit.record, so the
288
+ # change it records rolls back.
283
289
  def check_table(name)
284
- if PermissionForge::AuditEvent.table_exists?
285
- @table_checks[name] = true
286
- else
287
- @table_checks[name] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
288
- warn_missing_table(name) unless @warned_tables[name]
289
- @warned_tables[name] = true
290
- false
290
+ cause = nil
291
+ exists = PermissionForge::DatabaseReach.table_exists?(PermissionForge::AuditEvent,
292
+ errors: PermissionForge::DatabaseReach::UNREACHABLE_ERRORS) { |e| cause = e }
293
+ if cause
294
+ warn_unchecked_table(name, cause)
295
+ return false
291
296
  end
292
- rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished => e
293
- Rails.logger&.warn("[PermissionForge] cannot check for the audit table '#{name}': #{e.class}") if defined?(Rails)
297
+ return @table_checks[name] = true if exists
298
+
299
+ @table_checks[name] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
300
+ warn_missing_table(name) unless @warned_tables[name]
301
+ @warned_tables[name] = true
294
302
  false
295
303
  end
296
304
 
305
+ def warn_unchecked_table(name, cause)
306
+ PermissionForge::Warnings.emit(:audit_table_unchecked, "cannot check for the audit table '#{name}' (#{cause.class}): " \
307
+ 'no audit events are recorded until the database answers.',
308
+ severity: :security)
309
+ end
310
+
297
311
  def warn_missing_table(name)
298
- Rails.logger.warn(BootWarnings.missing_table(name)) if defined?(Rails) && Rails.logger
312
+ PermissionForge::Warnings.emit(:audit_table_missing, BootWarnings.missing_table(name), severity: :security)
299
313
  end
300
314
  end
301
315
  end
@@ -1,22 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'warnings'
4
+ require_relative 'api_only_admin'
5
+ require_relative 'database_reach'
6
+ require_relative 'api_authorizations'
7
+
3
8
  module PermissionForge
4
9
  # What the engine checks while the host app boots: run (from
5
10
  # config.after_initialize) and ensure_tenancy_gem! (from an initializer).
6
11
  #
7
12
  # The configuration checks need nothing. The database steps are advisory
8
13
  # warnings or idempotent work, and they must never abort a boot: a Docker
9
- # build precompiles assets with no database, a worker can start while the
10
- # database fails over. The database is pinged once (db_available?); a step
11
- # that loses it midway is skipped (DATABASE_STEP_ERRORS) and the next step
12
- # runs. Anything else a step raises (a ConfigurationError) still stops the
13
- # boot.
14
+ # build precompiles assets with no database, a fresh `rails new
15
+ # --skip-kamal` app has no production database file until the deploy sets
16
+ # DATABASE_URL, a worker can start while the database fails over. The
17
+ # database is pinged once (DatabaseReach.problem: whatever connecting
18
+ # raises, an adapter's refusal of its configuration included); a step that
19
+ # loses it midway is skipped (DATABASE_STEP_ERRORS) and the next step runs.
20
+ # Anything else a step raises (a ConfigurationError) still stops the boot.
21
+ #
22
+ # A process that runs nothing but asset tasks (asset_build?:
23
+ # `bin/rails assets:precompile`, the Dockerfile's build step) runs the
24
+ # configuration checks only: it reads no data and serves nothing, and a
25
+ # build has no database to ping (an unreachable host could hold the build
26
+ # for a TCP timeout).
27
+ #
28
+ # Every warning goes through Warnings.emit, which decides the channel (the
29
+ # log; the terminal where the log is not read) and writes nothing while a
30
+ # generator boots the app (Warnings.generator_running?): `bin/rails
31
+ # generate permission_forge:install` boots before it writes the
32
+ # initializer, so its warnings described a configuration the app was about
33
+ # to stop having (the defaults, e.g. cache_backend :memory) — the first
34
+ # lines a new user read in log/development.log. Nothing is checked then
35
+ # either (run returns before the database steps). The next server, console
36
+ # or task boot checks the configuration the app has.
14
37
  #
15
38
  # @api private
16
39
  module BootChecks
17
- # Errors that mean "the database is not there (yet)": gone, refused, not
18
- # created, not migrated.
19
- DATABASE_STEP_ERRORS = [ActiveRecord::ConnectionNotEstablished, ActiveRecord::StatementInvalid].freeze
40
+ # Errors that mean "the database is not there (yet)" once it answered the
41
+ # ping: gone, refused, not created, not migrated (DatabaseReach::READ_ERRORS).
42
+ DATABASE_STEP_ERRORS = DatabaseReach::READ_ERRORS
43
+
44
+ # The asset tasks of Propshaft and Sprockets (each boots the app through
45
+ # :environment). A process whose Rake top-level tasks are all among them
46
+ # is an asset build (asset_build?).
47
+ ASSET_TASKS = %w[assets:precompile assets:clean assets:clobber].freeze
20
48
 
21
49
  # The boot steps that read the database, in order. Each runs on its
22
50
  # own: one that loses the database is skipped (database_step).
@@ -34,16 +62,47 @@ module PermissionForge
34
62
  masked_attributes: ->(_config) { MaskingSync.boot! }
35
63
  }.freeze
36
64
 
65
+ # The commands `bin/rails generate` and `bin/rails destroy` run (the one
66
+ # definition is the emitter's: Warnings::GENERATOR_COMMANDS).
67
+ GENERATOR_COMMANDS = Warnings::GENERATOR_COMMANDS
68
+
37
69
  class << self
38
70
  # @param config [Configuration]
39
71
  def run(config)
40
72
  config.validate_gdpr_explicit_choice!
73
+ return MaskingSync.reset! if generator_running?
74
+
41
75
  warn_configuration(config)
42
- return database_unreachable unless db_available?
76
+ return MaskingSync.reset! if asset_build?
77
+
78
+ problem = database_problem
79
+ return database_unreachable(problem) if problem
43
80
 
44
81
  DATABASE_STEPS.each { |name, step| database_step(name) { step.call(config) } }
45
82
  end
46
83
 
84
+ # Whether this boot is that of a generator (`bin/rails generate` or
85
+ # `bin/rails destroy`): Warnings.generator_running?, which also keeps
86
+ # every warning quiet then.
87
+ #
88
+ # @return [Boolean]
89
+ def generator_running?
90
+ Warnings.generator_running?
91
+ end
92
+
93
+ # Whether this process runs nothing but asset tasks (ASSET_TASKS): the
94
+ # tasks Rake was asked for on the command line (`bin/rails
95
+ # assets:precompile`), not a task that only depends on one. False
96
+ # outside Rake.
97
+ #
98
+ # @return [Boolean]
99
+ def asset_build?
100
+ return false unless defined?(::Rake.application)
101
+
102
+ tasks = ::Rake.application.top_level_tasks.map { |task| task.sub(/\[.*\z/m, '') }
103
+ tasks.any? && tasks.all? { |task| ASSET_TASKS.include?(task) }
104
+ end
105
+
47
106
  # HasTenantScoping silently skips the scope when the gem is absent,
48
107
  # which would leave every tenant's permission data visible to every
49
108
  # other tenant — refuse to boot instead.
@@ -71,13 +130,13 @@ module PermissionForge
71
130
  return unless conditional_sets.exists?
72
131
  return unless resolved_context(config).empty?
73
132
 
74
- Rails.logger.warn '[PermissionForge] SECURITY warning: ' \
75
- "#{conditional_sets.count} permission set(s) use activation conditions " \
76
- '(session flag, allowed_ips, or login hours) but request_context_resolver ' \
77
- 'returns empty context. These sets will evaluate as unconditional ' \
78
- '(ALWAYS ACTIVE) — IP/hours/consent restrictions are NOT enforced. ' \
79
- 'Configure config.request_context_resolver, or set ' \
80
- 'config.conditional_sets_strict_mode = true to fail closed.'
133
+ Warnings.emit(:conditional_sets_without_context,
134
+ "#{conditional_sets.count} permission set(s) use activation conditions " \
135
+ '(session flag, allowed_ips, or login hours) but request_context_resolver ' \
136
+ 'returns empty context. These sets will evaluate as unconditional ' \
137
+ '(ALWAYS ACTIVE) — IP/hours/consent restrictions are NOT enforced. ' \
138
+ 'Configure config.request_context_resolver, or set ' \
139
+ 'config.conditional_sets_strict_mode = true to fail closed.', severity: :security)
81
140
  rescue ActiveRecord::StatementInvalid
82
141
  # Tables don't exist yet (pre-migration)
83
142
  end
@@ -87,88 +146,146 @@ module PermissionForge
87
146
  return if config.api_require_authorization
88
147
  return if config.api_action_authorization && config.api_assignment_authorization
89
148
 
90
- Rails.logger&.warn '[PermissionForge] SECURITY warning: API is enabled, ' \
91
- 'config.api_require_authorization is false and ' \
92
- 'api_action_authorization / api_assignment_authorization is not set. ' \
93
- 'api_auth only proves identity — any authenticated caller can create ' \
94
- 'permission sets and assign them (privilege escalation). Configure both ' \
95
- 'authorization lambdas, or remove api_require_authorization = false ' \
96
- '(the default, true, denies mutations until they are set).'
149
+ Warnings.emit(:api_authorization_gap,
150
+ 'API is enabled, config.api_require_authorization is false and ' \
151
+ 'api_action_authorization / api_assignment_authorization is not set. ' \
152
+ 'api_auth only proves identity — any authenticated caller can create ' \
153
+ 'permission sets and assign them (privilege escalation). Configure both ' \
154
+ 'authorization lambdas, or remove api_require_authorization = false ' \
155
+ '(the default, true, denies mutations until they are set).', severity: :security, audience: :server)
97
156
  end
98
157
 
99
- # One query for the distinct unmasked names (no record is built), and
100
- # one more for the resource of the first match.
101
- def warn_unmasked_gdpr_fields(config)
102
- patterns = config.gdpr_field_patterns
103
- return if patterns.blank?
158
+ # The API is on and api_auth admits callers, but a lambda the API
159
+ # needs is unset (ApiAuthorizations.unset): what it guards is refused
160
+ # (403). It fails closed; the note says so before the first refused
161
+ # request does. Under api_require_authorization = false the missing
162
+ # change and assignment lambdas are api_authorization_gap's instead.
163
+ #
164
+ # @param config [Configuration]
165
+ def warn_api_authorizations_unset(config)
166
+ lines = ApiAuthorizations.lines(config)
167
+ return if lines.empty?
168
+
169
+ Warnings.emit(:api_authorization_unset,
170
+ 'The JSON API is on and api_auth admits callers, but these authorization lambdas are not ' \
171
+ "set, so the API refuses (403, naming the option) what each guards — #{lines.join('; ')}. " \
172
+ 'Set each one in config/initializers/permission_forge.rb (docs/admin-api.md#requiring-authorization-lambdas).',
173
+ audience: :server)
174
+ end
104
175
 
105
- unmasked = PermissionForge::ResourceField.where(mask_access: false)
106
- name = unmasked.distinct.pluck(:name).find { |field_name| gdpr_match?(field_name, patterns) }
176
+ # One query for the distinct names of the rows in the clear that reach
177
+ # someone's hash (in effect, in a granting set; no record is built),
178
+ # and one more for the resource of the first match. What counts as
179
+ # personal data is PermissionForge::SensitiveColumns' answer, as for
180
+ # the GDPR audit and the admin.
181
+ def warn_unmasked_gdpr_fields(config)
182
+ sensitive = PermissionForge::SensitiveColumns.new(configuration: config)
183
+ in_clear = PermissionForge::ResourceField.in_effect.merge(PermissionForge::PermissionSet.granting).exposing_unacknowledged
184
+ name, match = first_sensitive(in_clear.distinct.pluck(:name), sensitive)
107
185
  return unless name
108
186
 
109
- resource = unmasked.where(name: name).first&.resource
110
- Rails.logger.warn "[PermissionForge] GDPR warning: field '#{name}' on resource " \
111
- "'#{resource&.name}' matches GDPR pattern but is not masked. " \
112
- 'Consider enabling gdpr_defaults or masking this field manually.'
187
+ resource = in_clear.where(name: name).first&.resource
188
+ Warnings.emit(:unmasked_gdpr_fields,
189
+ "GDPR warning: field '#{name}' on resource '#{resource&.name}' #{matched_by(match)} " \
190
+ 'but is not masked. Consider enabling gdpr_defaults or masking this field manually.')
113
191
  rescue ActiveRecord::StatementInvalid
114
192
  # Table doesn't exist yet (pre-migration)
115
193
  end
116
194
 
195
+ # The admin screens in a `rails new --api` application: without the
196
+ # cookies, session and flash middlewares every admin page that renders
197
+ # a form is a 500 (ApiOnlyAdmin says which ones to add). Skipped when
198
+ # config.api_only_admin_warning is false.
199
+ #
200
+ # @param application [Rails::Application]
201
+ # @param config [Configuration]
202
+ def warn_api_only_admin(application = Rails.application, config: PermissionForge.configuration)
203
+ return unless config.api_only_admin_warning
204
+
205
+ missing = ApiOnlyAdmin.missing_middlewares(application)
206
+ return if missing.empty?
207
+
208
+ Warnings.emit(:api_only_admin, ApiOnlyAdmin.message(missing), severity: :config, audience: :server)
209
+ end
210
+
211
+ # The engine's after_routes_loaded hook: the api_only_admin warning,
212
+ # once per process (a development reload draws the routes again).
213
+ #
214
+ # @param application [Rails::Application]
215
+ def routes_loaded(application)
216
+ return if @routes_checked
217
+
218
+ @routes_checked = true
219
+ warn_api_only_admin(application)
220
+ end
221
+
117
222
  private
118
223
 
119
- # Connects and pings. `ActiveRecord::Base.connection` alone is not enough:
120
- # since Rails 7.2 it hands back a connection that has not connected yet,
121
- # so an unreachable database looked available.
122
- def db_available?
123
- PermissionForge::PermissionSet.connection_pool.with_connection(&:verify!)
124
- true
125
- rescue ActiveRecord::ActiveRecordError
126
- false
224
+ # [name, SensitiveColumns::Match] of the first name that looks like personal data.
225
+ def first_sensitive(names, sensitive)
226
+ names.lazy.filter_map { |name| (match = sensitive.match(name)) && [name, match] }.first
227
+ end
228
+
229
+ # What made the field personal data (SensitiveColumns::Match#source).
230
+ def matched_by(match)
231
+ match.filter_parameter? ? "is named by the application's filter_parameters" : "matches the GDPR pattern '#{match.pattern}'"
232
+ end
233
+
234
+ # What connecting to the gem's database raised, nil when it answered
235
+ # (DatabaseReach.problem).
236
+ #
237
+ # @return [Exception, nil]
238
+ def database_problem
239
+ DatabaseReach.problem
127
240
  end
128
241
 
129
242
  def database_step(name)
130
243
  yield
131
244
  rescue *DATABASE_STEP_ERRORS => e
132
- Rails.logger&.warn "[PermissionForge] Boot check #{name} skipped: the database could not be read (#{e.class})."
245
+ Warnings.emit(:"boot_check_skipped.#{name}",
246
+ "Boot check #{name} skipped: the database could not be read (#{e.class}).")
133
247
  end
134
248
 
135
249
  # The boot step did not run: the first request or job that reaches the
136
- # database applies auto-applied masking (MaskingSync).
137
- def database_unreachable
250
+ # database applies auto-applied masking (MaskingSync). The cause is
251
+ # named: "not configured" (an adapter's ArgumentError) reads otherwise
252
+ # than "refused". The ping loads the gem's models (and Active Record
253
+ # with them, as Rails' lazy load would): an error raised there — a
254
+ # host's on_load(:active_record) hook — is named the same way.
255
+ def database_unreachable(problem)
138
256
  MaskingSync.reset!
139
- Rails.logger&.warn '[PermissionForge] The database could not be reached at boot; the boot checks that ' \
140
- 'read it (security warnings) were skipped. Auto-applied masking is applied at the first ' \
141
- 'request or job that reaches the database.'
257
+ Warnings.emit(:database_unreachable,
258
+ "The database could not be reached at boot, or the gem's models could not be loaded " \
259
+ "(#{problem.class}: #{problem.message.lines.first&.strip}); " \
260
+ 'the boot checks that read it (security warnings) were skipped. Auto-applied masking is ' \
261
+ 'applied at the first request or job that reaches the database.')
142
262
  end
143
263
 
144
264
  # The boot warnings that only read the configuration.
145
265
  def warn_configuration(config)
146
- warn_memory_cache_in_production(config)
266
+ CacheWarnings.call(config)
147
267
  warn_basic_auth_without_credentials(config)
148
268
  warn_api_authorization_gap(config)
149
- config.tenantless_admin_warning&.then { |message| Rails.logger&.warn(message) }
269
+ warn_api_authorizations_unset(config)
270
+ config.tenantless_admin_warning&.then { |message| warn_tenantless_admin(message) }
150
271
  QueryScopingWarnings.warn_unscoped_detection_raise_in_production(config)
272
+ WebRequest::StackCheck.check(config)
151
273
  end
152
274
 
153
- def warn_memory_cache_in_production(config)
154
- return unless Rails.env.production? && config.cache_backend == :memory
155
-
156
- Rails.logger&.warn '[PermissionForge] WARNING: Using in-process MemoryCache in production. ' \
157
- 'Permissions will be inconsistent across Puma workers. ' \
158
- 'Configure config.cache_backend = :rails_cache with Redis, Memcached, or SolidCache.'
275
+ # AdminUiSettings#tenantless_admin_warning is the text; the emitter
276
+ # puts the prefix on.
277
+ def warn_tenantless_admin(message)
278
+ Warnings.emit(:tenantless_admin, message, severity: :security, audience: :server)
159
279
  end
160
280
 
161
281
  def warn_basic_auth_without_credentials(config)
162
282
  return unless config.http_basic_auth_enabled
163
283
  return if config.http_basic_auth_user.present? && config.http_basic_auth_password.present?
164
284
 
165
- Rails.logger&.warn '[PermissionForge] HTTP Basic Auth is enabled but credentials are not configured. ' \
166
- 'The admin UI will return 401 for all requests. ' \
167
- 'Set config.http_basic_auth_user and config.http_basic_auth_password.'
168
- end
169
-
170
- def gdpr_match?(field_name, patterns)
171
- patterns.any? { |regex, _| field_name.match?(regex) }
285
+ Warnings.emit(:basic_auth_without_credentials,
286
+ 'HTTP Basic Auth is enabled but credentials are not configured. The admin UI will return ' \
287
+ '401 for all requests. Set config.http_basic_auth_user and config.http_basic_auth_password.',
288
+ audience: :server)
172
289
  end
173
290
 
174
291
  # Taken as the per-read selection takes it: a Hash, or anything with
@@ -46,6 +46,16 @@ module PermissionForge
46
46
  false
47
47
  end
48
48
 
49
+ # Whether the store keeps what is written to it. A store that keeps
50
+ # nothing (cache_backend = :null, a :null_store) caches no permission
51
+ # hash, and cannot carry the token MaskingSync tells other processes
52
+ # about a newly masked model with.
53
+ #
54
+ # @return [Boolean]
55
+ def keeps_entries?
56
+ true
57
+ end
58
+
49
59
  # Writes an entry that never expires — not even under a default expiry
50
60
  # the store applies to every entry (a Rails cache configured with
51
61
  # expires_in). Here a write without a TTL, which is that already.
@@ -9,6 +9,11 @@ module PermissionForge
9
9
  super
10
10
  end
11
11
 
12
+ # Nothing written is kept (see Base#keeps_entries?).
13
+ def keeps_entries?
14
+ false
15
+ end
16
+
12
17
  # @return [nil] always a miss
13
18
  def read(_key)
14
19
  nil