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,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Cache
5
+ # What one cache-touching operation did to the cached permissions, as
6
+ # every surface reports it — the terminal (RakeOutput), the admin flash
7
+ # (CacheConfirmation), the API body and the summary audit event:
8
+ #
9
+ # - `confirmed`: true when the store confirmed every removal the
10
+ # operation asked for (Cache::Failure reported nothing), false when it
11
+ # did not (the old permissions may be served until cache_ttl), nil when
12
+ # that is not known where the outcome is made — the invalidation
13
+ # follows the surrounding transaction's commit (Cache::Settlement), or
14
+ # the summary event is written before the cache work runs;
15
+ # - `process_local`: the store lives in each process
16
+ # (Cache::Base#process_local?: `cache_backend = :memory`, or Rails.cache a
17
+ # `:memory_store`), so only the process that ran the operation saw it;
18
+ # - `in_request`: the operation ran in a server process (the admin or
19
+ # the API: Audit::Source 'admin_ui' / 'api', or any code of a web
20
+ # server process, a host controller among it: Report.in_request?), so
21
+ # on a process-local store that server process did see it and the
22
+ # *other* web workers and the job processes kept theirs; false for a
23
+ # rake task, a runner, a console or a job process, where every other
24
+ # process kept theirs;
25
+ # - `unconfirmed`: how many users the store did not confirm (nil when not
26
+ # known yet);
27
+ # - `work`: false when the operation did no cache work at all — a dry run
28
+ # (.none: `confirmed` nil, `unconfirmed` 0); true otherwise. Only an
29
+ # outcome without work carries the key in #to_h (`work: false`), so
30
+ # every answer of an operation that did its work keeps its four keys.
31
+ # A dry run used to answer `confirmed: true` for work it never did
32
+ # (field test round 7, E#55).
33
+ #
34
+ # Made by Cache::Report, the one way a service measures an invalidation.
35
+ #
36
+ # @api private
37
+ Outcome = Data.define(:confirmed, :process_local, :unconfirmed, :in_request, :work) do
38
+ # @param work [Boolean] false only for an operation that did no cache work (.none)
39
+ def initialize(confirmed:, process_local:, unconfirmed:, in_request:, work: true)
40
+ super
41
+ end
42
+
43
+ # The outcome of an operation that did no cache work (every dry run):
44
+ # nothing asked of the store, so nothing confirmed and nobody left
45
+ # unconfirmed; the store's scope and the origin as measured.
46
+ #
47
+ # @param process_local [Boolean]
48
+ # @param in_request [Boolean]
49
+ # @return [Outcome]
50
+ def self.none(process_local:, in_request:)
51
+ new(confirmed: nil, process_local: process_local, unconfirmed: 0, in_request: in_request, work: false)
52
+ end
53
+
54
+ # The outcome before the cache work has run, or where its result is not
55
+ # known: nothing confirmed yet, the store's scope and the origin known
56
+ # already.
57
+ #
58
+ # @param process_local [Boolean]
59
+ # @param in_request [Boolean]
60
+ # @return [Outcome]
61
+ def self.deferred(process_local:, in_request:)
62
+ new(confirmed: nil, process_local: process_local, unconfirmed: nil, in_request: in_request)
63
+ end
64
+
65
+ # An Outcome from what a service answered under `cache:` (its Hash, or
66
+ # the Outcome itself).
67
+ #
68
+ # @param value [Outcome, Hash]
69
+ # @return [Outcome]
70
+ def self.wrap(value)
71
+ return value if value.is_a?(self)
72
+
73
+ hash = value.to_h.transform_keys(&:to_sym)
74
+ new(confirmed: hash[:confirmed], process_local: hash[:process_local] == true, unconfirmed: hash[:unconfirmed],
75
+ in_request: hash[:in_request] == true, work: hash[:work] != false)
76
+ end
77
+
78
+ # What a summary event records (string keys, as event data is stored).
79
+ #
80
+ # @return [Hash]
81
+ def event_data
82
+ { 'cache_confirmed' => confirmed, 'process_local' => process_local, 'in_request' => in_request, 'unconfirmed' => unconfirmed }
83
+ end
84
+
85
+ def process_local? = process_local == true
86
+ def in_request? = in_request == true
87
+ def confirmed? = confirmed == true
88
+ # No cache work was done (.none: a dry run).
89
+ def none? = work == false
90
+
91
+ # The outcome as a service answers it: the four keys, and `work: false`
92
+ # for an operation that did no cache work (#none?).
93
+ #
94
+ # @return [Hash]
95
+ def to_h
96
+ none? ? super : super.except(:work)
97
+ end
98
+
99
+ # A flash: an alert only when the store said no; a confirmation that is
100
+ # not known yet is not a failure to announce.
101
+ #
102
+ # @return [Symbol] :notice or :alert
103
+ def flash_kind = confirmed == false ? :alert : :notice
104
+
105
+ # Which processes kept their cached permissions: `:none` (a shared
106
+ # store), `:other_processes` (a process-local store, the operation ran
107
+ # in a server process: the other web workers and the job processes),
108
+ # `:servers` (a process-local store, the operation ran elsewhere: every
109
+ # web and job process).
110
+ #
111
+ # @return [Symbol]
112
+ def kept_by
113
+ return :none unless process_local?
114
+
115
+ in_request? ? :other_processes : :servers
116
+ end
117
+ end
118
+ end
119
+ end
@@ -83,6 +83,12 @@ module PermissionForge
83
83
  cache_store.is_a?(ActiveSupport::Cache::MemoryStore)
