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
@@ -27,7 +27,10 @@ module PermissionForge
27
27
  #
28
28
  # With no current user every value is returned as stored, unless
29
29
  # config.masked_attributes_nil_user_strategy = :hide, which hides every
30
- # guarded attribute instead (the primary key and the raw paths excepted).
30
+ # guarded attribute instead (the primary key and the raw paths excepted),
31
+ # or :hide_in_requests, which does so inside a web request only
32
+ # (PermissionForge::WebRequest). Under :bypass a value returned as stored
33
+ # inside a web request is recorded (field.unmasked_without_user).
31
34
  #
32
35
  # @example Include in a model
33
36
  # class Customer < ApplicationRecord
@@ -105,8 +108,10 @@ module PermissionForge
105
108
  # (`generated_attribute_methods`) and includes it in the model. Calling
106
109
  # `customer.email` hits that fast path, bypassing `read_attribute`. We
107
110
  # prepend an override module so direct getters (and setters, for the
108
- # write-back guard) route through our logic. Captured at include time —
109
- # column changes after this require a class reload to pick up.
111
+ # write-back guard) route through our logic: one per column the model
112
+ # has now, and one per column Rails finds each time it defines the
113
+ # attribute methods again (define_attribute_methods below: a column a
114
+ # migration added after the include, once the schema is read again).
110
115
  #
111
116
  # A getter raises ActiveModel::MissingAttributeError for a column that
112
117
  # was not loaded (`select(:id)`), as the getter Rails generates does.
@@ -116,16 +121,7 @@ module PermissionForge
116
121
  # the tenant model when computing cache keys). Masking `id` would create
117
122
  # a recursion: `id` → guard → `permissions_hash` → `current_tenant.id`.
118
123
  masked_attribute_module = ColumnAccessors.new
119
- primary_key = self.primary_key&.to_s
120
- columns_hash.each_key do |col_name|
121
- next if col_name.to_s == primary_key
122
-
123
- attr_sym = col_name.to_sym
124
- masked_attribute_module.define_method(attr_sym) { read_attribute(attr_sym) { |name| missing_attribute(name, caller) } }
125
- masked_attribute_module.define_method("#{attr_sym}=") do |value|
126
- next super(value) unless pf_skip_masked_write?(attr_sym, value)
127
- end
128
- end
124
+ PermissionForge::Concerns::MaskedAttributes.define_column_accessors(self, masked_attribute_module)
129
125
  prepend(masked_attribute_module)
130
126
 
131
127
  const_set(:MaskedAttributeReaders, masked_attribute_module)
@@ -139,6 +135,50 @@ module PermissionForge
139
135
  @_pf_masked_attributes_applied = true
140
136
  end
141
137
 
138
+ class_methods do
139
+ # Rails defines a model's attribute methods lazily, and again after
140
+ # the schema is read again (reset_column_information — what
141
+ # PermissionForge::SchemaRead does when the database's columns
142
+ # changed): each time, a column the guarded module lacks gets its
143
+ # guarded getter and setter, so `record.new_column` answers what
144
+ # `record[:new_column]` and `as_json` answer. A subclass asks its
145
+ # parent first (Rails), whose module then has the method: Rails
146
+ # generates none of its own for the subclass.
147
+ def define_attribute_methods
148
+ super.tap { |generated| pf_define_late_column_accessors if generated }
149
+ end
150
+
151
+ private
152
+
153
+ def pf_define_late_column_accessors
154
+ return unless const_defined?(:MaskedAttributeReaders, false)
155
+
156
+ PermissionForge::Concerns::MaskedAttributes.define_column_accessors(self, const_get(:MaskedAttributeReaders, false))
157
+ end
158
+ end
159
+
160
+ # Defines on `accessors` a guarded getter and setter for every column
161
+ # of `klass` it does not have yet (the primary key never: it is used by
162
+ # routing, associations and tenant scoping). The columns are the ones
163
+ # Rails just loaded for the model (`columns_hash`, ignored columns
164
+ # excluded): those it generates methods for.
165
+ #
166
+ # @param klass [Class]
167
+ # @param accessors [ColumnAccessors]
168
+ # @return [void]
169
+ def self.define_column_accessors(klass, accessors)
170
+ primary_key = klass.primary_key&.to_s
171
+ klass.columns_hash.each_key do |col_name|
172
+ next if col_name.to_s == primary_key || accessors.method_defined?(col_name, false)
173
+
174
+ attr_sym = col_name.to_sym
175
+ accessors.define_method(attr_sym) { read_attribute(attr_sym) { |name| missing_attribute(name, caller) } }
176
+ accessors.define_method("#{attr_sym}=") do |value|
177
+ next super(value) unless pf_skip_masked_write?(attr_sym, value)
178
+ end
179
+ end
180
+ end
181
+
142
182
  # Whether MaskedAttributes has already been applied to a class. Used by the
