permission_forge 0.14.1

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 (406) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +1138 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +356 -0
  5. data/app/controllers/concerns/permission_forge/acting_admin.rb +17 -0
  6. data/app/controllers/concerns/permission_forge/api/crud_actions.rb +84 -0
  7. data/app/controllers/concerns/permission_forge/api/nested_resource_lookup.rb +18 -0
  8. data/app/controllers/concerns/permission_forge/api/pagination.rb +50 -0
  9. data/app/controllers/concerns/permission_forge/api/response_renderer.rb +65 -0
  10. data/app/controllers/concerns/permission_forge/api/serializer_resolver.rb +30 -0
  11. data/app/controllers/concerns/permission_forge/api_authentication.rb +40 -0
  12. data/app/controllers/concerns/permission_forge/assignment_authorization.rb +97 -0
  13. data/app/controllers/concerns/permission_forge/audit_log_gate.rb +55 -0
  14. data/app/controllers/concerns/permission_forge/basic_authentication.rb +77 -0
  15. data/app/controllers/concerns/permission_forge/cache_confirmation.rb +32 -0
  16. data/app/controllers/concerns/permission_forge/forgery_protection.rb +29 -0
  17. data/app/controllers/concerns/permission_forge/html_format.rb +40 -0
  18. data/app/controllers/concerns/permission_forge/param_shapes.rb +87 -0
  19. data/app/controllers/concerns/permission_forge/tenant_required.rb +42 -0
  20. data/app/controllers/permission_forge/api/audit_events_controller.rb +33 -0
  21. data/app/controllers/permission_forge/api/base_controller.rb +220 -0
  22. data/app/controllers/permission_forge/api/dashboard_controller.rb +30 -0
  23. data/app/controllers/permission_forge/api/permission_set_group_users_controller.rb +51 -0
  24. data/app/controllers/permission_forge/api/permission_set_groups_controller.rb +71 -0
  25. data/app/controllers/permission_forge/api/permission_set_rules_controller.rb +37 -0
  26. data/app/controllers/permission_forge/api/permission_set_users_controller.rb +105 -0
  27. data/app/controllers/permission_forge/api/permission_sets_controller.rb +87 -0
  28. data/app/controllers/permission_forge/api/resource_actions_controller.rb +58 -0
  29. data/app/controllers/permission_forge/api/resource_fields_controller.rb +30 -0
  30. data/app/controllers/permission_forge/api/resources_controller.rb +56 -0
  31. data/app/controllers/permission_forge/application_controller.rb +198 -0
  32. data/app/controllers/permission_forge/assets_controller.rb +97 -0
  33. data/app/controllers/permission_forge/assignments_controller.rb +266 -0
  34. data/app/controllers/permission_forge/audit_events_controller.rb +94 -0
  35. data/app/controllers/permission_forge/bulk_assignments_controller.rb +108 -0
  36. data/app/controllers/permission_forge/field_lookups_controller.rb +38 -0
  37. data/app/controllers/permission_forge/home_controller.rb +24 -0
  38. data/app/controllers/permission_forge/maintenance_controller.rb +13 -0
  39. data/app/controllers/permission_forge/people_controller.rb +190 -0
  40. data/app/controllers/permission_forge/permission_set_editor.rb +71 -0
  41. data/app/controllers/permission_forge/permission_set_group_users_controller.rb +57 -0
  42. data/app/controllers/permission_forge/permission_set_groups_controller.rb +111 -0
  43. data/app/controllers/permission_forge/permission_set_rules_controller.rb +97 -0
  44. data/app/controllers/permission_forge/permission_sets_controller.rb +227 -0
  45. data/app/controllers/permission_forge/preferences_controller.rb +26 -0
  46. data/app/controllers/permission_forge/resource_actions_controller.rb +106 -0
  47. data/app/controllers/permission_forge/resource_fields_controller.rb +129 -0
  48. data/app/controllers/permission_forge/resources_controller.rb +189 -0
  49. data/app/controllers/permission_forge/settings_controller.rb +33 -0
  50. data/app/frontend/permission_forge/brand/apple-touch-icon.png +0 -0
  51. data/app/frontend/permission_forge/brand/chevron.svg +1 -0
  52. data/app/frontend/permission_forge/brand/favicon.ico +0 -0
  53. data/app/frontend/permission_forge/brand/favicon.svg +1 -0
  54. data/app/frontend/permission_forge/brand/logo-horizontal-dark.svg +1 -0
  55. data/app/frontend/permission_forge/brand/logo-horizontal-light.svg +1 -0
  56. data/app/frontend/permission_forge/brand/mark-small.svg +1 -0
  57. data/app/frontend/permission_forge/brand/mark.svg +1 -0
  58. data/app/frontend/permission_forge/pf_admin.css +766 -0
  59. data/app/frontend/permission_forge/pf_admin.js +482 -0
  60. data/app/helpers/permission_forge/application_helper.rb +167 -0
  61. data/app/helpers/permission_forge/audit_helper.rb +230 -0
  62. data/app/helpers/permission_forge/audit_sentences_helper.rb +84 -0
  63. data/app/helpers/permission_forge/badges_helper.rb +134 -0
  64. data/app/helpers/permission_forge/changes_helper.rb +117 -0
  65. data/app/helpers/permission_forge/people_helper.rb +118 -0
  66. data/app/helpers/permission_forge/sentences_helper.rb +175 -0
  67. data/app/helpers/permission_forge/settings_helper.rb +152 -0
  68. data/app/helpers/permission_forge/ui_helper.rb +184 -0
  69. data/app/models/permission_forge/audit_event.rb +159 -0
  70. data/app/models/permission_forge/permission_set.rb +100 -0
  71. data/app/models/permission_forge/permission_set_group.rb +83 -0
  72. data/app/models/permission_forge/permission_set_group_user.rb +43 -0
  73. data/app/models/permission_forge/permission_set_rule.rb +99 -0
  74. data/app/models/permission_forge/permission_set_user.rb +158 -0
  75. data/app/models/permission_forge/resource.rb +100 -0
  76. data/app/models/permission_forge/resource_action.rb +58 -0
  77. data/app/models/permission_forge/resource_field.rb +69 -0
  78. data/app/views/layouts/permission_forge/application.html.erb +30 -0
  79. data/app/views/layouts/permission_forge/fragment.html.erb +2 -0
  80. data/app/views/permission_forge/assignments/_person_options.html.erb +26 -0
  81. data/app/views/permission_forge/assignments/confirm_destroy.html.erb +24 -0
  82. data/app/views/permission_forge/assignments/new.html.erb +99 -0
  83. data/app/views/permission_forge/audit_events/index.html.erb +161 -0
  84. data/app/views/permission_forge/audit_events/show.html.erb +146 -0
  85. data/app/views/permission_forge/bulk_assignments/confirm_revoke.html.erb +21 -0
  86. data/app/views/permission_forge/bulk_assignments/new.html.erb +69 -0
  87. data/app/views/permission_forge/field_lookups/show.html.erb +32 -0
  88. data/app/views/permission_forge/home/_audit_card.html.erb +19 -0
  89. data/app/views/permission_forge/home/_ending_card.html.erb +32 -0
  90. data/app/views/permission_forge/home/_expired_card.html.erb +34 -0
  91. data/app/views/permission_forge/home/_privacy_card.html.erb +67 -0
  92. data/app/views/permission_forge/home/_rules_card.html.erb +23 -0
  93. data/app/views/permission_forge/home/_stale_card.html.erb +39 -0
  94. data/app/views/permission_forge/home/_tidy_card.html.erb +18 -0
  95. data/app/views/permission_forge/home/index.html.erb +69 -0
  96. data/app/views/permission_forge/people/_access_tab.html.erb +58 -0
  97. data/app/views/permission_forge/people/_audit_tab.html.erb +93 -0
  98. data/app/views/permission_forge/people/_effective_field_source.html.erb +11 -0
  99. data/app/views/permission_forge/people/_effective_tab.html.erb +118 -0
  100. data/app/views/permission_forge/people/_effective_why.html.erb +48 -0
  101. data/app/views/permission_forge/people/_why_item.html.erb +19 -0
  102. data/app/views/permission_forge/people/confirm_remove_all.html.erb +27 -0
  103. data/app/views/permission_forge/people/consent_preview.html.erb +39 -0
  104. data/app/views/permission_forge/people/erasure_preview.html.erb +28 -0
  105. data/app/views/permission_forge/people/index.html.erb +78 -0
  106. data/app/views/permission_forge/people/show.html.erb +54 -0
  107. data/app/views/permission_forge/permission_set_groups/confirm_destroy.html.erb +12 -0
  108. data/app/views/permission_forge/permission_set_groups/index.html.erb +55 -0
  109. data/app/views/permission_forge/permission_set_groups/show.html.erb +100 -0
  110. data/app/views/permission_forge/permission_set_rules/_builder.html.erb +145 -0
  111. data/app/views/permission_forge/permission_set_rules/_value_input.html.erb +42 -0
  112. data/app/views/permission_forge/permission_set_rules/confirm_destroy.html.erb +13 -0
  113. data/app/views/permission_forge/permission_set_rules/edit.html.erb +3 -0
  114. data/app/views/permission_forge/permission_set_rules/new.html.erb +3 -0
  115. data/app/views/permission_forge/permission_sets/_assignments_tab.html.erb +74 -0
  116. data/app/views/permission_forge/permission_sets/_audit_tab.html.erb +80 -0
  117. data/app/views/permission_forge/permission_sets/_conditions_tab.html.erb +83 -0
  118. data/app/views/permission_forge/permission_sets/_header.html.erb +36 -0
  119. data/app/views/permission_forge/permission_sets/_records_summary.html.erb +16 -0
  120. data/app/views/permission_forge/permission_sets/_resource_summary.html.erb +11 -0
  121. data/app/views/permission_forge/permission_sets/_resources_tab.html.erb +49 -0
  122. data/app/views/permission_forge/permission_sets/confirm_destroy.html.erb +23 -0
  123. data/app/views/permission_forge/permission_sets/confirm_switch_mode.html.erb +15 -0
  124. data/app/views/permission_forge/permission_sets/index.html.erb +83 -0
  125. data/app/views/permission_forge/permission_sets/new.html.erb +63 -0
  126. data/app/views/permission_forge/permission_sets/review.html.erb +26 -0
  127. data/app/views/permission_forge/permission_sets/show.html.erb +50 -0
  128. data/app/views/permission_forge/resource_actions/_across_sets.html.erb +24 -0
  129. data/app/views/permission_forge/resource_actions/_add_select.html.erb +12 -0
  130. data/app/views/permission_forge/resource_actions/_effect.html.erb +25 -0
  131. data/app/views/permission_forge/resource_actions/_endpoints.html.erb +16 -0
  132. data/app/views/permission_forge/resource_actions/_form.html.erb +44 -0
  133. data/app/views/permission_forge/resource_actions/_managed_note.html.erb +11 -0
  134. data/app/views/permission_forge/resource_actions/confirm_destroy.html.erb +13 -0
  135. data/app/views/permission_forge/resource_actions/new.html.erb +31 -0
  136. data/app/views/permission_forge/resource_actions/show.html.erb +39 -0
  137. data/app/views/permission_forge/resource_fields/_explain.html.erb +24 -0
  138. data/app/views/permission_forge/resource_fields/show.html.erb +186 -0
  139. data/app/views/permission_forge/resources/_actions_tab.html.erb +88 -0
  140. data/app/views/permission_forge/resources/_fields_tab.html.erb +89 -0
  141. data/app/views/permission_forge/resources/_rules_tab.html.erb +55 -0
  142. data/app/views/permission_forge/resources/_sync.html.erb +57 -0
  143. data/app/views/permission_forge/resources/confirm_destroy.html.erb +17 -0
  144. data/app/views/permission_forge/resources/new.html.erb +57 -0
  145. data/app/views/permission_forge/resources/show.html.erb +117 -0
  146. data/app/views/permission_forge/settings/_ladder.html.erb +15 -0
  147. data/app/views/permission_forge/settings/_row.html.erb +38 -0
  148. data/app/views/permission_forge/settings/show.html.erb +67 -0
  149. data/app/views/permission_forge/shared/_across_sets.html.erb +31 -0
  150. data/app/views/permission_forge/shared/_audit_events.html.erb +36 -0
  151. data/app/views/permission_forge/shared/_audit_footer.html.erb +16 -0
  152. data/app/views/permission_forge/shared/_audit_off_note.html.erb +15 -0
  153. data/app/views/permission_forge/shared/_audit_pagination.html.erb +19 -0
  154. data/app/views/permission_forge/shared/_audit_refused_note.html.erb +7 -0
  155. data/app/views/permission_forge/shared/_audit_timeline.html.erb +29 -0
  156. data/app/views/permission_forge/shared/_crud_cell.html.erb +18 -0
  157. data/app/views/permission_forge/shared/_dialog_frame.html.erb +11 -0
  158. data/app/views/permission_forge/shared/_expiry_picker.html.erb +27 -0
  159. data/app/views/permission_forge/shared/_head_extra.html.erb +3 -0
  160. data/app/views/permission_forge/shared/_help_banner.html.erb +10 -0
  161. data/app/views/permission_forge/shared/_level_control.html.erb +18 -0
  162. data/app/views/permission_forge/shared/_mode_banners.html.erb +22 -0
  163. data/app/views/permission_forge/shared/_nav.html.erb +12 -0
  164. data/app/views/permission_forge/shared/_preferences.html.erb +24 -0
  165. data/app/views/permission_forge/shared/_reason_field.html.erb +20 -0
  166. data/app/views/permission_forge/shared/_savebar.html.erb +13 -0
  167. data/app/views/permission_forge/shared/_sheet_frame.html.erb +22 -0
  168. data/app/views/permission_forge/shared/_sidebar.html.erb +18 -0
  169. data/app/views/permission_forge/shared/_tech_details.html.erb +20 -0
  170. data/app/views/permission_forge/shared/_toasts.html.erb +27 -0
  171. data/app/views/permission_forge/shared/_topbar.html.erb +15 -0
  172. data/app/views/permission_forge/shared/_user_select.html.erb +16 -0
  173. data/app/views/permission_forge/shared/audit_refused.html.erb +7 -0
  174. data/config/locales/permission_forge.en.yml +2115 -0
  175. data/config/locales/permission_forge.mk.yml +2166 -0
  176. data/config/locales/permission_forge.sq.yml +2164 -0
  177. data/config/routes.rb +143 -0
  178. data/lib/generators/permission_forge/install/install_generator.rb +105 -0
  179. data/lib/generators/permission_forge/install/templates/initializer.rb.tt +148 -0
  180. data/lib/generators/permission_forge/install/templates/migration.rb.tt +198 -0
  181. data/lib/generators/permission_forge/rls/rls_generator.rb +57 -0
  182. data/lib/generators/permission_forge/rls/templates/enable_permission_forge_rls.rb.tt +15 -0
  183. data/lib/generators/permission_forge/upgrade/templates/add_admin_ui_columns.rb.tt +8 -0
  184. data/lib/generators/permission_forge/upgrade/templates/add_audit_events.rb.tt +98 -0
  185. data/lib/generators/permission_forge/upgrade/templates/add_resource_fields_unique_index.rb.tt +69 -0
  186. data/lib/generators/permission_forge/upgrade/upgrade_generator.rb +66 -0
  187. data/lib/permission_forge/admin/action_across_sets.rb +76 -0
  188. data/lib/permission_forge/admin/action_rows.rb +156 -0
  189. data/lib/permission_forge/admin/attention_items.rb +207 -0
  190. data/lib/permission_forge/admin/audit_event_detail.rb +75 -0
  191. data/lib/permission_forge/admin/audit_log.rb +193 -0
  192. data/lib/permission_forge/admin/audit_log_days.rb +41 -0
  193. data/lib/permission_forge/admin/audit_lookups.rb +105 -0
  194. data/lib/permission_forge/admin/audit_name_history.rb +85 -0
  195. data/lib/permission_forge/admin/audit_trail.rb +169 -0
  196. data/lib/permission_forge/admin/bulk_people_matcher.rb +109 -0
  197. data/lib/permission_forge/admin/change_set.rb +123 -0
  198. data/lib/permission_forge/admin/change_set_parts/actions.rb +27 -0
  199. data/lib/permission_forge/admin/change_set_parts/base.rb +58 -0
  200. data/lib/permission_forge/admin/change_set_parts/context.rb +50 -0
  201. data/lib/permission_forge/admin/change_set_parts/field_edit.rb +67 -0
  202. data/lib/permission_forge/admin/change_set_parts/field_levels.rb +71 -0
  203. data/lib/permission_forge/admin/change_set_parts/new_actions.rb +91 -0
  204. data/lib/permission_forge/admin/change_set_parts/new_fields.rb +63 -0
  205. data/lib/permission_forge/admin/change_set_parts/resource_edit.rb +55 -0
  206. data/lib/permission_forge/admin/change_set_parts/resource_flags.rb +19 -0
  207. data/lib/permission_forge/admin/change_set_parts/set_attrs.rb +45 -0
  208. data/lib/permission_forge/admin/change_set_parts/values.rb +20 -0
  209. data/lib/permission_forge/admin/column_fields.rb +48 -0
  210. data/lib/permission_forge/admin/conditions_form.rb +78 -0
  211. data/lib/permission_forge/admin/controller_catalog.rb +166 -0
  212. data/lib/permission_forge/admin/csv.rb +41 -0
  213. data/lib/permission_forge/admin/editable_lock.rb +157 -0
  214. data/lib/permission_forge/admin/effective_access.rb +158 -0
  215. data/lib/permission_forge/admin/effective_fields.rb +176 -0
  216. data/lib/permission_forge/admin/expiry_presets.rb +51 -0
  217. data/lib/permission_forge/admin/exports/document.rb +89 -0
  218. data/lib/permission_forge/admin/exports/events.rb +51 -0
  219. data/lib/permission_forge/admin/exports/holdings.rb +118 -0
  220. data/lib/permission_forge/admin/exports.rb +37 -0
  221. data/lib/permission_forge/admin/field_across_sets.rb +51 -0
  222. data/lib/permission_forge/admin/field_detail.rb +112 -0
  223. data/lib/permission_forge/admin/field_level.rb +74 -0
  224. data/lib/permission_forge/admin/field_rows.rb +137 -0
  225. data/lib/permission_forge/admin/last_changes.rb +94 -0
  226. data/lib/permission_forge/admin/model_catalog.rb +216 -0
  227. data/lib/permission_forge/admin/people_candidates.rb +108 -0
  228. data/lib/permission_forge/admin/people_directory.rb +158 -0
  229. data/lib/permission_forge/admin/permission_set_factory.rb +48 -0
  230. data/lib/permission_forge/admin/person_label.rb +95 -0
  231. data/lib/permission_forge/admin/record_id.rb +19 -0
  232. data/lib/permission_forge/admin/rule_builder.rb +58 -0
  233. data/lib/permission_forge/admin/rule_form.rb +175 -0
  234. data/lib/permission_forge/admin/rule_form_input.rb +74 -0
  235. data/lib/permission_forge/admin/sensitive_columns.rb +68 -0
  236. data/lib/permission_forge/admin/settings_catalog.rb +261 -0
  237. data/lib/permission_forge/admin/settings_values.rb +130 -0
  238. data/lib/permission_forge/api/serializers/audit_event_serializer.rb +27 -0
  239. data/lib/permission_forge/api/serializers/base_serializer.rb +36 -0
  240. data/lib/permission_forge/api/serializers/permission_set_group_serializer.rb +12 -0
  241. data/lib/permission_forge/api/serializers/permission_set_group_user_serializer.rb +11 -0
  242. data/lib/permission_forge/api/serializers/permission_set_rule_serializer.rb +25 -0
  243. data/lib/permission_forge/api/serializers/permission_set_serializer.rb +13 -0
  244. data/lib/permission_forge/api/serializers/permission_set_user_serializer.rb +11 -0
  245. data/lib/permission_forge/api/serializers/resource_action_serializer.rb +13 -0
  246. data/lib/permission_forge/api/serializers/resource_field_serializer.rb +14 -0
  247. data/lib/permission_forge/api/serializers/resource_serializer.rb +15 -0
  248. data/lib/permission_forge/audit/actor.rb +144 -0
  249. data/lib/permission_forge/audit/actor_resolver.rb +103 -0
  250. data/lib/permission_forge/audit/audited_views.rb +208 -0
  251. data/lib/permission_forge/audit/boot_warnings.rb +58 -0
  252. data/lib/permission_forge/audit/column_values.rb +56 -0
  253. data/lib/permission_forge/audit/context.rb +98 -0
  254. data/lib/permission_forge/audit/detached.rb +62 -0
  255. data/lib/permission_forge/audit/event_catalog.rb +110 -0
  256. data/lib/permission_forge/audit/event_query.rb +192 -0
  257. data/lib/permission_forge/audit/failure.rb +47 -0
  258. data/lib/permission_forge/audit/field_access.rb +186 -0
  259. data/lib/permission_forge/audit/gate.rb +167 -0
  260. data/lib/permission_forge/audit/job_context.rb +32 -0
  261. data/lib/permission_forge/audit/log_access.rb +36 -0
  262. data/lib/permission_forge/audit/recorder.rb +168 -0
  263. data/lib/permission_forge/audit/refusals.rb +61 -0
  264. data/lib/permission_forge/audit/request_context.rb +152 -0
  265. data/lib/permission_forge/audit/runtime.rb +178 -0
  266. data/lib/permission_forge/audit/runtime_emission.rb +135 -0
  267. data/lib/permission_forge/audit/settings_observer.rb +70 -0
  268. data/lib/permission_forge/audit/source.rb +54 -0
  269. data/lib/permission_forge/audit/subscribers/base.rb +69 -0
  270. data/lib/permission_forge/audit/subscribers/legacy_hooks.rb +97 -0
  271. data/lib/permission_forge/audit/subscribers/log.rb +45 -0
  272. data/lib/permission_forge/audit/subscribers.rb +58 -0
  273. data/lib/permission_forge/audit.rb +302 -0
  274. data/lib/permission_forge/boot_checks.rb +184 -0
  275. data/lib/permission_forge/cache/backlog.rb +142 -0
  276. data/lib/permission_forge/cache/base.rb +200 -0
  277. data/lib/permission_forge/cache/bulk_invalidation.rb +131 -0
  278. data/lib/permission_forge/cache/failure.rb +171 -0
  279. data/lib/permission_forge/cache/handled_errors.rb +82 -0
  280. data/lib/permission_forge/cache/holder_invalidation.rb +85 -0
  281. data/lib/permission_forge/cache/invalidation.rb +180 -0
  282. data/lib/permission_forge/cache/memory_cache.rb +77 -0
  283. data/lib/permission_forge/cache/null_cache.rb +40 -0
  284. data/lib/permission_forge/cache/rails_cache.rb +231 -0
  285. data/lib/permission_forge/cache/rebuild.rb +149 -0
  286. data/lib/permission_forge/cache/settlement.rb +63 -0
  287. data/lib/permission_forge/cache/user_keys.rb +206 -0
  288. data/lib/permission_forge/cache/variant_removal.rb +55 -0
  289. data/lib/permission_forge/cache/withheld_notice.rb +60 -0
  290. data/lib/permission_forge/concerns/audited_collection.rb +34 -0
  291. data/lib/permission_forge/concerns/auto_permission_scoping.rb +158 -0
  292. data/lib/permission_forge/concerns/controller_action_guard.rb +106 -0
  293. data/lib/permission_forge/concerns/field_mask_checker.rb +45 -0
  294. data/lib/permission_forge/concerns/has_active_scope.rb +12 -0
  295. data/lib/permission_forge/concerns/has_audit_trail.rb +59 -0
  296. data/lib/permission_forge/concerns/has_permission_set_users.rb +115 -0
  297. data/lib/permission_forge/concerns/has_tenant_scoping.rb +86 -0
  298. data/lib/permission_forge/concerns/has_uuid.rb +24 -0
  299. data/lib/permission_forge/concerns/invalidates_in_batch.rb +207 -0
  300. data/lib/permission_forge/concerns/invalidates_managed_actions.rb +80 -0
  301. data/lib/permission_forge/concerns/masked_attributes/alias_access.rb +280 -0
  302. data/lib/permission_forge/concerns/masked_attributes/store_access.rb +100 -0
  303. data/lib/permission_forge/concerns/masked_attributes/validation_reads.rb +153 -0
  304. data/lib/permission_forge/concerns/masked_attributes/virtual_readers.rb +242 -0
  305. data/lib/permission_forge/concerns/masked_attributes.rb +576 -0
  306. data/lib/permission_forge/concerns/permission_cache_accessor.rb +416 -0
  307. data/lib/permission_forge/concerns/permission_scoped_findable.rb +337 -0
  308. data/lib/permission_forge/concerns/permissioned_user.rb +78 -0
  309. data/lib/permission_forge/concerns/permissions_memo.rb +160 -0
  310. data/lib/permission_forge/concerns/permissions_per_context.rb +288 -0
  311. data/lib/permission_forge/concerns/permissions_regenerator.rb +43 -0
  312. data/lib/permission_forge/concerns/records_audit_events.rb +286 -0
  313. data/lib/permission_forge/concerns/resource_permission_checker.rb +116 -0
  314. data/lib/permission_forge/concerns/validates_column_lengths.rb +69 -0
  315. data/lib/permission_forge/concerns/validates_dependency.rb +54 -0
  316. data/lib/permission_forge/conditional_sets.rb +361 -0
  317. data/lib/permission_forge/configuration/admin_ui_settings.rb +108 -0
  318. data/lib/permission_forge/configuration/audit_settings.rb +184 -0
  319. data/lib/permission_forge/configuration/cache_settings.rb +76 -0
  320. data/lib/permission_forge/configuration/controller_action_settings.rb +112 -0
  321. data/lib/permission_forge/configuration/gdpr_settings.rb +70 -0
  322. data/lib/permission_forge/configuration/masked_attributes_settings.rb +83 -0
  323. data/lib/permission_forge/configuration/permits_attributes_settings.rb +26 -0
  324. data/lib/permission_forge/configuration/presets.rb +139 -0
  325. data/lib/permission_forge/configuration/query_scoping_settings.rb +107 -0
  326. data/lib/permission_forge/configuration/strategy_values.rb +57 -0
  327. data/lib/permission_forge/configuration/virtual_attributes_settings.rb +65 -0
  328. data/lib/permission_forge/configuration.rb +624 -0
  329. data/lib/permission_forge/controller_actions/authorizer.rb +171 -0
  330. data/lib/permission_forge/controller_actions/managed_index.rb +213 -0
  331. data/lib/permission_forge/controller_actions/mapping.rb +167 -0
  332. data/lib/permission_forge/controller_actions.rb +122 -0
  333. data/lib/permission_forge/deprecation.rb +86 -0
  334. data/lib/permission_forge/engine.rb +113 -0
  335. data/lib/permission_forge/execution_state.rb +318 -0
  336. data/lib/permission_forge/invalidation_batch.rb +206 -0
  337. data/lib/permission_forge/masked_models.rb +226 -0
  338. data/lib/permission_forge/masking_sync.rb +271 -0
  339. data/lib/permission_forge/model_bindings.rb +90 -0
  340. data/lib/permission_forge/open_transaction.rb +303 -0
  341. data/lib/permission_forge/operators/constants.rb +41 -0
  342. data/lib/permission_forge/operators/registry.rb +300 -0
  343. data/lib/permission_forge/operators/rule_column.rb +120 -0
  344. data/lib/permission_forge/operators/validations.rb +111 -0
  345. data/lib/permission_forge/operators/value_casters/number.rb +68 -0
  346. data/lib/permission_forge/operators/value_casters/temporal.rb +69 -0
  347. data/lib/permission_forge/operators/value_casters.rb +80 -0
  348. data/lib/permission_forge/operators/value_typing.rb +115 -0
  349. data/lib/permission_forge/query_scoping_warnings.rb +68 -0
  350. data/lib/permission_forge/rake_audit.rb +65 -0
  351. data/lib/permission_forge/resource_chain.rb +190 -0
  352. data/lib/permission_forge/rls.rb +209 -0
  353. data/lib/permission_forge/services/attribute_access_guard.rb +138 -0
  354. data/lib/permission_forge/services/audit_event_purger.rb +150 -0
  355. data/lib/permission_forge/services/bulk_permission_set_assignment.rb +108 -0
  356. data/lib/permission_forge/services/concerns/across_tenants.rb +69 -0
  357. data/lib/permission_forge/services/concerns/audited_operation.rb +43 -0
  358. data/lib/permission_forge/services/concerns/clonable.rb +29 -0
  359. data/lib/permission_forge/services/consent_withdrawal_handler.rb +135 -0
  360. data/lib/permission_forge/services/dashboard_stats.rb +43 -0
  361. data/lib/permission_forge/services/data_subject_erasure.rb +306 -0
  362. data/lib/permission_forge/services/expired_assignment_cleaner.rb +152 -0
  363. data/lib/permission_forge/services/extra_field_defaults.rb +71 -0
  364. data/lib/permission_forge/services/field_permission_merger.rb +83 -0
  365. data/lib/permission_forge/services/filters_response_attributes.rb +144 -0
  366. data/lib/permission_forge/services/gdpr_field_auditor/text_report.rb +63 -0
  367. data/lib/permission_forge/services/gdpr_field_auditor.rb +223 -0
  368. data/lib/permission_forge/services/host_key_guard.rb +110 -0
  369. data/lib/permission_forge/services/mask_value.rb +93 -0
  370. data/lib/permission_forge/services/permission_cache_clearer.rb +174 -0
  371. data/lib/permission_forge/services/permission_hash_builder.rb +305 -0
  372. data/lib/permission_forge/services/permission_rule_applier.rb +192 -0
  373. data/lib/permission_forge/services/permission_set_cloner.rb +89 -0
  374. data/lib/permission_forge/services/permission_set_condition_evaluator.rb +102 -0
  375. data/lib/permission_forge/services/permission_set_group_cloner.rb +52 -0
  376. data/lib/permission_forge/services/permits_attributes/nested_records.rb +106 -0
  377. data/lib/permission_forge/services/permits_attributes.rb +270 -0
  378. data/lib/permission_forge/services/resource_field_generator.rb +93 -0
  379. data/lib/permission_forge/services/resource_with_fields_builder.rb +47 -0
  380. data/lib/permission_forge/services/rls_operator_sql.rb +131 -0
  381. data/lib/permission_forge/services/rls_policy_generator.rb +365 -0
  382. data/lib/permission_forge/services/security_audit/action_check.rb +157 -0
  383. data/lib/permission_forge/services/security_audit/assignment_check.rb +120 -0
  384. data/lib/permission_forge/services/security_audit/audit_trail_check.rb +166 -0
  385. data/lib/permission_forge/services/security_audit/base_check.rb +57 -0
  386. data/lib/permission_forge/services/security_audit/config_check.rb +212 -0
  387. data/lib/permission_forge/services/security_audit/integrity_check.rb +105 -0
  388. data/lib/permission_forge/services/security_audit/personal_data_check.rb +79 -0
  389. data/lib/permission_forge/services/security_audit/privilege_check.rb +67 -0
  390. data/lib/permission_forge/services/security_audit/rule_check.rb +141 -0
  391. data/lib/permission_forge/services/security_audit/text_report.rb +46 -0
  392. data/lib/permission_forge/services/security_audit/user_model_check.rb +67 -0
  393. data/lib/permission_forge/services/security_audit.rb +75 -0
  394. data/lib/permission_forge/services/stale_assignment_auditor.rb +68 -0
  395. data/lib/permission_forge/services/unscoped_query_detector.rb +132 -0
  396. data/lib/permission_forge/version.rb +3 -0
  397. data/lib/permission_forge/virtual_fields/discovery.rb +65 -0
  398. data/lib/permission_forge/virtual_fields.rb +102 -0
  399. data/lib/permission_forge.rb +464 -0
  400. data/lib/tasks/permission_forge.rake +31 -0
  401. data/lib/tasks/permission_forge_audit_events.rake +28 -0
  402. data/lib/tasks/permission_forge_cache.rake +26 -0
  403. data/lib/tasks/permission_forge_expiration.rake +30 -0
  404. data/lib/tasks/permission_forge_gdpr.rake +69 -0
  405. data/lib/tasks/permission_forge_rls.rake +38 -0
  406. metadata +515 -0