84
84
  end
85
85
 
86
+ # A :null_store keeps nothing (the Rails 7.1/7.2 development default
87
+ # without `bin/rails dev:cache`).
88
+ def keeps_entries?
89
+ !cache_store.is_a?(ActiveSupport::Cache::NullStore)
90
+ end
91
+
86
92
  # An explicit `expires_in: nil` overrides the store's default expiry.
87
93
  def write_permanent(key, value)
88
94
  cache_store.write(prefixed_key(key), value, expires_in: nil)
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'outcome'
4
+
5
+ module PermissionForge
6
+ module Cache
7
+ # The one way a service measures the cache work it does: runs the block
8
+ # under Cache::Failure.collect (so a host's override of
9
+ # invalidate_permissions decides nothing), asks the store once whether it
10
+ # is process-local, and answers a Cache::Outcome.
11
+ #
12
+ # outcome = Cache::Report.of { user.invalidate_permissions } # one user
13
+ # outcome = Cache::Report.of(user_ids: ids) { ids.each { ... } } # several: unconfirmed counted per user
14
+ #
15
+ # Measured inside a caller's transaction the outcome is pending
16
+ # (#deferred?, the one reading): the invalidations follow its commit.
17
+ #
18
+ # report = Cache::Report.new # when the summary event is written inside the measured block
19
+ # report.pending.event_data # the scope known in advance, nothing confirmed yet
20
+ # report.measure { ... }.value
21
+ # report.outcome(user_ids: ids)
22
+ #
23
+ # @api private
24
+ class Report
25
+ # @return [Object] what the measured block returned
26
+ attr_reader :value
27
+
28
+ class << self
29
+ # Measures the block's cache work.
30
+ #
31
+ # @param user_ids [Array, nil] the users invalidated (nil: one, unnamed)
32
+ # @return [Outcome] pending (#deferred?) inside a caller's transaction
33
+ def of(user_ids: nil, &)
34
+ measure(&).outcome(user_ids: user_ids)
35
+ end
36
+
37
+ # @return [Report] measured; `value` holds what the block returned
38
+ def measure(&)
39
+ new.measure(&)
40
+ end
41
+ end
42
+
43
+ # Runs the block, collecting the cache failures reported inside it.
44
+ # Whether the work waits for a caller's transaction is read here, once,
45
+ # before the first block (#deferred?).
46
+ #
47
+ # @return [Report] self
48
+ def measure
49
+ @deferred = PermissionForge::OpenTransaction.current ? true : false if @deferred.nil?
50
+ @collected = Failure.collect { @value = yield }
51
+ self
52
+ end
53
+
54
+ # The one reading of "deferred" for every operation that measures its
55
+ # cache work: a caller's transaction was open when it was measured
56
+ # (OpenTransaction.current), so the invalidations follow its commit
57
+ # (the rows' own after_commit, Cache::Settlement) and nothing is
58
+ # confirmed yet. False before anything was measured.
59
+ #
60
+ # @return [Boolean]
61
+ def deferred? = @deferred == true
62
+
63
+ # Whether the store lives in this process only — asked once per report.
64
+ #
65
+ # @return [Boolean]
66
+ def process_local?
67
+ @process_local = PermissionForge.cache.process_local? if @process_local.nil?
68
+ @process_local
69
+ end
70
+
71
+ # Whether the operation runs in a server process (Report.in_request?).
72
+ #
73
+ # @return [Boolean]
74
+ def in_request?
75
+ @in_request = Report.in_request? if @in_request.nil?
76
+ @in_request
77
+ end
78
+
79
+ # The one definition of "the operation ran in a server process": the
80
+ # audit context says it came from the admin or the API, or this process
81
+ # is a web server (Warnings.server_process?: a host controller, or a
82
+ # job run inline by Puma, whatever its audit source). A rake task, a
83
+ # runner, a console or a job process is a process of its own.
84
+ #
85
+ # @return [Boolean]
86
+ def self.in_request?
87
+ REQUEST_SOURCES.include?(PermissionForge::Audit::Source.current) || PermissionForge::Warnings.server_process?
88
+ end
89
+
90
+ # The audit sources of a request a server process serves.
91
+ REQUEST_SOURCES = %w[admin_ui api].freeze
92
+
93
+ # Those of `user_ids` whose invalidation the store confirmed.
94
+ #
95
+ # @param user_ids [Array]
96
+ # @return [Array]
97
+ def confirmed(user_ids)
98
+ collected.confirmed(user_ids)
99
+ end
100
+
101
+ # The outcome before the measurement (the summary event of a service
102
+ # that invalidates after writing it records this).
103
+ #
104
+ # @return [Outcome]
105
+ def pending
106
+ Outcome.deferred(process_local: process_local?, in_request: in_request?)
107
+ end
108
+
109
+ # The outcome of an operation that does no cache work (a dry run):
110
+ # Outcome.none, with the store's scope and the origin as this report
111
+ # reads them.
112
+ #
113
+ # @return [Outcome]
114
+ def none
115
+ Outcome.none(process_local: process_local?, in_request: in_request?)
116
+ end
117
+
118
+ # The outcome of a dry run, measured nowhere (Report#none).
119
+ #
120
+ # @return [Outcome]
121
+ def self.none
122
+ new.none
123
+ end
124
+
125
+ # @param user_ids [Array, nil] the users invalidated, to count the
126
+ # unconfirmed ones (nil: one unnamed — any failure counts as one)
127
+ # @param unconfirmed [Integer, nil] a count the caller made itself
128
+ # (PermissionCacheClearer counts by slice)
129
+ # @return [Outcome] pending while #deferred?
130
+ def outcome(user_ids: nil, unconfirmed: nil)
131
+ return pending if deferred?
132
+
133
+ unconfirmed ||= count_unconfirmed(user_ids)
134
+ Outcome.new(confirmed: unconfirmed.zero?, process_local: process_local?, unconfirmed: unconfirmed, in_request: in_request?)
135
+ end
136
+
137
+ private
138
+
139
+ def collected
140
+ @collected || Failure::Collected.new
141
+ end
142
+
143
+ def count_unconfirmed(user_ids)
144
+ return user_ids.size - confirmed(user_ids).size if user_ids
145
+
146
+ collected.to_a.empty? ? 0 : 1
147
+ end
148
+ end
149
+ end
150
+ end
@@ -15,7 +15,9 @@ module PermissionForge
15
15
  # another version never reads an entry it would misread. An invalidation
