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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # The gem's deprecations: one ActiveSupport::Deprecation for every renamed
5
+ # or removed public API (the README "Versioning and stability" surface).
6
+ #
7
+ # Convention: a public API is never renamed or removed in one step. The
8
+ # release that changes it keeps the old name working for at least one
9
+ # minor version, warning through {PermissionForge.deprecator}, and lists it
10
+ # under "Deprecated" in the CHANGELOG; the release at HORIZON removes it.
11
+ #
12
+ # # a renamed method or configuration option (reader and writer): the old
13
+ # # name calls the new one — not alias_method, which would skip the
14
+ # # writers Configuration prepends (validation, the preset's bookkeeping)
15
+ # def old_name = new_name
16
+ # def old_name=(value)
17
+ # self.new_name = value
18
+ # end
19
+ # PermissionForge.deprecator.deprecate_methods(self, old_name: :new_name, 'old_name=': :'new_name=')
20
+ # # a removed behaviour, warned once per process
21
+ # PermissionForge::Deprecation.warn('passing a String is deprecated; pass a Symbol')
22
+ #
23
+ # In a Rails application the deprecator is registered as
24
+ # `Rails.application.deprecators[:permission_forge]`, so the host's
25
+ # `config.active_support.deprecation` (:stderr, :log, :raise, :silence…),
26
+ # `disallowed_deprecation` and `Rails.application.deprecators.silence { }`
27
+ # govern it as they govern Rails' own.
28
+ #
29
+ # @api private
30
+ module Deprecation
31
+ # The version that removes what is deprecated now. A test fails once
32
+ # VERSION reaches it: remove the deprecated code, then move the horizon.
33
+ HORIZON = '0.15'
34
+ GEM_NAME = 'PermissionForge'
35
+
36
+ DEPRECATOR = ActiveSupport::Deprecation.new(HORIZON, GEM_NAME)
37
+
38
+ WARNED_MUTEX = Mutex.new
39
+ private_constant :WARNED_MUTEX
40
+
41
+ @warned = Set.new
42
+
43
+ class << self
44
+ # @return [ActiveSupport::Deprecation]
45
+ def deprecator = DEPRECATOR
46
+
47
+ # Warns through the deprecator the first time `key` is seen in this
48
+ # process, so a deprecated call on a hot path does not flood the log.
49
+ # A warning that raised (behavior :raise) or was silenced is not
50
+ # remembered: the next call warns (or raises) again. The message says
51
+ # what to use instead; the version that removes it is added.
52
+ #
53
+ # @param message [String] e.g. "passing a String is deprecated; pass a Symbol"
54
+ # @param key [Object] what "once" is counted by (default: the message)
55
+ # @return [void]
56
+ def warn(message, key: message, callstack: caller_locations(1))
57
+ return if WARNED_MUTEX.synchronize { @warned.include?(key) }
58
+
59
+ DEPRECATOR.warn("#{message}. #{GEM_NAME} #{HORIZON} removes it.", callstack)
60
+ WARNED_MUTEX.synchronize { @warned << key } unless DEPRECATOR.silenced
61
+ end
62
+
63
+ # Forgets which keys warned (for tests that check a warning).
64
+ #
65
+ # @return [void]
66
+ def reset!
67
+ WARNED_MUTEX.synchronize { @warned.clear }
68
+ end
69
+
70
+ # Hands the deprecator to the application's deprecators (Rails 7.1+).
71
+ #
72
+ # @param app [Rails::Application]
73
+ # @return [void]
74
+ def register(app)
75
+ app.deprecators[:permission_forge] = DEPRECATOR if app.respond_to?(:deprecators)
76
+ end
77
+ end
78
+ end
79
+
80
+ # @return [ActiveSupport::Deprecation] the gem's deprecator
81
+ def self.deprecator = Deprecation.deprecator
82
+ end
83
+
84
+ # Runs when the application starts initializing, or at once when it already
85
+ # has (the gem required late).
86
+ ActiveSupport.on_load(:before_initialize, yield: true) { |app| PermissionForge::Deprecation.register(app) }
@@ -0,0 +1,113 @@
1
+ require_relative 'query_scoping_warnings'
2
+ require_relative 'boot_checks'
3
+ require_relative 'masking_sync'
4
+ require_relative 'masked_models'
5
+
6
+ module PermissionForge
7
+ # Wires the gem into the host application: initializers, the executor and
8
+ # reloader hooks, the boot checks. The work is done by plain modules
9
+ # (BootChecks, MaskingSync, Audit, ...).
10
+ #
11
+ # @api public
12
+ class Engine < ::Rails::Engine
13
+ isolate_namespace PermissionForge
14
+
15
+ config.generators do |g|
16
+ g.test_framework :minitest
17
+ end
18
+
19
+ # The models (app/models/permission_forge) are autoloaded like any engine
20
+ # model: their class bodies read the configuration after the host's
21
+ # config/initializers, so nothing is re-declared after boot. No engine
22
+ # initializer may reference them — the application's autoloader is set up
23
+ # after every initializer has run (Rails 7.1+).
24
+
25
+ # The admin UI's CSS/JS/brand files are served by PermissionForge::AssetsController
26
+ # straight from app/frontend — no Sprockets/Propshaft/importmap registration needed.
27
+
28
+ initializer 'permission_forge.i18n' do
29
+ locale_dir = root.join('config', 'locales')
30
+ allowed = PermissionForge.configuration.available_locales || PermissionForge::BUNDLED_LOCALES
31
+
32
+ if allowed.present?
33
+ pattern = "{#{Array(allowed).join(',')}}"
34
+ config.i18n.load_path += Dir[locale_dir.join("permission_forge.#{pattern}.yml")]
35
+ else
36
+ config.i18n.load_path += Dir[locale_dir.join('permission_forge.*.yml')]
37
+ end
38
+ end
39
+
40
+ initializer 'permission_forge.append_routes' do |_app|
41
+ # Routes are only active when the engine is mounted — no automatic injection
42
+ end
43
+
44
+ initializer 'permission_forge.filter_parameters', after: :load_config_initializers do |app|
45
+ pf_config = PermissionForge.configuration
46
+ if pf_config.gdpr_defaults && pf_config.filter_parameters_integration
47
+ new_filters = PermissionForge.gdpr_filter_parameters
48
+ app.config.filter_parameters += new_filters
49
+
50
+ Rails.logger.info "[PermissionForge] Added #{new_filters.size} GDPR patterns to filter_parameters."
51
+ end
52
+ end
53
+
54
+ # Tenancy needs the acts_as_tenant gem: refuse to boot without it. The
55
+ # scope itself is declared by each model as it loads (HasTenantScoping).
56
+ initializer 'permission_forge.tenant_scoping', after: :load_config_initializers do
57
+ PermissionForge::BootChecks.ensure_tenancy_gem!(PermissionForge.configuration)
58
+ end
59
+
60
+ # Audit event log: install the notification subscribers (Audit::Subscribers
61
+ # — log, store and legacy-hook adapters register there) and reset the
62
+ # audit context at the end of every executor run.
63
+ initializer 'permission_forge.audit', after: :load_config_initializers do |app|
64
+ PermissionForge::Audit.install!(app)
65
+ PermissionForge::Audit::JobContext.install!
66
+ end
67
+
68
+ # Level 2 — install the unscoped-query detection subscriber once config is
69
+ # final. The per-event check re-reads config, so the subscriber is only
70
+ # installed when detection could ever be active (avoids a no-op subscriber
71
+ # on every query in production).
72
+ initializer 'permission_forge.unscoped_query_detection', after: :load_config_initializers do
73
+ PermissionForge::Services::UnscopedQueryDetector.install! if PermissionForge.configuration.unscoped_query_detection != :off
74
+ end
75
+
76
+ # Auto-applied masking in every process (MaskingSync): a check at the
77
+ # start of each request and job, and a re-apply after a code reload.
78
+ # Installed whatever the settings say now: one may be switched on after
79
+ # the boot step (check! costs nothing while both are off).
80
+ initializer 'permission_forge.masking_sync' do |app|
81
+ app.executor.to_run { PermissionForge::MaskingSync.check! }
82
+ end
83
+
84
+ config.to_prepare { PermissionForge::MaskingSync.reloaded! }
85
+
86
+ config.after_initialize do
87
+ PermissionForge::BootChecks.run(PermissionForge.configuration)
88
+ end
89
+
90
+ # A setting a model reads as it loads (user_class, audit_user_class,
91
+ # tenant_model) changed by the host's own after_initialize, once a
92
+ # production boot has eager-loaded the models: the classes keep what they
93
+ # declared (a development process would pick the change up). Registered
94
+ # after config/initializers so it runs after the after_initialize blocks
95
+ # declared there; nothing loads a model.
96
+ initializer 'permission_forge.model_bindings', after: :load_config_initializers do |app|
97
+ app.config.after_initialize { PermissionForge::ModelBindings.warn! }
98
+ end
99
+
100
+ # Deprecated: the masking machinery left the Engine (0.14). Removed in
101
+ # PermissionForge::Deprecation::HORIZON.
102
+ #
103
+ # @return [Boolean] what MaskedModels.apply returns
104
+ def self.apply_masked_attributes_to_managed_models(only: nil)
105
+ PermissionForge::Deprecation.warn(
106
+ 'PermissionForge::Engine.apply_masked_attributes_to_managed_models is deprecated; ' \
107
+ 'use PermissionForge::MaskedModels.apply(only:)',
108
+ callstack: caller_locations(1) # the host's call, not this method
109
+ )
110
+ PermissionForge::MaskedModels.apply(only: only)
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # The one place the gem keeps state for the code running right now: a
5
+ # flag set inside a block, the record being read, the build in progress.
6
+ # Nothing else in lib/ or app/ touches `Thread.current`, `Fiber[]`,
7
+ # ActiveSupport::IsolatedExecutionState or a CurrentAttributes class
8
+ # (test/permission_forge/execution_state_test.rb fails when something
9
+ # does; its ALLOWED list names the exceptions and why).
10
+ #
11
+ # Every slot is listed in KEYS with what it holds, where it is kept and
12
+ # what the end of an executor run (a request, a job) does to it.
13
+ #
14
+ # Where (`scope`):
15
+ #
16
+ # - :fiber — the running fiber, whatever Rails' isolation level says
17
+ # (Thread#[], which is fiber-local). For every slot whose value,
18
+ # seen by code it was not set for, would widen access (a scope
19
+ # suspended, a mask lifted, a re-entrant build answered with {}) or
20
+ # drop audit events (an emission or a read in progress): another
21
+ # fiber of the thread — an Enumerator iterated with #next, a
22
+ # streaming template — never sees it, and a fiber left paused inside
23
+ # the block takes it with it.
24
+ # - :execution — ActiveSupport::IsolatedExecutionState: per thread
25
+ # under Rails' `:thread` isolation (the fibers of the thread share
26
+ # it, as they share ActiveRecord's connection and CurrentAttributes),
27
+ # per fiber under `:fiber`. For state that has to be seen wherever
28
+ # the request's code runs: the audit context, the open invalidation
29
+ # batch of the connection, and the cache failures (a failure reported
30
+ # in an Enumerator's fiber must still count for the Failure.none?
31
+ # around it).
32
+ #
33
+ # At the end of a run (`at_run_end`):
34
+ #
35
+ # - :clean — what the run added is removed, and nothing else: a slot
36
+ # that was empty when the run started is emptied, a collection that
37
+ # was there loses the entries the run added, and a slot that held a
38
+ # value is left alone. A request or job run starts empty, so there
39
+ # the slot is emptied, and nothing a request left set reaches the
40
+ # next one on the thread. Nothing is ever written back: a run may
41
+ # complete after the block it started in has ended
42
+ # (ActionDispatch::Executor completes it when the server closes the
43
+ # body, after the middlewares above it returned), and writing the
44
+ # block's value back then would leave it set for good. A block that
45
+ # wraps the whole run (a console or rake block around an inline job)
46
+ # keeps its value because it is still there.
47
+ # - :keep — outlives runs: a counter only ever compared before and
48
+ # after one call, or a slot whose block puts back what it found and
49
+ # which, emptied in the middle of the block, would fail open.
50
+ #
51
+ # A run that completes inside a block that started during the run — a
52
+ # `run!(reset: true)` completing an outer run (ActionDispatch::Executor
53
+ # does that for a run left open) — empties that block's slot for the
54
+ # rest of the block: it fails closed (scoping applies again).
55
+ #
56
+ # @api private
57
+ module ExecutionState
58
+ # One slot: its name, where it is kept (:fiber or :execution), what the
59
+ # end of an executor run does to it (:clean or :keep) and what it holds.
60
+ Key = Data.define(:name, :scope, :at_run_end, :holds)
61
+
62
+ SCOPES = %i[fiber execution].freeze
63
+ AT_RUN_END = %i[clean keep].freeze
64
+
65
+ KEYS = [
66
+ Key.new(:pf_skip_auto_scope, :fiber, :clean,
67
+ 'true inside PermissionForge.without_permission_scope (and the internal builds): Level 1 auto-scoping ' \
68
+ 'and Level 2 detection are suspended. Fiber: seen elsewhere it would lift scoping there'),
69
+ Key.new(:pf_internal_scope_suspension, :fiber, :clean,
70
+ 'true while the gem suspends Level 1 for its own queries (AutoPermissionScoping.internally_suspended: ' \
71
+ 'find_by / permission_scoped bodies, invalidation holders, cache clears, audit lookups): not reported as ' \
72
+ 'a scope_suspended bypass, and nobody signed in gets no find_by bypass. Same scope as pf_skip_auto_scope ' \
73
+ '(host_suspended? reads both). Fiber: seen elsewhere it would hide a bypass from the audit log'),
74
+ Key.new(:pf_in_auto_scope_hook, :fiber, :clean,
75
+ 'true while the Level 1 default scope builds the rules: a query made meanwhile (current_user_resolver, a ' \
76
+ 'permissions rebuild) runs unscoped instead of recursing. Fiber: seen elsewhere every query runs unscoped'),
77
+ Key.new(:pf_building_permission_keys, :fiber, :clean,
78
+ 'Hash of permission cache key => the read-through build (or request-context resolution) in progress; a ' \
79
+ 'read of a key being built gets the reentrant {}. Fiber: seen elsewhere a read gets {} (rules off)'),
80
+ Key.new(:pf_raw_store_columns, :fiber, :clean,
81
+ 'Hash (by identity) of record => the store column a store accessor is writing. Fiber: seen elsewhere ' \
82
+ 'the column reads unmasked'),
83
+ Key.new(:pf_validating_records, :fiber, :clean,
84
+ 'Set (by identity) of the records whose validators are running (MaskedAttributes::ValidationReads). ' \
85
+ 'Fiber: seen elsewhere a validation read there would read stored values'),
86
+ Key.new(:pf_validator_windows, :fiber, :clean,
87
+ 'Set (by identity) of the records a Rails EachValidator is judging: the attribute it read for the ' \
88
+ 'validation stays readable as stored until it is done (numericality reads it twice). Fiber: seen ' \
89
+ 'elsewhere that attribute reads unmasked'),
90
+ Key.new(:pf_validation_reads, :fiber, :clean,
91
+ 'Hash (by identity) of record => the attribute a validator is reading, which reads as stored. Fiber: ' \
92
+ 'seen elsewhere that column reads unmasked'),
93
+ Key.new(:pf_unmasked_virtual_records, :fiber, :clean,
94
+ 'Set (by identity) of the records inside pf_without_virtual_masking. Fiber: seen elsewhere the virtual ' \
95
+ 'readers read unmasked'),
96
+ Key.new(:pf_audit_emitting, :fiber, :clean,
97
+ 'true while a runtime audit event is built, stored or published (re-entrancy guard). Fiber: seen ' \
98
+ 'elsewhere no runtime event is emitted there'),
99
+ Key.new(:pf_audit_field_access, :fiber, :clean,
100
+ 'the Audit::FieldAccess::Frame of the record read in progress. Fiber: seen elsewhere its decisions go ' \
101
+ 'into a frame that is not theirs'),
102
+ Key.new(:pf_inspecting, :fiber, :clean,
103
+ 'true while inspect / pretty_print read a record: no field.access decision. Fiber: seen elsewhere ' \
104
+ 'field reads go unrecorded'),
105
+ Key.new(:pf_open_invalidation_batch, :execution, :clean,
106
+ 'the InvalidationBatch the next permission record written on its connection joins. Execution, like the ' \
107
+ 'connection: losing or sharing a batch costs repeated invalidations, never a missed one'),
108
+ Key.new(:pf_cache_failure_collectors, :execution, :keep,
109
+ 'stack of Cache::Failure::Collected, one per Cache::Failure.collect block. Execution: a failure ' \
110
+ 'reported in a fiber of the block is collected. Kept at the end of a run: each collect removes its ' \
111
+ 'own entry by identity, and emptying the stack inside a block would lose its failure (fails open)'),
112
+ Key.new(:pf_cache_failures, :execution, :keep,
113
+ 'Integer count of the cache failures reported (Cache::Failure.none? compares it before and after). ' \
114
+ 'Execution: a failure in a fiber of the block counts; never reset, so a none? around a run sees one in it'),
115
+ Key.new(:pf_store_errors, :execution, :keep,
116
+ 'Array of the errors a cache store handed its error handler during the store call in progress ' \
117
+ '(Cache::HandledErrors.during, .written). Execution: another fiber\'s error seen here counts against the call ' \
118
+ '(it is tried again). Kept at the end of a run: the block puts back what it found, and emptying the ' \
119
+ 'slot inside the call would lose the error (a failed write taken for done)'),
120
+ Key.new(:pf_audit_context, :execution, :clean,
121
+ 'the frozen Hash of the Audit::Context slots (actor, source, request id, IP, reason, correlation id)'),
122
+ Key.new(:pf_audit_transaction_correlation, :execution, :clean,
123
+ '[transaction, id] of the correlation id minted for the outermost open transaction'),
124
+ Key.new(:pf_audit_runtime_once, :execution, :clean,
125
+ 'Hash of the runtime audit events published once per request (Audit::Runtime.once?)')
126
+ ].to_h { |key| [key.name, key] }.freeze
127
+
128
+ CLEANED = KEYS.values.select { |key| key.at_run_end == :clean }.map(&:name).freeze
129
+
130
+ # The :fiber store: Thread#[] is fiber-local.
131
+ module FiberStorage
132
+ class << self
133
+ def [](name)
134
+ Thread.current[name]
135
+ end
136
+
137
+ def []=(name, value)
138
+ Thread.current[name] = value
139
+ end
140
+
141
+ # Ruby drops a fiber-local set to nil.
142
+ def delete(name)
143
+ locals = Thread.current
144
+ value = locals[name]
145
+ locals[name] = nil unless value.nil?
146
+ value
147
+ end
148
+ end
149
+ end
150
+
151
+ STORAGE = KEYS.transform_values { |key| key.scope == :fiber ? FiberStorage : ActiveSupport::IsolatedExecutionState }.freeze
152
+
153
+ # The :clean slots by scope: what the start and the end of a run walk.
154
+ CLEANED_FIBER = CLEANED.select { |name| KEYS[name].scope == :fiber }.freeze
155
+ CLEANED_EXECUTION = (CLEANED - CLEANED_FIBER).freeze
156
+
157
+ # The executor hook (Rails.application.executor.register_hook): notes
158
+ # what the :clean slots hold when a run starts, and removes what the run
159
+ # added when it completes.
160
+ module RunHook
161
+ # The fiber and the execution context the run started on, and what the
162
+ # :clean slots held then (ExecutionState.snapshot; nil for nothing).
163
+ Snapshot = Data.define(:fiber, :context, :values)
164
+
165
+ class << self
166
+ # @return [Snapshot]
167
+ def run
168
+ Snapshot.new(Fiber.current, ActiveSupport::IsolatedExecutionState.context, ExecutionState.snapshot)
169
+ end
170
+
171
+ # Only where the run started: its fiber (:fiber slots) or its
172
+ # execution context (:execution slots).
173
+ #
174
+ # @param snapshot [Snapshot]
175
+ # @return [void]
176
+ def complete(snapshot)
177
+ ExecutionState.run_completed(snapshot.values, fiber: snapshot.fiber.equal?(Fiber.current),
178
+ execution: snapshot.context.equal?(ActiveSupport::IsolatedExecutionState.context))
179
+ end
180
+ end
181
+ end
182
+
183
+ class << self
184
+ # @param name [Symbol] a key of KEYS
185
+ # @return [Object, nil]
186
+ def [](name)
187
+ storage(name)[name]
188
+ end
189
+
190
+ # @param name [Symbol] a key of KEYS
191
+ # @return [Object] the value
192
+ def []=(name, value)
193
+ storage(name)[name] = value
194
+ end
195
+
196
+ # The slot's value; when it holds nothing, the block's value, stored.
197
+ #
198
+ # ExecutionState.fetch(:pf_unmasked_virtual_records) { Set.new.compare_by_identity }
199
+ #
200
+ # @param name [Symbol] a key of KEYS
201
+ # @return [Object]
202
+ def fetch(name)
203
+ value = self[name]
204
+ value.nil? ? (self[name] = yield) : value
205
+ end
206
+
207
+ # @param name [Symbol] a key of KEYS
208
+ # @return [Object, nil] what the slot held
209
+ def delete(name)
210
+ store = storage(name)
211
+ store.delete(name)
212
+ end
213
+
214
+ # Sets the slot for the block and puts back what it held when the
215
+ # block ends, however it ends (a slot that held nil is emptied).
216
+ #
217
+ # @param name [Symbol] a key of KEYS
218
+ # @return the block's value
219
+ def with(name, value)
220
+ store = storage(name)
221
+ previous = store[name]
222
+ store[name] = value
223
+ begin
224
+ yield
225
+ ensure
226
+ previous.nil? ? store.delete(name) : store[name] = previous
227
+ end
228
+ end
229
+
230
+ # Empties every slot of this fiber and execution context: tests.
231
+ #
232
+ # @return [void]
233
+ def reset!
234
+ KEYS.each_key { |name| delete(name) }
235
+ end
236
+
237
+ # Registers RunHook with the application's executor (every request and
238
+ # job). Called once by Audit.install!.
239
+ #
240
+ # @param app [Rails::Application]
241
+ # @return [void]
242
+ def install!(app)
243
+ app.executor.register_hook(RunHook)
244
+ end
245
+
246
+ # What the :clean slots hold now (RunHook): nil when they are all
247
+ # empty (a request or a job), else name => [value, copy of its entries
248
+ # when it is a collection changed in place].
249
+ #
250
+ # @return [Hash{Symbol => Array(Object, Object)}, nil]
251
+ def snapshot
252
+ values = nil
253
+ locals = Thread.current
254
+ CLEANED_FIBER.each { |name| values = noted(values, name, locals[name]) }
255
+ CLEANED_EXECUTION.each { |name| values = noted(values, name, ActiveSupport::IsolatedExecutionState[name]) }
256
+ values
257
+ end
258
+
259
+ # The end of an executor run (RunHook): removes from the :clean slots of
260
+ # the scopes the run ends in what the run added, and never writes a
261
+ # value. A slot empty at the start (`values` from #snapshot) is
262
+ # emptied; from a collection there at the start, the entries the run
263
+ # added are dropped; any other slot is left as it is.
264
+ #
265
+ # @param values [Hash, nil] from #snapshot
266
+ # @return [void]
267
+ def run_completed(values, fiber:, execution:)
268
+ clean_all(CLEANED_FIBER, FiberStorage, values) if fiber
269
+ clean_all(CLEANED_EXECUTION, ActiveSupport::IsolatedExecutionState, values) if execution
270
+ end
271
+
272
+ private
273
+
274
+ def noted(values, name, value)
275
+ return values if value.nil?
276
+
277
+ (values || {}).merge!(name => [value, collection?(value) ? value.dup : nil])
278
+ end
279
+
280
+ def clean_all(names, store, values)
281
+ return names.each { |name| store.delete(name) } unless values
282
+
283
+ names.each { |name| (before = values[name]) ? clean(store, name, before) : store.delete(name) }
284
+ end
285
+
286
+ # A slot that held `value` when the run started: only a collection
287
+ # still in it loses what the run added.
288
+ def clean(store, name, before)
289
+ value, entries = before
290
+ return unless entries
291
+
292
+ current = store[name]
293
+ drop_added(current, entries) if current.equal?(value)
294
+ end
295
+
296
+ # Drops the entries of `collection` that `before` (its copy from the
297
+ # start of the run) does not have.
298
+ def drop_added(collection, before)
299
+ case collection
300
+ when Hash then collection.select! { |key, _| before.key?(key) }
301
+ when Set then collection.select! { |entry| before.include?(entry) }
302
+ else collection.select! { |entry| before.any? { |kept| kept.equal?(entry) } }
303
+ end
304
+ end
305
+
306
+ def storage(name)
307
+ STORAGE.fetch(name) do
308
+ raise ArgumentError, "unknown execution state key #{name.inspect} (PermissionForge::ExecutionState::KEYS)"
309
+ end
310
+ end
311
+
312
+ # A collection changed in place (a frozen one is replaced, never changed).
313
+ def collection?(value)
314
+ (value.is_a?(Hash) || value.is_a?(Set) || value.is_a?(Array)) && !value.frozen?
315
+ end
316
+ end
317
+ end
318
+ end