143
183
  # engine's auto-apply to avoid re-prepending the readers module.
144
184
  def self.applied?(klass)
@@ -206,8 +246,7 @@ module PermissionForge
206
246
  masked_attributes_applied: applied_in_chain?(klass),
207
247
  primary_key: klass.primary_key,
208
248
  is_primary_key: klass.primary_key.to_s == attr_name.to_s,
209
- current_user_present: !user.nil?,
210
- masked_attributes_nil_user_strategy: config.masked_attributes_nil_user_strategy, user_model_exempt: config.user_model_class?(klass),
249
+ current_user_present: !user.nil?, **explain_without_user(config, klass),
211
250
  user_responds_to_field_permissions: user.respond_to?(:field_permissions),
212
251
  resource_in_permissions_hash: entries?(user, chain),
213
252
  field_configured: !field.nil?,
@@ -219,6 +258,13 @@ module PermissionForge
219
258
  }
220
259
  end
221
260
 
261
+ # What explain reports about reads with nobody signed in.
262
+ def self.explain_without_user(config, klass)
263
+ { masked_attributes_nil_user_strategy: config.masked_attributes_nil_user_strategy,
264
+ user_model_exempt: config.user_model_class?(klass), in_web_request: PermissionForge::WebRequest.active? }
265
+ end
266
+ private_class_method :explain_without_user
267
+
222
268
  # Whether the user's hash has a resource entry along the chain.
223
269
  def self.entries?(user, chain)
224
270
  user.respond_to?(:permissions_hash) && PermissionForge::ResourceChain.entries(user, chain).any?
@@ -280,12 +326,13 @@ module PermissionForge
280
326
  private_class_method :decide
281
327
 
282
328
  # What the guard does with no user (config.masked_attributes_nil_user_strategy;
283
- # the user model is exempt from :hide).
329
+ # the user model is exempt from :hide and :hide_in_requests).
284
330
  def self.without_user_decision(klass)
285
331
  config = PermissionForge.configuration
286
- return :no_user_bypassed unless config.hide_masked_attributes_without_user?
332
+ return :no_user_hidden if config.hide_masked_attributes_without_user_for?(klass)
333
+ return :no_user_user_model_exempt if config.hide_masked_attributes_without_user_in_requests? && config.user_model_class?(klass)
287
334
 
288
- config.user_model_class?(klass) ? :no_user_user_model_exempt : :no_user_hidden
335
+ :no_user_bypassed
289
336
  end
290
337
  private_class_method :without_user_decision
291
338
 
@@ -304,6 +351,13 @@ module PermissionForge
304
351
  end
305
352
  private_class_method :print_explain_table
306
353
 
354
+ # Forgets the attributes read as hidden with nobody signed in
355
+ # (pf_hidden_without_user_reads): the record holds fresh values.
356
+ def reload(*)
357
+ pf_forget_hidden_without_user_reads
358
+ super
359
+ end
360
+
307
361
  # Reads an attribute through the field permissions of the current user.
308
362
  # @param attr_name [String, Symbol]
309
363
  def read_attribute(attr_name, &)
@@ -315,8 +369,12 @@ module PermissionForge
315
369
  attr_name = name = pf_alias_end(target) if target
316
370
  return raw if pf_read_as_stored?(name)
317
371
 
318
- include_field, value = pf_guard(attr_name, raw)
319
- value = PermissionForge.configuration.hidden_attribute_value unless include_field
372
+ user = pf_guarded_user
373
+ include_field, value = pf_guard(attr_name, raw, user)
374
+ unless include_field
375
+ value = PermissionForge.configuration.hidden_attribute_value
376
+ pf_note_hidden_without_user(name, raw) unless user
377
+ end
320
378
  # A returned value that differs from the raw column value was masked or
321
379
  # hidden; remember the exact sentinel so a write-back of it can be refused.