16
16
  # removes the keys of the previous namespaces as well
17
17
  # (PREVIOUS_ENTRY_NAMESPACES), so a change made through either version
18
- # reaches both. The stamp is not namespaced: every version must see it.
18
+ # reaches both — for one minor version after a bump (0.15 dropped the
19
+ # bare keys of 0.13; none is listed now). The stamp is not namespaced:
20
+ # every version must see it.
19
21
  #
20
22
  # An invalidation removes the base entries, and reads nothing: the
21
23
  # per-context variants of a user with conditional sets cannot be reached
@@ -30,8 +32,10 @@ module PermissionForge
30
32
  # @api private
31
33
  module UserKeys
32
34
  ENTRY_NAMESPACE = 'v2'
33
- # '' is the bare key of 0.13. Drop in 0.15.
34
- PREVIOUS_ENTRY_NAMESPACES = [''].freeze
35
+ # The namespaces of the previous entry format, removed by every
36
+ # invalidation for one minor version after a bump ('' would be a bare
37
+ # key). Empty since 0.15, which dropped the bare keys of 0.13.
38
+ PREVIOUS_ENTRY_NAMESPACES = [].freeze
35
39
 
36
40
  module_function
37
41
 
@@ -118,7 +122,7 @@ module PermissionForge
118
122
  [current, previous]