@@ -0,0 +1,576 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'masked_attributes/virtual_readers'
4
+ require_relative 'masked_attributes/store_access'
5
+ require_relative 'masked_attributes/alias_access'
6
+ require_relative 'masked_attributes/validation_reads'
7
+
8
+ module PermissionForge
9
+ module Concerns
10
+ # Auto-applies field-level read permissions on the model itself by overriding
11
+ # `read_attribute`, `attributes`, every per-column getter, and the
12
+ # `*_for_serialization` hooks. Any path that touches a column — direct getter
13
+ # (`customer.email`), bracket access (`customer[:email]`), `attributes`,
14
+ # `as_json`, `to_json`, `serializable_hash`, AMS, Jbuilder — is enforced.
15
+ #
16
+ # Hidden attributes are *omitted* from `attributes` by default — matching
17
+ # Salesforce `Security.stripInaccessible()` semantics. Configure via
18
+ # {PermissionForge::Configuration#omit_hidden_in_attributes_hash}.
19
+ #
20
+ # NOTE: This does NOT intercept `pluck`, raw SQL, or any path that bypasses
21
+ # the model layer. Use {PermissionForge::Services::FiltersResponseAttributes}
22
+ # for those code paths.
23
+ #
24
+ # Virtual (non-column) readers are covered by {VirtualReaders}, opt-in
25
+ # through config.mask_virtual_attributes. An alias_attribute name reads and
26
+ # writes its attribute, at that attribute's level ({AliasAccess}).
27
+ #
28
+ # With no current user every value is returned as stored, unless
29
+ # config.masked_attributes_nil_user_strategy = :hide, which hides every
30
+ # guarded attribute instead (the primary key and the raw paths excepted).
31
+ #
32
+ # @example Include in a model
33
+ # class Customer < ApplicationRecord
34
+ # include PermissionForge::Concerns::PermissionScopedFindable
35
+ # include PermissionForge::Concerns::MaskedAttributes
36
+ # end
37
+ #
38
+ # customer.email # masked / nil / raw based on current_user
39
+ # customer.read_attribute(:email) # same
40
+ # customer.as_json # hidden keys omitted entirely
41
+ #
42
+ # @api public
43
+ module MaskedAttributes # rubocop:disable Metrics/ModuleLength
44
+ extend ActiveSupport::Concern
45
+
46
+ # The class methods the guard calls. ActiveSupport::Concern extends a
47
+ # concern's own class methods only after it has included the concern,
48
+ # and another thread can read a record of the class in between, through
49
+ # the read_attribute just included. A dependency's class methods are
50
+ # extended before: so they live here.
51
+ #
52
+ # @api private
53
+ module ResourceName
54
+ extend ActiveSupport::Concern
55
+
56
+ # Extended into the model by ActiveSupport::Concern.
57
+ module ClassMethods
58
+ # The Resource name a read of this class is reported under, as a
59
+ # Symbol: worked out once per class, not on every read.
60
+ def pf_resource_symbol
61
+ @pf_resource_symbol ||= name.to_sym
62
+ end
63
+
64
+ # The Resource names a field of this class is looked up under: its
65
+ # own and its STI ancestors' (ResourceChain), worked out once per
66
+ # class. A read is reported under the first of them.
67
+ def pf_resource_chain
68
+ PermissionForge::ResourceChain.names(self)
69
+ end
70
+ end
71
+ end
72
+
73
+ include ResourceName
74
+ include VirtualReaders
75
+ include StoreAccess
76
+ include AliasAccess
77
+ include ValidationReads
78
+
79
+ # Raised (when config.masked_write_protection == :raise) if a masked or hidden
80
+ # value read through the getter is written back to the same attribute.
81
+ #
82
+ # @api public
83
+ class MaskedWriteError < StandardError
84
+ def initialize(attribute)
85
+ super("Refusing to persist a masked/hidden value back to '#{attribute}'. " \
86
+ 'This usually means a callback read the masked value and wrote it back. ' \
87
+ 'Read the raw value via read_attribute_before_type_cast, or set ' \
88
+ 'config.masked_write_protection = :skip.')
89
+ end
90
+ end
91
+
92
+ # The module a class's masked column getters and guarded setters live in.
93
+ #
94
+ # @api private
95
+ class ColumnAccessors < Module; end
96
+
97
+ # Set (an ExecutionState slot) while attribute_for_inspect reads: a
98
+ # debugging read is not a field read.
99
+ INSPECT_KEY = :pf_inspecting
100
+
101
+ included do
102
+ next if PermissionForge::Concerns::MaskedAttributes.applied?(self)
103
+
104
+ # AR generates per-column reader methods on a dedicated module
105
+ # (`generated_attribute_methods`) and includes it in the model. Calling
106
+ # `customer.email` hits that fast path, bypassing `read_attribute`. We
107
+ # 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.
110
+ #
111
+ # A getter raises ActiveModel::MissingAttributeError for a column that
112
+ # was not loaded (`select(:id)`), as the getter Rails generates does.
113
+ #
114
+ # Primary key is intentionally never overridden — it's used by routing,
115
+ # association lookups, and tenant scoping (acts_as_tenant calls `id` on
116
+ # the tenant model when computing cache keys). Masking `id` would create
117
+ # a recursion: `id` → guard → `permissions_hash` → `current_tenant.id`.
118
+ 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
129
+ prepend(masked_attribute_module)
130
+
131
+ const_set(:MaskedAttributeReaders, masked_attribute_module)
132
+ # A subclass that is already loaded may have generated column methods
133
+ # of its own, which it would keep calling: they are removed, so that
134
+ # it finds this class's, as a subclass loaded later does.
135
+ descendants.each do |klass|
136
+ PermissionForge::Concerns::MaskedAttributes.drop_generated_accessors(klass, masked_attribute_module)
137
+ end
138
+ pf_mask_attribute_aliases
139
+ @_pf_masked_attributes_applied = true
140
+ end
141
+
142
+ # Whether MaskedAttributes has already been applied to a class. Used by the
143
+ # engine's auto-apply to avoid re-prepending the readers module.
144
+ def self.applied?(klass)
145
+ klass.instance_variable_defined?(:@_pf_masked_attributes_applied) &&
146
+ klass.instance_variable_get(:@_pf_masked_attributes_applied)
147
+ end
148
+
149
+ # Applied to the class or to a class it inherits from: a subclass is
150
+ # masked through its parent (the concern is never included into it).
151
+ #
152
+ # @param klass [Class]
153
+ # @return [Boolean]
154
+ def self.applied_in_chain?(klass)
155
+ klass.ancestors.any? { |ancestor| ancestor.is_a?(Class) && applied?(ancestor) }
156
+ end
157
+
158
+ # Removes from the module Rails generated a subclass's attribute methods
159
+ # into the ones `readers` covers. Rails gives a subclass column methods of
160
+ # its own unless its parent defines them outside such a module, which the
161
+ # parent does from the moment it has the concern; a subclass used before
162
+ # that has them already. Under the lock Rails generates them under, so a
163
+ # generation in progress on another thread is finished first.
164
+ #
165
+ # A method Rails undefined there (reset_column_information) is not found
166
+ # by `remove_method`, and would stop the lookup short of the parent's:
167
+ # it is defined again first, as a call to `super`, then removed.
168
+ def self.drop_generated_accessors(klass, readers)
169
+ generated = klass.send(:generated_attribute_methods)
170
+ with_attribute_methods_lock(generated) do
171
+ readers.instance_methods(false).each do |name|
172
+ generated.define_method(name) { |*args| super(*args) } unless generated.method_defined?(name, false)
173
+ generated.send(:remove_method, name)
174
+ end
175
+ end
176
+ end
177
+
178
+ # Rails 7.2 and later generate attribute methods under one lock; 7.1
179
+ # under one per generated module.
180
+ def self.with_attribute_methods_lock(generated, &)
181
+ owner = ActiveRecord::AttributeMethods::GeneratedAttributeMethods
182
+ owner.const_defined?(:LOCK) ? owner::LOCK.synchronize(&) : generated.synchronize(&)
183
+ end
184
+ private_class_method :with_attribute_methods_lock
185
+
186
+ # Diagnostic: explains exactly what the guard decided for a given record
187
+ # and attribute, and why. Run from the console when a field returns an
188
+ # unexpected value (nil where you expected raw, raw where you expected masked, etc).
189
+ #
190
+ # @example
191
+ # PermissionForge::Concerns::MaskedAttributes.explain(customer, :email)
192
+ # rubocop:disable-next Metrics/AbcSize
193
+ def self.explain(record, attr_name)
194
+ config = PermissionForge.configuration
195
+ user = PermissionForge.current_user
196
+ klass = record.class
197
+ chain = PermissionForge::ResourceChain.names(klass)
198
+ raw = record.read_attribute_before_type_cast(attr_name) || record[attr_name]
199
+ field_name = AliasAccess.target(klass, attr_name) || attr_name
200
+ field = PermissionForge::ResourceChain.field_permissions(user, chain, field_name)
201
+
202
+ {
203
+ attribute: attr_name,
204
+ class: klass.name,
205
+ resource_chain: chain,
206
+ masked_attributes_applied: applied_in_chain?(klass),
207
+ primary_key: klass.primary_key,
208
+ 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),
211
+ user_responds_to_field_permissions: user.respond_to?(:field_permissions),
212
+ resource_in_permissions_hash: entries?(user, chain),
213
+ field_configured: !field.nil?,
214
+ field_entry: field,
215
+ unconfigured_field_strategy: config.unconfigured_field_strategy,
216
+ omit_hidden_in_attributes_hash: config.omit_hidden_in_attributes_hash,
217
+ raw_value: raw,
218
+ returned_value: record.public_send(attr_name)
219
+ }
220
+ end
221
+
222
+ # Whether the user's hash has a resource entry along the chain.
223
+ def self.entries?(user, chain)
224
+ user.respond_to?(:permissions_hash) && PermissionForge::ResourceChain.entries(user, chain).any?
225
+ end
226
+ private_class_method :entries?
227
+
228
+ # Diagnostic: walks every column and virtual reader on the record and reports
229
+ # raw vs. returned, plus the decision the guard made (passed / masked / hidden /
230
+ # unconfigured / bypassed). A virtual reader that is not masked on the model
231
+ # (config.mask_virtual_attributes off for it) reports :virtual_masking_off.
232
+ # Prints a readable table to stdout and returns the rows for programmatic use.
233
+ #
234
+ # @example
235
+ # PermissionForge::Concerns::MaskedAttributes.explain_all(customer)
236
+ def self.explain_all(record)
237
+ klass = record.class
238
+ rows = klass.column_names.map { |col| explain_row(record, col, record[col]) } +
239
+ virtual_reader_names(record).map { |name| explain_virtual_row(record, name) }
240
+ print_explain_table(rows)
241
+ rows
242
+ end
243
+
244
+ def self.explain_row(record, attr_name, raw)
245
+ klass = record.class
246
+ user = PermissionForge.current_user
247
+ field = PermissionForge::ResourceChain.field_permissions(user, PermissionForge::ResourceChain.names(klass), attr_name)
248
+
249
+ decision = decide(attr_name: attr_name, klass: klass, user: user, field: field)
250
+
251
+ { attribute: attr_name, decision: decision, configured: !field.nil?,
252
+ read_access: field&.dig(:read_access), mask_access: field&.dig(:mask_access),
253
+ mask_strategy: field&.dig(:mask_strategy), raw: raw, returned: record.public_send(attr_name), virtual: false }
254
+ end
255
+ private_class_method :explain_row
256
+
257
+ # The model's virtual fields that have a reader, masked on the model or not.
258
+ def self.virtual_reader_names(record)
259
+ klass = record.class
260
+ klass.respond_to?(:pf_virtual_readers) ? klass.pf_virtual_readers : []
261
+ end
262
+ private_class_method :virtual_reader_names
263
+
264
+ def self.explain_virtual_row(record, name)
265
+ raw = record.pf_without_virtual_masking { record.public_send(name) }
266
+ row = explain_row(record, name, raw).merge(virtual: true)
267
+ VirtualReaders.masked?(record.class, name) ? row : row.merge(decision: :virtual_masking_off)
268
+ end
269
+ private_class_method :explain_virtual_row
270
+
271
+ def self.decide(attr_name:, klass:, user:, field:)
272
+ return :primary_key_skipped if attr_name.to_s == klass.primary_key.to_s
273
+ return without_user_decision(klass) if user.nil?
274
+ return :unconfigured if field.nil?
275
+ return :hidden_no_read_access unless field[:read_access]
276
+ return :masked if field[:mask_access]
277
+
278
+ :passed_raw
279
+ end
280
+ private_class_method :decide
281
+
282
+ # What the guard does with no user (config.masked_attributes_nil_user_strategy;
283
+ # the user model is exempt from :hide).
284
+ def self.without_user_decision(klass)
285
+ config = PermissionForge.configuration
286
+ return :no_user_bypassed unless config.hide_masked_attributes_without_user?
287
+
288
+ config.user_model_class?(klass) ? :no_user_user_model_exempt : :no_user_hidden
289
+ end
290
+ private_class_method :without_user_decision
291
+
292
+ def self.print_explain_table(rows)
293
+ return if rows.empty?
294
+
295
+ Rails.logger&.info('[PermissionForge.explain_all]')
296
+ rows.each do |r|
297
+ line = format(
298
+ '%<attribute>-22s decision=%<decision>-22s configured=%<configured>-5s raw=%<raw>s -> returned=%<returned>s',
299
+ attribute: r[:attribute], decision: r[:decision], configured: r[:configured],
300
+ raw: r[:raw].inspect, returned: r[:returned].inspect
301
+ )
302
+ puts line
303
+ end
304
+ end
305
+ private_class_method :print_explain_table
306
+
307
+ # Reads an attribute through the field permissions of the current user.
308
+ # @param attr_name [String, Symbol]
309
+ def read_attribute(attr_name, &)
310
+ name = attr_name.to_s
311
+ raw = super(name, &)
312
+ # An alias is judged as the attribute it points to (AliasAccess); what
313
+ # is read is what Rails reads for it.
314
+ target = self.class.attribute_aliases[name]
315
+ attr_name = name = pf_alias_end(target) if target
316
+ return raw if pf_read_as_stored?(name)
317
+
318
+ include_field, value = pf_guard(attr_name, raw)
319
+ value = PermissionForge.configuration.hidden_attribute_value unless include_field
320
+ # A returned value that differs from the raw column value was masked or
321
+ # hidden; remember the exact sentinel so a write-back of it can be refused.
322
+ pf_record_masked_read(name, value) unless include_field && value == raw
323
+ value
324
+ end
325
+
326
+ # The user is resolved once for the whole hash. With no user every
327
+ # 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.
331
+ def attributes
332
+ raw = super
333
+ user = pf_guarded_user
334
+ return raw unless user || pf_hide_without_user? || PermissionForge::Audit::FieldAccess::GATE.open?
335
+
336
+ PermissionForge::Audit::FieldAccess.read(self, self.class.name, record: self) do
337
+ pf_guard_attributes_hash(raw, user)
338
+ end
339
+ end
340
+
341
+ # as_json / to_json / serializable_hash: one read of the record, so one
342
+ # field.access event for all the fields it serializes.
343
+ def serializable_hash(options = nil)
344
+ PermissionForge::Audit::FieldAccess.read(self, self.class.name, record: self) { super }
345
+ end
346
+
347
+ # Hook used by ActiveModel::Serialization (and therefore as_json / to_json /
348
+ # serializable_hash). Filtering the *names* here means hidden keys never get
349
+ # serialized at all — matching Salesforce stripInaccessible semantics.
350
+ def attribute_names_for_serialization
351
+ names = super
352
+ user = pf_guarded_user
353
+ return names unless user || pf_hide_without_user?
354
+ return names unless PermissionForge.configuration.omit_hidden_in_attributes_hash
355
+
356
+ hidden, shown = names.partition { |name| pf_hidden_attribute?(name, user) }
357
+ PermissionForge::Audit::FieldAccess.omitted(self, self.class.pf_resource_symbol, hidden, user) unless hidden.empty?
358
+ shown
359
+ end
360
+
361
+ # AR's default routes through `send(name)` which hits the AR attribute fast
362
+ # path and bypasses our `read_attribute` override. Routing real columns
363
+ # through `read_attribute` lets the masking logic apply during serialization.
364
+ # Virtual attributes (defined as methods) still go through the original path.
365
+ # A column that was not loaded raises, as that original path does.
366
+ def read_attribute_for_serialization(name)
367
+ if self.class.attribute_names.include?(name.to_s)
368
+ read_attribute(name) { |missing| missing_attribute(missing, caller) }
369
+ else
370
+ super
371
+ end
372
+ end
373
+
374
+ # What inspect, pretty_print and the console print for an attribute: the
375
+ # value the current user's getter returns, formatted as Rails formats it
376
+ # (filter_attributes still apply on top). Rails reads the raw attribute
377
+ # here, so a record in a log line or an error report used to show masked
378
+ # and hidden values in full. Not a field read: no field.access decision,
379
+ # no hook. And never an error: inspect runs inside error reporters and
380
+ # rescue blocks, often in the failed transaction itself, so a read that
381
+ # fails — or a Rails formatting of the value that fails (a private
382
+ # method, renamed some day) — shows the hidden value, never the stored
383
+ # one.
384
+ def attribute_for_inspect(attr_name)
385
+ name = attr_name.to_s
386
+ name = self.class.attribute_aliases[name] || name
387
+ pf_while_inspecting { format_for_inspect(name, read_attribute(name)) }
388
+ rescue StandardError
389
+ pf_hidden_for_inspect
390
+ end
391
+
392
+ # Rails 7.1 reads the raw attribute in pretty_print; 7.2 and later print
393
+ # attribute_for_inspect. The 7.2 listing, for 7.1.
394
+ if ActiveRecord.gem_version < Gem::Version.new('7.2')
395
+ # Prints the record without revealing masked or hidden values.
396
+ def pretty_print(pp)
397
+ return super if custom_inspect_method_defined?
398
+
399
+ pp.object_address_group(self) do
400
+ next pf_pp_not_initialized(pp) unless defined?(@attributes) && @attributes
401
+
402
+ attr_names = self.class.attribute_names.select { |name| _has_attribute?(name) }
403
+ pp.seplist(attr_names, proc { pp.text ',' }) { |attr_name| pf_pp_attribute(pp, attr_name) }
404
+ end
405
+ end
406
+
407
+ def pf_pp_attribute(pp, attr_name)
408
+ pp.breakable ' '
409
+ pp.group(1) do
410
+ pp.text attr_name
411
+ pp.text ':'
412
+ pp.breakable
413
+ pp.text attribute_for_inspect(attr_name)
414
+ end
415
+ end
416
+
417
+ def pf_pp_not_initialized(pp)
418
+ pp.breakable ' '
419
+ pp.text 'not initialized'
420
+ end
421
+ private :pf_pp_attribute, :pf_pp_not_initialized
422
+ end
423
+
424
+ private
425
+
426
+ # Whether the user's entry for `name`, along this class's resource
427
+ # chain, denies read. False for an unconfigured field. With no user:
428
+ # false, or true for everything but the primary key under :hide.
429
+ def pf_hidden_attribute?(name, user = pf_guarded_user)
430
+ return pf_hide_without_user? && name.to_s != self.class.primary_key unless user
431
+
432
+ field = PermissionForge::ResourceChain.field_permissions(user, self.class.pf_resource_chain, name)
433
+ field ? !field[:read_access] : false
434
+ end
435
+
436
+ # The primary key is never masked; a store column is read as stored
437
+ # while a store accessor writes one of its keys (StoreAccess), and the
438
+ # column a validator reads while it reads it (ValidationReads).
439
+ def pf_read_as_stored?(name)
440
+ name == self.class.primary_key || pf_raw_store_column?(name) || name == pf_validation_read
441
+ end
442
+
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.
447
+ def pf_hide_without_user?
448
+ PermissionForge.configuration.hide_masked_attributes_without_user_for?(self.class)
449
+ end
450
+
451
+ # Every guarded read of this record is one field.access read
452
+ # (Audit::FieldAccess), joined by the reads made inside it. Nobody
453
+ # listening (the gate is shut): one check, nothing collected.
454
+ def pf_guard(attr_name, raw, user = pf_guarded_user)
455
+ chain = self.class.pf_resource_chain
456
+ unless PermissionForge::Audit::FieldAccess::GATE.open? && !PermissionForge::ExecutionState[INSPECT_KEY]
457
+ return PermissionForge::Services::AttributeAccessGuard.call(
458
+ resource_name: chain, attribute: attr_name, raw_value: raw, user: user, observer: nil, model: self.class
459
+ )
460
+ end
461
+
462
+ PermissionForge::Audit::FieldAccess.read(self, self.class.name, record: self) do |frame|
463
+ PermissionForge::Services::AttributeAccessGuard.call(
464
+ resource_name: chain, attribute: attr_name, raw_value: raw, user: user, observer: frame, model: self.class
465
+ )
466
+ end
467
+ end
468
+
469
+ # Marks the reads in the block as debugging reads: no field.access
470
+ # decision, no hook.
471
+ def pf_while_inspecting(&)
472
+ PermissionForge::ExecutionState.with(INSPECT_KEY, true, &)
473
+ end
474
+
475
+ # What inspect prints for an attribute whose read or formatting failed:
476
+ # the hidden value as inspect prints it — a String whatever the
477
+ # configured value is, so inspect itself never raises.
478
+ def pf_hidden_for_inspect
479
+ PermissionForge.configuration.hidden_attribute_value.inspect
480
+ rescue StandardError
481
+ 'nil'
482
+ end
483
+
484
+ # Per-instance record of the exact masked/hidden sentinel handed out for
485
+ # each attribute, keyed by attribute name string.
486
+ def pf_masked_reads
487
+ @pf_masked_reads ||= {}
488
+ end
489
+
490
+ def pf_record_masked_read(attr_name, sentinel)
491
+ pf_masked_reads[attr_name.to_s] = sentinel
492
+ end
493
+
494
+ # True when the guard should suppress this write because `value` is a masked/
495
+ # hidden sentinel being written back over real data. Honors
496
+ # config.masked_write_protection. Two detections:
497
+ # 1. Ledger — this instance handed out that exact sentinel earlier (covers
498
+ # same-instance read-modify-write, e.g. a before_save callback).
499
+ # 2. Stateless — `value` equals what the mask would produce for the value
500
+ # CURRENTLY persisted in the DB. This survives a request boundary: a form
501
+ # rendered "********" and submitted back onto a freshly-loaded record has
502
+ # no ledger entry, but still matches the persisted value's mask.
503
+ def pf_skip_masked_write?(attr_name, value)
504
+ protection = PermissionForge.configuration.masked_write_protection
505
+ return false if protection == :off
506
+
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)
509
+
510
+ pf_masked_write_refused(attr_name, protection, ledger_hit)
511
+ raise MaskedWriteError, attr_name if protection == :raise
512
+
513
+ true # :skip — preserve the stored value by not calling super
514
+ end
515
+
516
+ # write.masked_skipped: which attribute, how it was refused (:skip
517
+ # keeps the stored value, :raise raised) and how the sentinel was
518
+ # recognised. Never the value.
519
+ def pf_masked_write_refused(attr_name, protection, ledger_hit)
520
+ event = 'write.masked_skipped'
521
+ return unless PermissionForge::Audit.instrument?(event)
522
+
523
+ user = PermissionForge.current_user
524
+ PermissionForge::Audit.instrument(event) do
525
+ { record: self, subject_user_id: PermissionForge::Audit::Runtime.user_id(user),
526
+ data: { 'model' => self.class.name, 'attribute' => attr_name.to_s, 'protection' => protection.to_s,
527
+ 'detected_by' => ledger_hit ? 'ledger' : 'persisted_value' },
528
+ objects: { user: user, record: self } }
529
+ end
530
+ end
531
+
532
+ # True when `value` equals the masked/hidden sentinel derived from the value
533
+ # currently persisted in the database for this attribute, and differs from
534
+ # that persisted value (writing the same value is a harmless no-op either way).
535
+ def pf_matches_persisted_sentinel?(attr_name, value)
536
+ field = pf_current_field_permissions(attr_name)
537
+ return false unless field
538
+ return value == PermissionForge.configuration.hidden_attribute_value unless field[:read_access]
539
+ return false unless field[:mask_access]
540
+
541
+ pf_persisted_mask_matches?(attr_name, value, field[:mask_strategy])
542
+ end
543
+
544
+ def pf_current_field_permissions(attr_name)
545
+ PermissionForge::ResourceChain.field_permissions(PermissionForge.current_user, self.class.pf_resource_chain, attr_name)
546
+ end
547
+
548
+ def pf_persisted_mask_matches?(attr_name, value, strategy)
549
+ persisted = attribute_in_database(attr_name.to_s)
550
+ sentinel = PermissionForge::Services::MaskValue.call(persisted, strategy: strategy || 'full')
551
+ value == sentinel && value != persisted
552
+ end
553
+
554
+ def pf_guard_attributes_hash(raw, user)
555
+ omit_hidden = PermissionForge.configuration.omit_hidden_in_attributes_hash
556
+ hidden_value = PermissionForge.configuration.hidden_attribute_value
557
+
558
+ primary_key = self.class.primary_key.to_s
559
+ raw.filter_map do |key, value|
560
+ # The primary key is never guarded (read_attribute and as_json keep it).
561
+ next [key, value] if key.to_s == primary_key
562
+
563
+ include_field, masked = pf_guard(key, value, user)
564
+ next [key, masked] if include_field
565
+ next if omit_hidden
566
+
567
+ [key, hidden_value]
568
+ end.to_h
569
+ end
570
+
571
+ def pf_guarded_user
572
+ PermissionForge.current_user
573
+ end
574
+ end
575
+ end
576
+ end