322
380
  pf_record_masked_read(name, value) unless include_field && value == raw
@@ -325,13 +383,15 @@ module PermissionForge
325
383
 
326
384
  # The user is resolved once for the whole hash. With no user every
327
385
  # value is returned as stored (or hidden, under
328
- # config.masked_attributes_nil_user_strategy = :hide); the decisions
329
- # (bypassed) are still published, as for a getter or as_json, but only
330
- # when someone listens.
386
+ # config.masked_attributes_nil_user_strategy = :hide, and :hide_in_requests
387
+ # inside a web request); the decisions (bypassed) are still published,
388
+ # as for a getter or as_json, but only when someone listens, and each
389
+ # attribute returned as stored inside a web request is recorded
390
+ # (field.unmasked_without_user).
331
391
  def attributes
332
392
  raw = super
333
393
  user = pf_guarded_user
334
- return raw unless user || pf_hide_without_user? || PermissionForge::Audit::FieldAccess::GATE.open?
394
+ return raw unless user || pf_guard_without_user? || PermissionForge::Audit::FieldAccess::GATE.open?
335
395
 
336
396
  PermissionForge::Audit::FieldAccess.read(self, self.class.name, record: self) do
337
397
  pf_guard_attributes_hash(raw, user)
@@ -440,14 +500,25 @@ module PermissionForge
440
500
  name == self.class.primary_key || pf_raw_store_column?(name) || name == pf_validation_read
441
501
  end
442
502
 
443
- # config.masked_attributes_nil_user_strategy = :hide — with no user
444
- # every guarded attribute is hidden (AttributeAccessGuard#without_user),
445
- # except on the user model, which sign-in reads before anyone is
446
- # signed in.
503
+ # config.masked_attributes_nil_user_strategy = :hide (and
504
+ # :hide_in_requests inside a web request) — with no user every guarded
505
+ # attribute is hidden (AttributeAccessGuard#without_user), except on
506
+ # the user model, which sign-in reads before anyone is signed in.
447
507
  def pf_hide_without_user?
448
508
  PermissionForge.configuration.hide_masked_attributes_without_user_for?(self.class)
449
509
  end
450
510
 
511
+ # Whether a read with no user goes through the guard attribute by
512
+ # attribute: it hides them, or records them (inside a web request
513
+ # under :bypass, while there is something left to record).
514
+ def pf_guard_without_user?
515
+ case PermissionForge.configuration.masked_attributes_without_user_for(self.class)
516
+ when :hide then true
517
+ when :record then PermissionForge::Audit::UnmaskedWithoutUser.pending?(self.class)
518
+ else false
519
+ end
520
+ end
521
+
451
522
  # Every guarded read of this record is one field.access read
452
523
  # (Audit::FieldAccess), joined by the reads made inside it. Nobody
453
524
  # listening (the gate is shut): one check, nothing collected.
@@ -491,12 +562,45 @@ module PermissionForge
491
562
  pf_masked_reads[attr_name.to_s] = sentinel
492
563
  end
493
564
 
565
+ # The attributes this instance read as hidden because nobody was signed
566
+ # in (masked_attributes_nil_user_strategy :hide, or :hide_in_requests in a
567
+ # web request) while they held a value. Whatever is written to one of
568
+ # them afterwards was worked out from the hidden value, or replaces a
569
+ # value nobody could see: pf_skip_masked_write? treats it as a
570
+ # write-back. Only something real counts: a value the record holds in
571
+ # memory, stored or assigned (an empty column, or an attribute of a new
572
+ # record nobody filled, hides nothing: a default or a fill-if-blank is
573
+ # written; a value submitted on a new record is kept from a normalizer
574
+ # that reads it). The value is the one just read, not
575
+ # attribute_in_database, which would build the record's mutation
576
+ # tracker on every hidden read. A debugging read (inspect) does not
577
+ # count. Forgotten on reload only: a save does not make the hidden read
578
+ # visible, so a value derived from it before the save is still refused
579
+ # after it.
580
+ def pf_hidden_without_user_reads
581
+ @pf_hidden_without_user_reads ||= Set.new
582
+ end
583
+
584
+ def pf_note_hidden_without_user(attr_name, value)
585
+ return if value.nil? || PermissionForge::ExecutionState[INSPECT_KEY]
586
+
587
+ pf_hidden_without_user_reads << attr_name.to_s
588
+ end
589
+
590
+ def pf_forget_hidden_without_user_reads
591
+ @pf_hidden_without_user_reads = nil
592
+ end
593
+
494
594
  # True when the guard should suppress this write because `value` is a masked/