119
123
  end
120
124
 
121
- # `key`, of this namespace, in another one ('' : the bare key of 0.13).
125
+ # `key`, of this namespace, in another one ('' : a bare key).
122
126
  def in_namespace(key, namespace)
123
127
  bare = key.delete_prefix("#{ENTRY_NAMESPACE}:")
124
128
  namespace.empty? ? bare : "#{namespace}:#{bare}"
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # Boot warnings about a cache store that does not reach other processes.
5
+ # Called by BootChecks once per boot; only the configuration and the
6
+ # store's class are read. Each warning goes through Warnings.emit, which
7
+ # decides the channel (in development the terminal as well as the log:
8
+ # the boot logs before `bin/rails server` attaches its console logger, and
9
+ # `bin/rails runner`, the console and `db:seed` never do).
10
+ #
11
+ # A store of its own in every process (Cache::Base#process_local?:
12
+ # cache_backend = :memory, or Rails.cache / a store instance that is an
13
+ # ActiveSupport MemoryStore): an invalidation reaches the cache of the
14
+ # process that makes it, so a change made elsewhere (a console,
15
+ # `bin/rails runner`, a rake task, another Puma worker) leaves this
16
+ # process's cached permission hashes as they were until cache_ttl.
17
+ #
18
+ # Such a store, or one that keeps nothing (Cache::Base#keeps_entries?:
19
+ # cache_backend = :null, a :null_store), also cannot carry the token
20
+ # MaskingSync announces a newly masked model with, while a masking setting
21
+ # takes its models from the Resource rows (MaskedModels.row_driven?). In
22
+ # development MaskingSync re-reads the Resource names from the database
23
+ # instead (MaskingSync.database_recheck?); elsewhere a model first named in
24
+ # another process stays unmasked here until a restart.
25
+ #
26
+ # @api private
27
+ module CacheWarnings
28
+ # The remedies for masking that cannot be announced, in every warning.
29
+ MASKING_REMEDIES = 'Use a cache store every process shares (Redis, Memcached, Solid Cache; ' \
30
+ 'in development also :file_store), or include PermissionForge::Concerns::MaskedAttributes ' \
31
+ 'in each model that holds personal data and set config.auto_apply_masked_attributes = false ' \
32
+ '(the recommended production setup), or list the models in ' \
33
+ 'config.auto_apply_masked_attributes. See docs/masked-attributes.md.'
34
+
35
+ # What store_description adds about a :memory_store in development.
36
+ DEVELOPMENT_DEFAULT = ', the Rails development default'
37
+
38
+ module_function
39
+
40
+ # @param config [Configuration]
41
+ # @return [void]
42
+ def call(config)
43
+ if Rails.env.production?
44
+ warn_in_production(config)
45
+ elsif Rails.env.development?
46
+ warn_in_development(config)
47
+ end
48
+ end
49
+
50
+ def warn_in_production(config)
51
+ cache = PermissionForge.cache
52
+ if cache.process_local?
53
+ Warnings.emit(:cache_process_local,
54
+ 'Using an in-process permission cache in production (cache_backend = :memory, or ' \
55
+ 'Rails.cache is a :memory_store). Permissions will be inconsistent across Puma workers: a ' \
56
+ 'revoked permission stays in use in the other workers until cache_ttl. ' \
57
+ 'Configure config.cache_backend = :rails_cache with Redis, Memcached, or SolidCache.', severity: :security)
58
+ end
59
+ return unless unannounced_masking?(cache)
60
+
61
+ Warnings.emit(:cache_unannounced_masking,
62
+ "#{masking_setting} takes its models from the Resource rows, but the cache store is " \
63
+ "#{store_description(config, cache)}: it cannot tell the other processes that a Resource row " \
64
+ 'names a new model. A model first named by a row committed in another process (another ' \
65
+ 'worker, a console, a rake task) stays unmasked in this process until it restarts. ' \
66
+ "#{MASKING_REMEDIES}", severity: :security)
67
+ end
68
+
69
+ # Rails 8.0+ generates `config.cache_store = :memory_store` for
70
+ # development (7.1/7.2: :memory_store after `bin/rails dev:cache`,
71
+ # :null_store otherwise), and the generated initializer caches through
72
+ # Rails.cache.
73
+ def warn_in_development(config)
74
+ cache = PermissionForge.cache
75
+ Warnings.emit(:cache_stale_permissions, stale_permissions_warning(config)) if cache.process_local?
76
+ Warnings.emit(:cache_development_masking, development_masking_warning(config, cache)) if unannounced_masking?(cache)
77
+ end
78
+
79
+ # Printed by every development process that boots (a server, a console,
80
+ # `bin/rails runner`, `db:seed`), so it speaks of processes, not of
81
+ # "this server".
82
+ def stale_permissions_warning(config)
83
+ "Development: the permission cache is #{store_description(config, PermissionForge.cache)}, " \
84
+ 'one per process. A permission change made in one process (a console, `bin/rails runner`, ' \
85
+ "a rake task or db:seed) invalidates only that process's cache: a running server keeps the " \
86
+ "permissions it has cached until cache_ttl (#{config.cache_ttl.inspect} s) or a restart. " \
87
+ "Changes made in the server's own admin UI apply there at once. To share the cache between " \
88
+ 'processes, set config.cache_store = :file_store, "tmp/cache/store" in ' \
89
+ 'config/environments/development.rb (with config.cache_backend = :rails_cache), or set ' \
90
+ 'config.cache_backend = :null in development (no caching). See docs/configuration.md#cache-consistency.'
91
+ end
92
+
93
+ def development_masking_warning(config, cache)
94
+ "Development: #{masking_setting} takes its models from the Resource rows, and the cache " \
95
+ "store is #{store_description(config, cache)}: it cannot tell a running server that a Resource row " \
96
+ 'committed from a console, `bin/rails runner` or a rake task names a new model. In development ' \
97
+ 'the server re-reads the Resource names from the database instead (one query at most every ' \
98
+ "#{MaskingSync::DATABASE_RECHECK} s); in production a process would mask such a model only after a " \
99
+ "restart. #{MASKING_REMEDIES}"
100
+ end
101
+
102
+ # A row-driven masking setting with a store that cannot carry
103
+ # MaskingSync's token to another process.
104
+ def unannounced_masking?(cache)
105
+ MaskedModels.row_driven? && (cache.process_local? || !cache.keeps_entries?)
106
+ end
107
+
108
+ def masking_setting
109
+ config = PermissionForge.configuration
110
+ names = { 'auto_apply_masked_attributes' => config.auto_apply_masked_attributes,
111
+ 'mask_virtual_attributes' => config.mask_virtual_attributes }
112
+ on = names.reject { |_name, value| MaskedModels::OFF.include?(value) || value.is_a?(Array) }
113
+ "auto-applied masking (#{on.map { |name, value| "#{name} = #{value.inspect}" }.join(', ')})"
114
+ end
115
+
116
+ # How the warnings name the store. A :memory_store is what Rails
117
+ # generates for development (DEVELOPMENT_DEFAULT): said in a development
118
+ # boot only — a production boot with one has chosen it (field test
119
+ # round 8, E#66).
120
+ #
121
+ # @param env [ActiveSupport::EnvironmentInquirer] the environment booting
122
+ def store_description(config, cache, env: Rails.env)
123
+ return kept_nothing_description(config) unless cache.keeps_entries?
124
+
125
+ case config.cache_backend
126
+ when :memory then 'in memory (cache_backend = :memory)'
127
+ when :rails_cache then "an in-memory store (Rails.cache is a :memory_store#{DEVELOPMENT_DEFAULT if env.development?})"
128
+ else 'an in-memory store (cache_backend is an ActiveSupport::Cache::MemoryStore)'
129
+ end
130
+ end
131
+
132
+ def kept_nothing_description(config)
133
+ case config.cache_backend
134
+ when :null then 'one that keeps nothing (cache_backend = :null)'
135
+ when :rails_cache then 'one that keeps nothing (Rails.cache is a :null_store)'
136
+ else 'one that keeps nothing (cache_backend is an ActiveSupport::Cache::NullStore)'
137
+ end
138
+ end
139
+ end
140
+ end
@@ -107,6 +107,7 @@ module PermissionForge
107
107
  def pf_build_auto_scope
