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,200 @@
1
+ module PermissionForge
2
+ # The permission cache backends (Base and its subclasses, config.cache_backend) and the
3
+ # machinery that invalidates and rebuilds cached permission hashes. The backends are public;
4
+ # each class says which it is (a public namespace does not make its children public).
5
+ #
6
+ # @api public
7
+ module Cache
8
+ # The interface a cache backend implements (config.cache_backend accepts a subclass):
9
+ # read/write/delete/clear, plus what the gem builds on them — #read_fresh, #expunge, the
10
+ # multi-key calls and the removal of permission entries by pattern (#clear_entries).
11
+ #
12
+ # @api public
13
+ class Base
14
+ # @return [String] put in front of every key, with a colon (config.cache_key_prefix)
15
+ attr_reader :prefix
16
+
17
+ def initialize(prefix: 'pf')
18
+ @prefix = prefix
19
+ end
20
+
21
+ def read(key)
22
+ raise NotImplementedError
23
+ end
24
+
25
+ # Reads `key` from the store itself. A store that keeps a copy of what a
26
+ # request has read (ActiveSupport's local cache) answers a repeated read
27
+ # from that copy — also a miss; a value another process may have written
28
+ # in the meantime has to be read past it. Backends without such a copy
29
+ # read as usual.
30
+ def read_fresh(key)
31
+ read(key)
32
+ end
33
+
34
+ # @return [Object] something truthy when the value was written; false or
35
+ # nil when the store reports that it was not (ActiveSupport stores
36
+ # answer that instead of raising when they are unreachable)
37
+ def write(key, value, ttl: nil)
38
+ raise NotImplementedError
39
+ end
40
+
41
+ # Whether every process has a store of its own (an in-memory store):
42
+ # clearing or writing it here reaches no other process.
43
+ #
44
+ # @return [Boolean]
45
+ def process_local?
46
+ false
47
+ end
48
+
49
+ # Writes an entry that never expires — not even under a default expiry
50
+ # the store applies to every entry (a Rails cache configured with
51
+ # expires_in). Here a write without a TTL, which is that already.
52
+ #
53
+ # @return [Object] as #write
54
+ def write_permanent(key, value)
55
+ write(key, value)
56
+ end
57
+
58
+ def delete(key)
59
+ raise NotImplementedError
60
+ end
61
+
62
+ # Removes `key` and says whether it is certainly gone. `delete` alone
63
+ # cannot say that for a store that swallows its errors: it answers false
64
+ # both for "there was nothing to delete" and for "the delete failed"
65
+ # (RailsCache#expunge deals with that). A backend that can only fail by
66
+ # raising is taken at its word unless its delete says false.
67
+ #
68
+ # @return [Boolean] false when the store could not confirm the removal
69
+ # rubocop:disable-next Naming/PredicateMethod -- removes the key; the answer is whether the store confirmed it
70
+ def expunge(key)
71
+ delete(key) != false
72
+ end
73
+
74
+ def clear
75
+ raise NotImplementedError
76
+ end
77
+
78
+ # Removes every permission entry of one key namespace — the base
79
+ # entries of every user and tenant, and their variants
80
+ # (UserKeys.entry_patterns) — where the store can match keys
81
+ # (#purge_entries), and says whether it did. Verified, not taken on
82
+ # trust: an entry of each key shape (UserKeys.canary_keys) is written
83
+ # before and read back after; a pattern that did not reach the store's
84
+ # keys (a store namespace the pattern does not know) leaves them, and
85
+ # the answer is false. False also when the backend cannot match keys,
86
+ # or the canaries could not be written: the caller enumerates the
87
+ # users instead (Services::PermissionCacheClearer).
88
+ #
89
+ # @param namespace [String] one of UserKeys.namespaces
90
+ # @return [Boolean]
91
+ def clear_entries(namespace)
92
+ canaries = UserKeys.canary_keys(namespace)
93
+ return false unless canaries.all? { |key| write(key, CANARY_ENTRY, ttl: CANARY_TTL) }
94
+ return false unless purge_entries(namespace)
95
+
96
+ canaries.none? { |key| read_fresh(key) }
97
+ ensure
98
+ canaries&.each { |key| delete(key) }
99
+ end
100
+
101
+ # What a canary entry holds, and how long it may live if left behind.
102
+ CANARY_ENTRY = { canary: true }.freeze
103
+ CANARY_TTL = 60
104
+
105
+ # Removes the entries matching UserKeys.entry_patterns in one call per
106
+ # pattern, where the backend can. Here it cannot.
107
+ #
108
+ # @return [Boolean] whether the backend could
109
+ # rubocop:disable-next Naming/PredicateMethod -- removes the entries; the answer is whether the store could
110
+ def purge_entries(_namespace)
111
+ false
112
+ end
113
+
114
+ # Writes several entries and says, per key, whether the store took it
115
+ # (#write). Here one call per key; a backend that can send them in one
116
+ # round trip overrides it (RailsCache). Every key is tried: one whose
117
+ # write raises answers false, and the error is given to the block.
118
+ #
119
+ # @param entries [Hash{String => Object}]
120
+ # @param ttl [Integer, nil] seconds, for every entry
121
+ # @yieldparam error [StandardError] what a write raised
122
+ # @return [Hash{String => Boolean}]
123
+ def write_many(entries, ttl: nil, &on_error)
124
+ entries.to_h { |key, value| [key, answer(on_error) { write(key, value, ttl: ttl) }] }
125
+ end
126
+
127
+ # Removes several keys and says, per key, whether it is certainly gone
128
+ # (#expunge). Here one call per key; a backend that can send them in
129
+ # one round trip overrides it (RailsCache). Every key is tried: one
130
+ # whose removal raises answers false, and the error is given to the
131
+ # block.
132
+ #
133
+ # @param keys [Array<String>]
134
+ # @yieldparam error [StandardError] what a removal raised
135
+ # @return [Hash{String => Boolean}]
136
+ def expunge_many(keys, &on_error)
137
+ keys.to_h { |key| [key, answer(on_error) { expunge(key) }] }
138
+ end
139
+
140
+ # Reads several keys from the store itself (#read_fresh) and answers,
141
+ # per key, the entry or nil. Here one read per key; a backend that can
142
+ # fetch them in one round trip overrides it (RailsCache). A key whose
143
+ # read raises answers nil.
144
+ #
145
+ # @param keys [Array<String>]
146
+ # @return [Hash{String => Object, nil}]
147
+ def read_many(keys)
148
+ keys.to_h { |key| [key, fresh_or_nil(key)] }
149
+ end
150
+
151
+ # Removes several keys without asking whether they are gone (#delete):
152
+ # for an invalidation that has failed already (its stamp was refused),
153
+ # where a confirmation would be paid for twice. Here one call per key;
154
+ # RailsCache sends them in one round trip where it can. Every key is
155
+ # tried; an error is given to the block.
156
+ #
157
+ # @param keys [Array<String>]
158
+ # @yieldparam error [StandardError] what a removal raised
159
+ # @return [void]
160
+ def delete_many(keys, &on_error)
161
+ keys.each { |key| answer(on_error) { delete(key) } }
162
+ nil
163
+ end
164
+
165
+ private
166
+
167
+ def fresh_or_nil(key)
168
+ read_fresh(key)
169
+ rescue StandardError
170
+ nil
171
+ end
172
+
173
+ # The store's answer as true or false; false, after the error is
174
+ # handed on, when the call raised.
175
+ def answer(on_error)
176
+ yield ? true : false
177
+ rescue StandardError => e
178
+ on_error&.call(e)
179
+ false
180
+ end
181
+
182
+ def prefixed_key(key)
183
+ "#{prefix}:#{key}"
184
+ end
185
+
186
+ # Stores hand back string keys (JSON) or the hash as written; the
187
+ # permission hash is read with symbol keys.
188
+ def deep_symbolize(obj)
189
+ case obj
190
+ when Hash
191
+ obj.to_h { |k, v| [k.to_sym, deep_symbolize(v)] }
192
+ when Array
193
+ obj.map { |v| deep_symbolize(v) }
194
+ else
195
+ obj
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Cache
5
+ # Many invalidations at once: what Invalidation does for one — a fresh
6
+ # stamp, then the keys, every answer checked, ATTEMPTS tries, what still
7
+ # fails reported (Failure) and owed (Backlog) — with one multi-key call
8
+ # per step (Base#write_many, Base#expunge_many) instead of one call per
9
+ # key. On a store that sends them together (Redis, Solid Cache) a slice
10
+ # of users costs two round trips instead of three to five per user.
11
+ #
12
+ # The order holds for every user: all the stamps of the call are written
13
+ # before any key is removed, so each user's stamp is there before that
14
+ # user's keys go (see Invalidation). The keys are removed even when the
15
+ # stamps could not be written — then plainly deleted, not confirmed
16
+ # (Invalidation#remove): that user has failed already, and on a store
17
+ # that is down a confirmation is one more failing call per key. An
18
+ # attempt only repeats the users the store did not confirm.
19
+ #
20
+ # Callers keep the size of one call bounded (HolderInvalidation::SLICE).
21
+ #
22
+ # @api private
23
+ class BulkInvalidation
24
+ def self.call(...) = new(...).call
25
+
26
+ # The ids of the users whose invalidation the store did not confirm.
27
+ # A call that raises as a whole is owed and reported user by user.
28
+ #
29
+ # @param targets [Array<Hash>] as for #initialize
30
+ # @return [Array]
31
+ def self.unconfirmed(targets)
32
+ call(targets).map { |target| target[:user_id] }
33
+ rescue StandardError => e
34
+ targets.each do |target|
35
+ Invalidation.owe(**target)
36
+ Failure.report(operation: :invalidate, **target.slice(:user_id, :keys), error: e)
37
+ end
38
+ targets.map { |target| target[:user_id] }
39
+ end
40
+
41
+ # @param targets [Array<Hash>] one per user, the arguments of an
42
+ # Invalidation: user_id:, stamp_key:, keys:
43
+ def initialize(targets, cache: PermissionForge.cache)
44
+ @targets = targets
45
+ @cache = cache
46
+ @error = nil
47
+ end
48
+
49
+ # @return [Array<Hash>] the targets the store did not confirm (empty
50
+ # when it confirmed every one)
51
+ def call
52
+ attempts = Invalidation.store_failing? ? 1 : Invalidation::ATTEMPTS
53
+ pending = @targets
54
+ attempts.times do
55
+ break if pending.empty?
56
+
57
+ pending = attempt(pending)
58
+ end
59
+ failed(pending, attempts) if pending.any?
60
+ # as Invalidation does user by user: once one is confirmed, the store
61
+ # answers, and what is owed (the failures just now included) is tried
62
+ succeeded if pending.size < @targets.size
63
+ pending
64
+ end
65
+
66
+ private
67
+
68
+ # @return [Array<Hash>] the targets still unconfirmed after this attempt
69
+ def attempt(targets)
70
+ stamped = answers { @cache.write_many(fresh_stamps(targets), ttl: PermissionForge.configuration.cache_ttl, &method(:keep_error)) }
71
+ confirming, failed = targets.partition { |target| stamped[target[:stamp_key]] }
72
+ # the keys are removed whatever became of the stamps
73
+ delete_keys(failed)
74
+ removed = expunge_keys(confirming)
75
+ targets.reject { |target| confirmed?(target, stamped, removed) }
76
+ end
77
+
78
+ # Users whose stamp was refused: deleted, no confirmation asked.
79
+ def delete_keys(targets)
80
+ answers { @cache.delete_many(keys_of(targets), &method(:keep_error)) } if targets.any?
81
+ end
82
+
83
+ # @return [Hash{String => Boolean}] whether each key is certainly gone
84
+ def expunge_keys(targets)
85
+ return {} if targets.empty?
86
+
87
+ answers { @cache.expunge_many(keys_of(targets), &method(:keep_error)) }
88
+ end
89
+
90
+ def keys_of(targets)
91
+ targets.flat_map { |target| target[:keys] }.uniq
92
+ end
93
+
94
+ # A backend whose multi-key call raises as a whole confirmed nothing.
95
+ def answers
96
+ yield
97
+ rescue StandardError => e
98
+ keep_error(e)
99
+ {}
100
+ end
101
+
102
+ # The user's stamp was written and every one of its keys removed.
103
+ def confirmed?(target, stamped, removed)
104
+ stamped[target[:stamp_key]] && target[:keys].all? { |key| removed[key] }
105
+ end
106
+
107
+ def fresh_stamps(targets)
108
+ targets.to_h { |target| [target[:stamp_key], SecureRandom.hex(8)] }
109
+ end
110
+
111
+ # The last error a store call raised, for the failure report.
112
+ def keep_error(error)
113
+ @error = error
114
+ end
115
+
116
+ # The store answers: what is owed from before can be made now.
117
+ def succeeded
118
+ Invalidation.store_answered!
119
+ Invalidation.retry_owed
120
+ end
121
+
122
+ def failed(targets, attempts)
123
+ Invalidation.store_failed!
124
+ targets.each do |target|
125
+ Invalidation.owe(**target)
126
+ Failure.report(operation: :invalidate, **target.slice(:user_id, :keys), error: @error, attempts: attempts)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Cache
5
+ # Makes a cache failure visible without raising: an invalidation the store
6
+ # did not confirm, an entry that could not be removed, or an error inside
7
+ # an after_commit invalidation. Raising there would make Rails skip the
8
+ # commit callbacks of the remaining records, so the failure is
9
+ #
10
+ # - logged at error level,
11
+ # - published as the `cache_failure.permission_forge` notification, and
12
+ # - handed to `config.on_cache_failure`.
13
+ #
14
+ # A subscriber or a hook that raises is logged and does not stop the other.
15
+ #
16
+ # What a failure means: a cached permission hash that should be gone may
17
+ # still be in the store, and stays there until `cache_ttl`.
18
+ #
19
+ # Code that has to say whether an invalidation went through asks here
20
+ # (.none?), not the method it called: a host app overrides
21
+ # User#invalidate_permissions, and what such an override returns is its
22
+ # own business.
23
+ #
24
+ # @api private
25
+ module Failure
26
+ # The failures reported inside a Failure.collect block.
27
+ #
28
+ # @api private
29
+ class Collected
30
+ # Payloads kept per collect. A collect abandoned in an Enumerator
31
+ # stays open (the end of a run keeps the open collects), so what it
32
+ # keeps is bounded; past the bound it confirms nobody (fails closed).
33
+ MAX_PAYLOADS = 1_000
34
+
35
+ def initialize
36
+ @payloads = []
37
+ @overflowed = false
38
+ end
39
+
40
+ def <<(payload)
41
+ @payloads.size < MAX_PAYLOADS ? @payloads << payload : @overflowed = true
42
+ self
43
+ end
44
+
45
+ # @return [Array<Hash>] the payloads, in order
46
+ def to_a
47
+ @payloads.dup
48
+ end
49
+
50
+ # Those of `user_ids` that no failure was about — none when one was
51
+ # about nobody in particular (it may be about any of them).
52
+ #
53
+ # @param user_ids [Array]
54
+ # @return [Array]
55
+ def confirmed(user_ids)
56
+ return [] if @overflowed || failed_user_ids.include?(nil)
57
+
58
+ user_ids.reject { |user_id| failed_user_ids.include?(user_id.to_s) }
59
+ end
60
+
61
+ private
62
+
63
+ # As Strings (a key may come back as either); nil for a failure
64
+ # about nobody in particular.
65
+ def failed_user_ids
66
+ @payloads.map { |payload| payload[:user_id]&.to_s }
67
+ end
68
+ end
69
+
70
+ EVENT = 'cache_failure.permission_forge'
71
+ # ExecutionState slots: how many failures were reported on this thread
72
+ # (fiber under Rails' fiber isolation), and the open collect blocks.
73
+ COUNT_KEY = :pf_cache_failures
74
+ COLLECT_KEY = :pf_cache_failure_collectors
75
+ # Open collects kept per thread: more can only be abandoned ones (an
76
+ # Enumerator paused inside a collect), so the oldest go first.
77
+ MAX_OPEN_COLLECTS = 64
78
+
79
+ class << self
80
+ # Runs the block and says whether it got through without a failure
81
+ # being reported on this thread: every invalidation made inside it was
82
+ # confirmed by the store.
83
+ #
84
+ # @return [Boolean]
85
+ def none?
86
+ before = count
87
+ yield
88
+ count == before
89
+ end
90
+
91
+ # Runs the block and returns what was reported on this thread inside it.
92
+ # The open collects are one list per thread (fiber under fiber
93
+ # isolation): each one takes out its own Collected when it ends, so
94
+ # collects that interleave (one paused in an Enumerator) never take
95
+ # each other's.
96
+ #
97
+ # @return [Collected]
98
+ def collect
99
+ collected = Collected.new
100
+ open = ExecutionState.fetch(COLLECT_KEY) { [] }
101
+ open.shift while open.size >= MAX_OPEN_COLLECTS
102
+ open.push(collected)
103
+ yield
104
+ collected
105
+ ensure
106
+ ExecutionState[COLLECT_KEY]&.delete_if { |open| open.equal?(collected) }
107
+ end
108
+
109
+ # @param operation [Symbol] :invalidate, :discard or :after_commit
110
+ # @param details [Hash] user_id: whose cached permissions are concerned,
111
+ # keys: cache keys that may still hold an entry, error: what the store
112
+ # (or the callback) raised — and anything else worth knowing
113
+ # (record:, attempts:)
114
+ # @return [void]
115
+ def report(operation:, **details)
116
+ payload = { operation: operation, user_id: nil, keys: [], error: nil, **details }
117
+
118
+ ExecutionState[COUNT_KEY] = count + 1
119
+ ExecutionState[COLLECT_KEY]&.each { |collected| collected << payload }
120
+ log(payload)
121
+ notify(payload)
122
+ call_hook(payload)
123
+ end
124
+
125
+ private
126
+
127
+ def count
128
+ ExecutionState[COUNT_KEY] || 0
129
+ end
130
+
131
+ def log(payload)
132
+ return unless defined?(Rails) && Rails.logger
133
+
134
+ Rails.logger.error("[PermissionForge] #{message(payload)} — cached permissions may be out of date until cache_ttl")
135
+ end
136
+
137
+ def message(payload)
138
+ cause = payload[:error]
139
+ parts = ["cache #{payload[:operation]} failed"]
140
+ parts << "for user #{payload[:user_id]}" if payload[:user_id]
141
+ parts << "(keys: #{payload[:keys].join(', ')})" if payload[:keys].any?
142
+ parts << "— #{cause.class}: #{cause.message}" if cause
143
+ parts.join(' ')
144
+ end
145
+
146
+ # A subscriber that raises would abort the commit callbacks this is
147
+ # reported from, like a hook that raises.
148
+ def notify(payload)
149
+ ActiveSupport::Notifications.instrument(EVENT, payload)
150
+ rescue StandardError => e
151
+ log_error('subscriber', e)
152
+ end
153
+
154
+ def call_hook(payload)
155
+ hook = PermissionForge.configuration.on_cache_failure
156
+ return unless hook
157
+
158
+ hook.call(**payload)
159
+ rescue StandardError => e
160
+ log_error('on_cache_failure hook', e)
161
+ end
162
+
163
+ def log_error(source, error)
164
+ return unless defined?(Rails) && Rails.logger
165
+
166
+ Rails.logger.error("[PermissionForge] #{EVENT} #{source} error: #{error.class}: #{error.message}")
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Cache
5
+ # The errors a store rescued during a call and handed to its error
6
+ # handler. Rails' Redis store rescues a Redis that cannot be reached,
7
+ # hands the error to its handler (the `error_handler:` option) and goes
8
+ # on as if nothing had been asked of it. Its answer to #write_multi is
9
+ # then no proof: Rails 7.1 to 8.1 answer nil, the store Rails 8.2 ships
10
+ # answers the same Hash whether the batch was written or not. The call
11
+ # of the handler is the one sign every version gives, so
12
+ # RailsCache#write_multi asks here whether there was one.
13
+ #
14
+ # The first time a store is watched, the handler it keeps (its
15
+ # @error_handler) is wrapped: the wrapper notes the error for the call
16
+ # being watched in this execution context, then calls the store's own
17
+ # handler (if it has one) with the same arguments, as the store would
18
+ # have. Outside a watched call it only hands the error on. A store
19
+ # without an @error_handler is not watched (nothing is noted).
20
+ #
21
+ # @api private
22
+ module HandledErrors
23
+ # ExecutionState slot: the errors noted for the watched call in progress.
24
+ KEY = :pf_store_errors
25
+ LOCK = Mutex.new
26
+ private_constant :LOCK
27
+
28
+ class << self
29
+ # Runs the block (a call of `store`) and returns its value with the
30
+ # errors the store handed its error handler meanwhile, in this
31
+ # execution context. An entry is nil when the handler was given no
32
+ # exception: a call is still noted.
33
+ #
34
+ # @return [Array(Object, Array<Exception, nil>)]
35
+ def during(store, &)
36
+ return [yield, []] unless store.instance_variable_defined?(:@error_handler)
37
+
38
+ wrap(store)
39
+ handled = []
40
+ [ExecutionState.with(KEY, handled, &), handled]
41
+ end
42
+
43
+ # Runs the block (a write to `store`) and says whether it was written:
44
+ # the store answered truthy and handed its error handler no error
45
+ # meanwhile (Rails 8.2's Redis store answers the same either way).
46
+ # Also returns the last error handed on, if any.
47
+ #
48
+ # @return [Array(Boolean, Exception, nil)]
49
+ def written(store, &)
50
+ answer, handled = during(store, &)
51
+ [answer ? handled.empty? : false, handled.compact.last]
52
+ end
53
+
54
+ private
55
+
56
+ # Puts a Handler in front of the store's own error handler, once.
57
+ def wrap(store)
58
+ return if store.instance_variable_get(:@error_handler).is_a?(Handler)
59
+
60
+ LOCK.synchronize do
61
+ handler = store.instance_variable_get(:@error_handler)
62
+ store.instance_variable_set(:@error_handler, Handler.new(handler)) unless handler.is_a?(Handler)
63
+ end
64
+ end
65
+ end
66
+
67
+ # Stands in for a store's error handler.
68
+ class Handler
69
+ def initialize(handler)
70
+ @handler = handler
71
+ end
72
+
73
+ # Notes the error for the watched call (if one is in progress), then
74
+ # hands everything to the store's own handler.
75
+ def call(*, **details, &)
76
+ ExecutionState[KEY]&.push(details[:exception])
77
+ @handler&.call(*, **details, &)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Cache
5
+ # Invalidates the cached hashes of the holders of a changed permission
6
+ # record by user id, in slices of SLICE users: the same stamps and keys
7
+ # as User#invalidate_permissions (Cache::UserKeys), made through
8
+ # BulkInvalidation — one multi-key write for the stamps and one for the
9
+ # keys per slice — without loading a single user. A change to a set with
10
+ # 10,000 holders cost 30,000 store calls and 20,000 loaded records in the
11
+ # saving request before.
12
+ #
13
+ # Only where nothing but those store calls would happen for a user
14
+ # (.by_id?). Otherwise the holders are loaded and invalidated one by one
15
+ # through the public User#invalidate_permissions, as before
16
+ # (Concerns::InvalidatesInBatch):
17
+ # - the user model overrides invalidate_permissions, or the key or
18
+ # stamp it is built from (PermissionCacheAccessor): the host's code
19
+ # has to run;
20
+ # - somebody listens for permissions.invalidated (or stores it): each
21
+ # event carries its user;
22
+ # - the hashes are withheld in an open transaction that reads from a
23
+ # snapshot (OpenTransaction.withholding): each invalidation is then
24
+ # settled after the commit by the user's own instance.
25
+ #
26
+ # The processes' memos are retired as invalidate_permissions retires
27
+ # them: the cache version is bumped once per slice. Nothing is read: the
28
+ # per-context variants of a user with conditional sets are left to their
29
+ # TTL, unreachable once the base entry is gone (Cache::UserKeys).
30
+ #
31
+ # @api private
32
+ class HolderInvalidation
33
+ SLICE = 500
34
+ EVENT = 'permissions.invalidated'
35
+
36
+ # The methods of PermissionCacheAccessor whose work this does in bulk.
37
+ OWN_METHODS = %i[invalidate_permissions permission_cache_key pf_invalidation_stamp_key].freeze
38
+
39
+ class << self
40
+ def call(...) = new(...).call
41
+
42
+ # Whether the holders can be invalidated by id, here and now.
43
+ #
44
+ # @param model [Class] the host's user model
45
+ # @return [Boolean]
46
+ def by_id?(model)
47
+ own_methods?(model) && !PermissionForge::Audit.instrument?(EVENT) && !PermissionForge::OpenTransaction.withholding
48
+ end
49
+
50
+ private
51
+
52
+ # The model and its subclasses (an STI `Admin < User` that overrides
53
+ # them would be loaded as an Admin by the per-user path). A subclass
54
+ # that is not loaded yet (development) is not seen.
55
+ def own_methods?(model)
56
+ [model, *model.descendants].all? do |klass|
57
+ OWN_METHODS.all? do |name|
58
+ (klass.method_defined?(name) || klass.private_method_defined?(name)) &&
59
+ klass.instance_method(name).owner == PermissionForge::Concerns::PermissionCacheAccessor
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ # @param user_ids [Array] the holders' ids (no duplicates)
66
+ # @param tenant_id [Object, nil] the changed record's tenant
67
+ def initialize(user_ids:, tenant_id:)
68
+ @user_ids = user_ids
69
+ @tenant_id = tenant_id
70
+ end
71
+
72
+ # @return [Array] the ids whose invalidation the store did not confirm
73
+ # (each reported through Cache::Failure, and owed)
74
+ def call
75
+ @user_ids.each_slice(SLICE).flat_map do |slice|
76
+ # a slice whose invalidation raised is owed and reported user by user, and the next slices still get theirs
77
+ BulkInvalidation.unconfirmed(UserKeys.invalidations(slice, tenant_id: @tenant_id))
78
+ ensure
79
+ # after the invalidation, whatever became of it, as invalidate_permissions does
80
+ PermissionForge.bump_permissions_cache_version!
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end