495
595
  # hidden sentinel being written back over real data. Honors
496
596
  # config.masked_write_protection. Two detections:
497
597
  # 1. Ledger — this instance handed out that exact sentinel earlier (covers
498
598
  # same-instance read-modify-write, e.g. a before_save callback).
499
- # 2. Stateless — `value` equals what the mask would produce for the value
599
+ # 2. Hidden without a user — this instance read the attribute as hidden
600
+ # because nobody was signed in: any value written to it was derived
601
+ # from the hidden one (`self.name = name.to_s.strip` stores "") or
602
+ # replaces a value nobody could see.
603
+ # 3. Stateless — `value` equals what the mask would produce for the value
500
604
  # CURRENTLY persisted in the DB. This survives a request boundary: a form
501
605
  # rendered "********" and submitted back onto a freshly-loaded record has
502
606
  # no ledger entry, but still matches the persisted value's mask.
@@ -504,19 +608,29 @@ module PermissionForge
504
608
  protection = PermissionForge.configuration.masked_write_protection
505
609
  return false if protection == :off
506
610
 
507
- ledger_hit = pf_masked_reads.key?(attr_name.to_s) && pf_masked_reads[attr_name.to_s] == value
508
- return false unless ledger_hit || pf_matches_persisted_sentinel?(attr_name, value)
611
+ detected_by = pf_masked_write_detection(attr_name.to_s, value)
612
+ return false unless detected_by
509
613
 
510
- pf_masked_write_refused(attr_name, protection, ledger_hit)
614
+ pf_masked_write_refused(attr_name, protection, detected_by)
615
+ PermissionForge::Audit::UnmaskedWithoutUser.write_refused(self.class, attr_name, protection) if detected_by == 'hidden_without_user'
511
616
  raise MaskedWriteError, attr_name if protection == :raise
512
617
 
513
618
  true # :skip — preserve the stored value by not calling super
514
619
  end
515
620
 
621
+ # How a write was recognised as a write-back (write.masked_skipped
622
+ # `detected_by`), nil for a write that is not one.
623
+ def pf_masked_write_detection(name, value)
624
+ return 'ledger' if pf_masked_reads.key?(name) && pf_masked_reads[name] == value
625
+ return 'hidden_without_user' if pf_hidden_without_user_reads.include?(name)
626
+
627
+ 'persisted_value' if pf_matches_persisted_sentinel?(name, value)
628
+ end
629
+
516
630
  # write.masked_skipped: which attribute, how it was refused (:skip
517
631
  # keeps the stored value, :raise raised) and how the sentinel was
518
632
  # recognised. Never the value.
519
- def pf_masked_write_refused(attr_name, protection, ledger_hit)
633
+ def pf_masked_write_refused(attr_name, protection, detected_by)
520
634
  event = 'write.masked_skipped'
521
635
  return unless PermissionForge::Audit.instrument?(event)
522
636
 
@@ -524,7 +638,7 @@ module PermissionForge
524
638
  PermissionForge::Audit.instrument(event) do
525
639
  { record: self, subject_user_id: PermissionForge::Audit::Runtime.user_id(user),
526
640
  data: { 'model' => self.class.name, 'attribute' => attr_name.to_s, 'protection' => protection.to_s,
527
- 'detected_by' => ledger_hit ? 'ledger' : 'persisted_value' },
641
+ 'detected_by' => detected_by },
528
642
  objects: { user: user, record: self } }
529
643
  end
530
644
  end
@@ -562,6 +676,8 @@ module PermissionForge
562
676
 
563
677
  include_field, masked = pf_guard(key, value, user)
564
678
  next [key, masked] if include_field
679
+
680
+ pf_note_hidden_without_user(key, value) unless user
565
681
  next if omit_hidden
566
682
 