108
108
  current_user = PermissionForge.current_user
109
109
  return pf_nil_user_auto_scope unless current_user
110
+ return pf_no_read_access_auto_scope(current_user) if PermissionForge::RecordReadAccess.denied?(current_user, self)
110
111
 
111
112
  # Resolve the rules once — permissions_hash is a cache read per
112
113
  # call, and this hook runs on every relation build: those of this
@@ -130,6 +131,17 @@ module PermissionForge
130
131
  )
131
132
  end
132
133
 
134
+ # A signed-in user the model's Resource chain grants no read_access,
135
+ # under no_read_access_strategy :deny (RecordReadAccess): no rows,
136
+ # access.denied once per request.
137
+ def pf_no_read_access_auto_scope(current_user)
138
+ PermissionForge::Audit::Runtime.denied(
139
+ PermissionForge::Audit::Runtime::DENIED,
140
+ reason: PermissionForge::RecordReadAccess::REASON, model: self, via: 'auto_scope', user: current_user, once: true
141
+ )
142
+ none
143
+ end
144
+
133
145
  # Fail closed for :deny AND :raise — raising from inside a default_scope
134
146
  # would detonate in consoles, rake tasks, and view-layer counts, so the
135
147
  # strictest safe behavior is an empty relation (documented deviation).