567
683
  [key, hidden_value]
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Concerns
5
+ # The one definition of what a blank label is stored as, for every model
6
+ # with a `label` column: a field row, an action row and a Resource. A
7
+ # label that is nil, empty or only whitespace is "no label", and so is
8
+ # the install migration's column default on a new row ('Untitled Field');
9
+ # such a label is stored as #generated_label — on create and on update,
10
+ # in code, through the JSON API and in the admin alike.
11
+ #
12
+ # A field or action row is labelled with its name (what the gem writes
13
+ # for a row it generates, and what PermissionForge::GeneratedText reads
14
+ # as no label); the field's column is NOT NULL, so nil cannot stand for
15
+ # "no label" there. A Resource stores nil (its label column is nullable
16
+ # and the admin names a model from the host's translation).
17
+ #
18
+ # A rename that leaves a label the gem would generate for the old name
19
+ # (the name or its humanized form: GeneratedText.label?) relabels the row
20
+ # with its new name: the old name would read as a label an admin wrote.
21
+ #
22
+ # Checked on create, when the label changes and on a rename only: a row
23
+ # stored with a blank label before keeps it through a save of its other
24
+ # attributes (no label change in that save's audit event).
25
+ #
26
+ # Field test round 8 (D#25): through the API a field label `null` was a
27
+ # 422 "can't be blank" while `""` was stored, and an action stored both
28
+ # as sent — the admin stored a blanked label as the name.
29
+ #
30
+ # @api private
31
+ module NormalizesLabel
32
+ extend ActiveSupport::Concern
33
+
34
+ included do
35
+ before_validation :normalize_label
36
+ end
37
+
38
+ # What a label `text` written to this row is stored as: a blank one
39
+ # (nil, empty, white space) as no label — the row's name (a
40
+ # Resource: nil) — anything else as written. The admin's review asks
41
+ # it for the value it shows.
42
+ #
43
+ # @param text [String, nil]
44
+ # @return [String, nil]
45
+ def label_to_store(text)
46
+ text.to_s.strip.empty? ? generated_label : text
47
+ end
48
+
49
+ private
50
+
51
+ # The label "no label" is stored as: the row's name.
52
+ #
53
+ # @return [String, nil]
54
+ def generated_label
55
+ name.to_s
56
+ end
57
+
58
+ def normalize_label
59
+ return unless has_attribute?(:label) && label_check_due?
60
+
61
+ self.label = unwritten_label? ? generated_label : label_to_store(label)
62
+ end
63
+
64
+ def label_check_due?
65
+ new_record? || will_save_change_to_label? || will_save_change_to_name?
66
+ end
67
+
68
+ # On a new row the column default; on a rename, a label generated for
69
+ # the old name that this save leaves as it is.
70
+ def unwritten_label?
71
+ return label == self.class.column_defaults['label'] if new_record?
72
+
73
+ will_save_change_to_name? && !will_save_change_to_label? && !generated_label.nil? &&
74
+ PermissionForge::GeneratedText.label?(label, name_in_database)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -389,9 +389,9 @@ module PermissionForge
389
389
  # @return [Array<PermissionForge::PermissionSet>]
390
390
  def pf_active_non_expired_permission_sets
391
391
  active_psu_ids = permission_set_users.active_assignments.select(:permission_set_id)
392
- # Deterministic order so "first mask_strategy wins" in FieldPermissionMerger
393
- # is stable across requests and DB adapters (was DB-insertion-order dependent).
394
- PermissionForge::PermissionSet.active.where(id: active_psu_ids).order(:created_at, :id).to_a
392
+ # Deterministic order (PermissionSet.in_hash_order) so "first mask_strategy wins" in
393
+ # FieldPermissionMerger is stable across requests and DB adapters.
394
+ PermissionForge::PermissionSet.active.where(id: active_psu_ids).in_hash_order.to_a
395
395
  end
396
396
 
397
397
  # The 0.13 name of pf_active_non_expired_permission_sets, kept for host
@@ -8,7 +8,11 @@ module PermissionForge
8
8
  # `find_by!` apply the user's cached permission set rules before returning a
9
9
  # record, and `permission_scoped` returns a rule-constrained relation you can
10
10
  # chain (`.where`, `.first`, `.find`, `.count`, …). If view_all or modify_all
11
- # is enabled for the resource, rules are bypassed entirely.
11
+ # is enabled for the resource, rules are bypassed entirely. A signed-in
12
+ # user the model's Resource chain grants no read_access gets the rules
13
+ # alone under config.no_read_access_strategy :rules_only (the default:
14
+ # no rules, every row) and no rows under :deny (PermissionForge::
15
+ # RecordReadAccess, asked before the rules).
12
16
  #
13
17
  # Level 1 — ENFORCE (config.auto_query_scoping = :filter_only): a
14
18
  # default_scope applies the current user's FILTER-type rules to every
@@ -87,14 +91,17 @@ module PermissionForge
87
91
  class << self
88
92
  # `find_by` constrained by the current user's row-level rules.
89
93
  # @return [ActiveRecord::Base, nil] nil for a missing record
90
- # @raise [NotAuthorizedError] when a rule denies an existing record
94
+ # @raise [NotAuthorizedError] when a rule (or, under
95
+ # no_read_access_strategy :deny, the missing read access) denies an
96
+ # existing record
91
97
  def find_by(*args)
92
98
  find_by_with_permission(*args)
93
99
  end
94
100
 
95
101
  # `find_by!` constrained by the current user's row-level rules.
96
102
  # @raise [ActiveRecord::RecordNotFound] for a missing record
97
- # @raise [NotAuthorizedError] when a rule denies an existing record
103
+ # @raise [NotAuthorizedError] when a rule (or the missing read access)
104
+ # denies an existing record
98
105
  def find_by!(*args)
99
106
  find_by_with_permission(*args) || raise(ActiveRecord::RecordNotFound)
100
107
  end
@@ -102,13 +109,16 @@ module PermissionForge
102
109
  end
103
110
 
104
111
  # Raised when a row-level rule denies an existing record (find_by /
105
- # find_by!) or there is no user under nil_user_strategy :raise.
112
+ # find_by!), when the user has no read access to the model under
113
+ # no_read_access_strategy :deny, or there is no user under
114
+ # nil_user_strategy :raise.
106
115
  #
107
116
  # `user` is the user the rules were applied for, `record_id` the id of
108
117
  # the record that was denied. `record` is nil when the gem raises: the
109
118
  # row the user may not see is not loaded to be handed out with the
110
- # error. All three are optional keywords; `NotAuthorizedError.new(details)`
111
- # and `raise NotAuthorizedError, details` work as before.
119
+ # error. They and `failure_messages` are optional keywords;
120
+ # `NotAuthorizedError.new(details)` and `raise NotAuthorizedError,
121
+ # details` work as before.
112
122
  #
113
123
  # `details` keeps whatever was raised with (the gem raises the Array of
114
124
  # rule failure messages; a Hash with :message for a missing user).
@@ -116,14 +126,43 @@ module PermissionForge
116
126
  # error reporter and `rescue_from` handler expects: the failure
117
127
  # messages joined, the :message of a Hash, or the class name.
118
128
  #
129
+ # `failure_messages` is what the denying rules say on their own (each
130
+ # rule's `failure_message`, as an admin wrote it, or the gem's default
131
+ # sentence for a rule without one), for a friendly 403 without parsing
132
+ # `message`.
133
+ #
134
+ # Unless the host rescues it, Rails answers this error with 403
135
+ # Forbidden (the engine registers it in
136
+ # `config.action_dispatch.rescue_responses`).
137
+ #
138
+ # @example A friendly 403
139
+ # rescue_from PermissionForge::Concerns::PermissionScopedFindable::NotAuthorizedError do |error|
140
+ # render json: { error: error.failure_messages.first || 'Forbidden' }, status: :forbidden
141
+ # end
142
+ #
119
143
  # @api public
120
144
  class NotAuthorizedError < StandardError
121
145
  # Why the lookup was refused.
122
146
  # @return [Hash, nil]
123
147
  attr_reader :details, :user, :record, :record_id
124
148
 
149
+ # The `failure_message` of each rule that denied the record, in rule
150
+ # order, without repeats (two Permission Sets may carry the same
151
+ # rule); a rule left without one says the gem's own sentence in the
152
+ # current locale (`permission_forge.denials.rule_default`:
153
+ # `Services::PermissionRuleApplier.failure_message_for`). A user
154
+ # without read access under no_read_access_strategy :deny gets one
155
+ # sentence, `permission_forge.denials.no_read_access`. Empty when
156
+ # no rule denied: nobody signed in under `nil_user_strategy = :raise`,
157
+ # or an error the host raised without `failure_messages:`. Rule
158
+ # messages are written by admins: decide whether they belong in a
159
+ # response.
160
+ #
161
+ # @return [Array<String>] frozen
162
+ attr_reader :failure_messages
163
+
125
164
  NO_DETAILS = Object.new.freeze