@@ -148,9 +160,14 @@ module PermissionForge
148
160
  # Filter-type rules only: join-type rules inside a default_scope would
149
161
  # break includes/count/pluck and duplicate rows, so they remain the job
150
162
  # of permission_scoped (a boot warning fires when join rules exist while
151
- # Level 1 is enabled).
163
+ # Level 1 is enabled). A join type this version does not know joins
164
+ # nothing — the applier makes such a rule match nothing — so it applies
165
+ # here too: skipped, it would leave the default scope wider than
166
+ # find_by and permission_scoped.
152
167
  def pf_filter_rule?(rule)
153
- PermissionForge::PermissionSetRule.filter_join_type?(rule[:join_type])
168
+ join_type = rule[:join_type]
169
+ PermissionForge::PermissionSetRule.filter_join_type?(join_type) ||
170
+ !PermissionForge::Services::PermissionRuleApplier::JOIN_METHODS.key?(join_type.to_s)
154
171
  end
155
172
  end
156
173
  end
@@ -76,7 +76,7 @@ module PermissionForge
76
76
  return all unless config.tenant_enabled? && tenant_scoping_applied?
77
77
 
78
78
  column = config.resolved_tenant_column
79
- return unscoped if tenant_id.nil? || !column_names.include?(column.to_s)
79
+ return unscoped if tenant_id.nil? || !PermissionForge::SchemaRead.column?(self, column.to_s, ignored: false)
80
80
 
81
81
  unscoped.where(column => [tenant_id, nil])
82
82
  end
@@ -11,7 +11,7 @@ module PermissionForge
11
11
  included do
12
12
  before_create :generate_uuid, if: -> { respond_to?(:uuid) && uuid.blank? }
13
13
 
14
- validates :uuid, uniqueness: true, allow_nil: true
14
+ validates :uuid, uniqueness: { message: ->(*) { I18n.t('permission_forge.validations.taken') } }, allow_nil: true
15
15
  end
16
16
 
17
17
  private
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Concerns
5
+ # A copy (Services::Clonable: `record.dup`) names what its original names.
6
+ # The checks a row gets when it is new or when an attribute changes —
7
+ # an operator or join type this version knows, a mask strategy that is
8
+ # one, an IP list of addresses — would refuse a copy of a row stored
9
+ # before them (or written past the model), and with it the whole copy
10
+ # of the set: a 500 in the admin's Duplicate. The copy keeps its
11
+ # original's stored values instead and behaves exactly like it (the rule
12
+ # matches nothing, the field masks fully, the condition never passes);
13
+ # `rake permission_forge:audit` reports both rows.
14
+ #
15
+ # keeps_copied :operator, :join_type
16
+ # validates :operator, ..., if: -> { !copied_unchanged?(:operator) && (new_record? || ...) }
17
+ #
18
+ # @api private
19
+ module KeepsCopiedValues
20
+ extend ActiveSupport::Concern
21
+
22
+ included do
23
+ class_attribute :pf_copied_attributes, instance_writer: false, default: [].freeze
24
+ end
25
+
26
+ class_methods do
27
+ # @param attributes [Array<Symbol>] the attributes a copy keeps unchecked while it holds the original's value
28
+ # @return [void]
29
+ def keeps_copied(*attributes)
30
+ self.pf_copied_attributes = (pf_copied_attributes + attributes.map(&:to_s)).uniq.freeze
31
+ end
32
+ end
33
+
34
+ def initialize_dup(other)
35
+ super
36
+ @pf_copied = attributes.slice(*pf_copied_attributes).freeze
37
+ end
38
+
39
+ private
40
+
41
+ # Whether this row is a copy and each attribute still holds what the
42
+ # original held: a check-on-change validation is not due for it.
43
+ def copied_unchanged?(*names)
44
+ !@pf_copied.nil? && names.all? { |name| @pf_copied.key?(name.to_s) && @pf_copied[name.to_s] == self[name] }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -36,8 +36,10 @@ module PermissionForge
36
36
  # The module a class's alias readers and writers live in.
37
37
  class AccessorModule < Module; end
38
38
 
39
- # `id_value`, which Rails itself aliases to `id`, stays Rails': `id`
40
- # is the primary key reader, not the reader of a column called id.
39
+ # `id_value`, which Rails itself aliases to `id` (every model up to
40
+ # Rails 8.1; from 8.2 only a model with a composite key, the others
41
+ # get a plain reader), stays Rails': `id` is the primary key reader,
42
+ # not the reader of a column called id.
41
43
  UNMASKED_TARGET = 'id'
42
44
 
43
45
  CREATION = Mutex.new
@@ -61,11 +63,19 @@ module PermissionForge
61
63
  accessors.redefine_method(:"#{name}=", &writer(klass, name, declared))
62
64
  end
63
65
 
66
+ # The unmasked read is the one Rails' own alias reader makes: on
67
+ # Rails 8.2 `_read_attribute!` (it raises MissingAttributeError for
68
+ # an attribute the query did not load; `_read_attribute` takes no
69
+ # block there), before it `_read_attribute` with the block that
70
+ # raises. Which one is decided once, when the accessor is defined.
64
71
  def self.reader(klass, name, declared)
72
+ raising = klass.method_defined?(:_read_attribute!) || klass.private_method_defined?(:_read_attribute!)
65
73
  proc do
66
74
  target = klass.attribute_aliases.fetch(name, declared)
67
75
  if is_a?(MaskedAttributes) && pf_masked_reader?(target)
68
76
  read_attribute(target) { |missing| missing_attribute(missing, caller) }
77
+ elsif raising
78
+ _read_attribute!(target)
69
79
  else
70
80
  _read_attribute(target) { |missing| missing_attribute(missing, caller) }
71
81
  end
@@ -102,14 +102,16 @@ module PermissionForge
102
102
 
103
103
  def pf_warn_virtual_readers_unmasked(error)
104
104
  method = PermissionForge.configuration.extra_fields_method
105
- Rails.logger&.warn("[PermissionForge] SECURITY warning: the virtual readers #{name} declares are NOT " \
106
- "masked on the model: #{name}.#{method} raised #{error.class}: #{error.message}. " \
107
- 'Fix it and restart, or call pf_mask_virtual_readers with explicit names.')
105
+ PermissionForge::Warnings.emit("virtual_readers.unmasked.#{name}",
106
+ "the virtual readers #{name} declares are NOT masked on the model: " \
107
+ "#{name}.#{method} raised #{error.class}: #{error.message}. Fix it and " \
108
+ 'restart, or call pf_mask_virtual_readers with explicit names.',
109
+ severity: :security)
108
110
  end
109
111
 
110
112
  def pf_virtual_reader?(name)
111
113
  return false unless name.match?(READER_NAME)
112
- return false if Array(primary_key).map(&:to_s).include?(name) || columns_hash.key?(name)
114
+ return false if Array(primary_key).map(&:to_s).include?(name) || PermissionForge::SchemaRead.column?(self, name, ignored: false)
113
115
 
114
116
  attribute_names.include?(name) || (!pf_framework_method?(name) && pf_argumentless_reader?(name))
115
117
  end