126
- READERS = %i[user record record_id].freeze
165
+ READERS = %i[user record record_id failure_messages].freeze
127
166
  private_constant :NO_DETAILS, :READERS
128
167
 
129
168
  # The String a details value reads as.
@@ -136,9 +175,9 @@ module PermissionForge
136
175
  end
137
176
  end
138
177
 
139
- # The :message of a Hash, else the Hash without the user, record and
140
- # record_id keywords (a user's inspect must not land in an error
141
- # tracker), else nothing.
178
+ # The :message of a Hash, else the Hash without the user, record,
179
+ # record_id and failure_messages keywords (a user's inspect must not
180
+ # land in an error tracker), else nothing.
142
181
  def self.hash_text(details)
143
182
  text = text_for(details[:message] || details['message'])
144
183
  return text if text
@@ -148,12 +187,22 @@ module PermissionForge
148
187
  end
149
188
  private_class_method :hash_text
150
189
 
151
- # Keywords other than user:, record: and record_id: are details, as
152
- # they always were (`new(message: 'x')`); without a positional
190
+ # The failure_messages: keyword as a frozen Array of distinct,
191
+ # non-blank Strings.
192
+ #
193
+ # @api private
194
+ # @return [Array<String>]
195
+ def self.message_list(value)
196
+ Array(value).filter_map { |text| text.to_s.presence }.uniq.freeze
197
+ end
198
+
199
+ # Keywords other than user:, record:, record_id: and failure_messages:
200
+ # are details, as they always were (`new(message: 'x')`); without a positional
153
201
  # argument every keyword given stays in details too, so a host's
154
202
  # `new(record: r, message: 'x')` reads as before.
155
203
  def initialize(details = NO_DETAILS, **keywords)
156
204
  @user, @record, @record_id = keywords.values_at(:user, :record, :record_id)
205
+ @failure_messages = self.class.message_list(keywords[:failure_messages])
157
206
  if details.equal?(NO_DETAILS)
158
207
  details = keywords
159
208
  elsif !(extra = keywords.except(*READERS)).empty?
@@ -179,22 +228,15 @@ module PermissionForge
179
228
 
180
229
  next handle_nil_user(*args, host_suspended: host_suspended) unless current_user
181
230
 
182
- rules = pf_applicable_rules(current_user, 'find_by')
183
- next tenant_safe_find_by(*args) unless rules
184
-
185
- query, failure_messages = PermissionForge::Services::PermissionRuleApplier.call(
186
- model_class: self, rules: rules, args: args, current_user: current_user
187
- )
188
-
189
- next deny_or_missing(failure_messages, current_user, *args) if failure_messages.any?
190
-
191
- query.where(*args).take
231
+ pf_find_by_as(current_user, *args)
192
232
  end
193
233
  end
194
234
 
195
235
  # Returns a relation constrained by the current user's row-level rules, so
196
236
  # any further chaining (`.where`, `.first`, `.find`, `.count`) stays scoped.
197
237
  # Honors nil_user_strategy: :bypass -> all, :deny -> none, :raise -> raises.
238
+ # A signed-in user the model's Resource chain grants no read_access gets
239
+ # none under no_read_access_strategy :deny (RecordReadAccess).
198
240
  # view_all/modify_all and the no-rules case return the unconstrained relation.
199
241
  #
200
242
  # Built with auto-scoping suspended (rules are applied here explicitly —
@@ -213,6 +255,22 @@ module PermissionForge
213
255
 
214
256
  private
215
257
 
258
+ # find_by for a signed-in user: the read access the model needs
259
+ # (RecordReadAccess), then the rules.
260
+ def pf_find_by_as(current_user, *args)
261
+ return deny_or_missing(pf_no_read_access_denial, current_user, *args) if pf_no_read_access?(current_user)
262
+
263
+ rules = pf_applicable_rules(current_user, 'find_by')
264
+ return tenant_safe_find_by(*args) unless rules
265
+
266
+ query, failure_messages, denying_rules = PermissionForge::Services::PermissionRuleApplier.call(
267
+ model_class: self, rules: rules, args: args, current_user: current_user
268
+ )
269
+ return deny_or_missing(pf_rule_denial(failure_messages, denying_rules), current_user, *args) if failure_messages.any?
270
+
271
+ query.where(*args).take
272
+ end
273
+
216
274
  # The gem's own suspension of Level 1 (the queries find_by and
217
275
  # permission_scoped make while they apply the rules themselves): not
218
276
  # reported as a scope_suspended bypass, which is about the host's
@@ -224,6 +282,7 @@ module PermissionForge
224
282
  def build_permission_scope(host_suspended)
225
283
  current_user = PermissionForge.current_user
226
284
  return permission_scoped_without_user(host_suspended) unless current_user
285
+ return pf_no_read_access_scope(current_user) if pf_no_read_access?(current_user)
227
286
 
228
287
  rules = pf_applicable_rules(current_user, 'permission_scoped')
229
288
  return all unless rules
@@ -233,20 +292,53 @@ module PermissionForge
233
292
  )
234
293
  end
235
294
 
236
- # Distinguishes "denied by a rule" from "no such record". find_by must honor
237
- # its nil contract for a genuinely missing record (matching stock Rails) and
238
- # only raise NotAuthorizedError when the record exists but rules deny it.
239
- def deny_or_missing(failure_messages, current_user, *args)
295
+ # Distinguishes "denied" from "no such record". find_by must honor its
296
+ # nil contract for a genuinely missing record (matching stock Rails)
297
+ # and only raise NotAuthorizedError when the record exists but the
298
+ # rules (or the missing read access) deny it.
299
+ #
300
+ # `denial` is [reason, messages, failure_messages]: pf_rule_denial or
301
+ # pf_no_read_access_denial.
302
+ def deny_or_missing(denial, current_user, *args)
240
303
  relation = tenant_safe_relation.where(*args)
241
304
  return nil unless relation.exists?
242
305
 
306
+ reason, messages, failure_messages = denial
243
307
  record_id = pf_denied_record_id(relation)
244
308
  PermissionForge::Audit::Runtime.denied(
245
- PermissionForge::Audit::Runtime::DENIED, reason: :rule, model: self, via: 'find_by',
309
+ PermissionForge::Audit::Runtime::DENIED, reason: reason, model: self, via: 'find_by',
246
310
  user: current_user, record_id: record_id,
247
- data: { 'messages' => failure_messages }
311
+ data: { 'messages' => messages }
312
+ )
313
+ raise NotAuthorizedError.new(messages, user: current_user, record_id: record_id, failure_messages: failure_messages)
314
+ end
315
+
316
+ # What the applier answered: the failure messages and what each
317
+ # denying rule says on its own.
318
+ def pf_rule_denial(messages, denying_rules)
319
+ [:rule, messages, denying_rules.map { |rule| PermissionForge::Services::PermissionRuleApplier.failure_message_for(rule) }]
320
+ end
321
+
322
+ # The user's entries along the chain do not grant read_access, under
323
+ # no_read_access_strategy :deny: one message, from the locale.
324
+ def pf_no_read_access_denial
325
+ message = PermissionForge::RecordReadAccess.failure_message(self)
326
+ [PermissionForge::RecordReadAccess::REASON, [message], [message]]
327
+ end
328
+
329
+ # RecordReadAccess, the one definition.
330
+ def pf_no_read_access?(current_user)
331
+ PermissionForge::RecordReadAccess.denied?(current_user, self)
332
+ end
333
+
334
+ # permission_scoped for a user denied by RecordReadAccess: no rows,
335
+ # published as access.denied (no_read_access).
336
+ def pf_no_read_access_scope(current_user)
337
+ PermissionForge::Audit::Runtime.denied(
338
+ PermissionForge::Audit::Runtime::DENIED,
339
+ reason: PermissionForge::RecordReadAccess::REASON, model: self, via: 'permission_scoped', user: current_user
248
340
  )
249
- raise NotAuthorizedError.new(failure_messages, user: current_user, record_id: record_id)
341
+ none
250
342
  end
251
343
 
252
344
  # The id of the row the rules denied, for access.denied and the error:
@@ -57,7 +57,7 @@ module PermissionForge
57
57
  # With no tenant ambient the associations above reached every row.
58
58
  def pf_destroy_permission_rows_in_other_tenants
59
59
  config = PermissionForge.configuration
60
- return unless config.tenant_enabled? && defined?(::ActsAsTenant) && ::ActsAsTenant.current_tenant
60
+ return unless PermissionForge.ambient_tenant
61
61
 
62
62
  [PermissionForge::PermissionSetUser, PermissionForge::PermissionSetGroupUser].each do |model|
63
63
  model.unscoped.where(user_id: id).find_each do |row|