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,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Cache
5
+ # One invalidation of a user's cached permission hashes — or of the
6
+ # managed-actions index (ControllerActions::ManagedIndex): leaves a fresh
7
+ # stamp, then removes the keys — and checks what the store answers.
8
+ #
9
+ # The stamp is written BEFORE the keys are removed. A rebuild that read its
10
+ # rows before the commit is only dangerous when it stores its hash after
11
+ # the removal; it re-reads the stamp after storing, so with this order the
12
+ # new stamp is already there for it to see. Removing first would leave a
13
+ # gap: remove, the stale hash is stored, the rebuild sees the old stamp,
14
+ # and only then is the new stamp written — nothing would notice. (A rebuild
15
+ # that stores before the removal loses its entry to it; either way no
16
+ # pre-commit hash survives.) The keys are removed even when the stamp could
17
+ # not be written.
18
+ #
19
+ # A store that is unreachable does not raise: it answers false or nil. So every
20
+ # answer is checked, and an invalidation the store did not confirm is tried
21
+ # again, ATTEMPTS times in all. When it still fails it is reported
22
+ # (Cache::Failure) and not raised: the entry may then outlive the change
23
+ # until cache_ttl, and somebody has to know.
24
+ #
25
+ # A store that is down fails every call, and each one may wait for a
26
+ # timeout. After a failed invalidation the next ones therefore make a
27
+ # single attempt for RETRY_PAUSE seconds, which keeps a commit that
28
+ # invalidates many users from waiting three times as long for nothing.
29
+ #
30
+ # An invalidation that failed for good is still owed: it is put on the
31
+ # process's Backlog and made when the store answers again.
32
+ #
33
+ # @api private
34
+ class Invalidation
35
+ ATTEMPTS = 3
36
+ RETRY_PAUSE = 5
37
+
38
+ class << self
39
+ def call(...) = new(...).call
40
+
41
+ # @return [Boolean] whether a recent invalidation failed for good
42
+ def store_failing?
43
+ until_time = @failing_until
44
+ !until_time.nil? && now < until_time
45
+ end
46
+
47
+ def store_failed!
48
+ @failing_until = now + RETRY_PAUSE
49
+ end
50
+
51
+ def store_answered!
52
+ @failing_until = nil
53
+ end
54
+
55
+ # @return [Backlog] the invalidations this process still owes the store
56
+ def backlog
57
+ @backlog ||= Backlog.new
58
+ end
59
+
60
+ # Notes an invalidation the store did not confirm, to be made later.
61
+ #
62
+ # @return [Boolean] false when exactly this was owed already
63
+ def owe(user_id:, stamp_key:, keys:)
64
+ backlog.add(user_id: user_id, stamp_key: stamp_key, keys: keys)
65
+ end
66
+
67
+ # Whether an invalidation is still owed, after one more try to make
68
+ # the owed ones. Costs nothing while nothing is owed.
69
+ #
70
+ # @param stamp_key [String] the stamp key of the invalidation (one per
71
+ # user, one for the managed-actions index)
72
+ # @return [Boolean]
73
+ def owed?(stamp_key)
74
+ owed = backlog
75
+ return false if owed.empty?
76
+
77
+ retry_owed
78
+ owed.include?(stamp_key)
79
+ end
80
+
81
+ # Tries the owed invalidations once each. Not while the store has just
82
+ # failed one: an outage must not add a failing call to every request.
83
+ # And not from inside an open database transaction: a store that
84
+ # shares the connection (Solid Cache in the application's database)
85
+ # would take the removals back if the transaction rolled back.
86
+ #
87
+ # @return [void]
88
+ def retry_owed
89
+ owed = backlog
90
+ return if owed.empty? || store_failing? || PermissionForge::OpenTransaction.current
91
+
92
+ owed.try_again { |entry| new(**entry).try_again }
93
+ end
94
+
95
+ # For tests, and for a cache store that has been replaced: what was
96
+ # owed to the old one is not owed to the new one.
97
+ def reset!
98
+ store_answered!
99
+ backlog.clear
100
+ end
101
+
102
+ private
103
+
104
+ def now
105
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
106
+ end
107
+ end
108
+
109
+ # @param user_id [Object, nil] whose hashes are invalidated (for the
110
+ # report; nil for an entry that belongs to no user)
111
+ # @param stamp_key [String] where the user's invalidation stamp lives
112
+ # @param keys [Array<String>] the cache keys to remove
113
+ def initialize(user_id:, stamp_key:, keys:, cache: PermissionForge.cache)
114
+ @user_id = user_id
115
+ @stamp_key = stamp_key
116
+ @keys = keys
117
+ @cache = cache
118
+ @error = nil
119
+ end
120
+
121
+ # @return [Boolean] whether the store confirmed the stamp and every removal
122
+ def call
123
+ attempts = self.class.store_failing? ? 1 : ATTEMPTS
124
+ confirmed = attempts.times.any? { attempt }
125
+ confirmed ? succeeded : failed(attempts)
126
+ confirmed
127
+ end
128
+
129
+ # One more try at an invalidation that failed for good. Without the
130
+ # stamp nothing is removed this time: every key was tried when it failed.
131
+ #
132
+ # @return [Boolean] whether the store confirmed the stamp and every removal
133
+ def try_again
134
+ stamp && @keys.all? { |key| answered? { @cache.expunge(key) } }
135
+ end
136
+
137
+ private
138
+
139
+ def attempt
140
+ stamped = stamp
141
+ # every key is tried, whatever happened to the stamp and to the keys before it
142
+ removed = @keys.map { |key| answered? { remove(key, confirm: stamped) } }
143
+ stamped && removed.all?
144
+ end
145
+
146
+ def stamp
147
+ answered? { @cache.write(@stamp_key, SecureRandom.hex(8), ttl: PermissionForge.configuration.cache_ttl) }
148
+ end
149
+
150
+ # Without the stamp the attempt has failed already: the key is deleted
151
+ # all the same, but not paid for a second time to confirm it.
152
+ def remove(key, confirm:)
153
+ return @cache.expunge(key) if confirm
154
+
155
+ @cache.delete(key)
156
+ false
157
+ end
158
+
159
+ def answered?
160
+ yield
161
+ rescue StandardError => e
162
+ @error = e
163
+ false
164
+ end
165
+
166
+ # The store answers: what is owed from before can be made now.
167
+ def succeeded
168
+ self.class.store_answered!
169
+ self.class.retry_owed
170
+ end
171
+
172
+ def failed(attempts)
173
+ invalidation = self.class
174
+ invalidation.store_failed!
175
+ invalidation.owe(user_id: @user_id, stamp_key: @stamp_key, keys: @keys)
176
+ Failure.report(operation: :invalidate, user_id: @user_id, keys: @keys, error: @error, attempts: attempts)
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,77 @@
1
+ module PermissionForge
2
+ module Cache
3
+ # In-process hash-backed cache. Intended for development, tests, and
4
+ # single-process deployments. Thread-safe via a Mutex so concurrent Puma
5
+ # threads can't corrupt the store, but still PROCESS-LOCAL: in a multi-worker
6
+ # deployment each worker has its own copy, so prefer :rails_cache (Redis,
7
+ # Memcached, SolidCache) in production. The engine logs a warning at boot if
8
+ # this backend is used in Rails.env.production?.
9
+ #
10
+ # @api public
11
+ class MemoryCache < Base
12
+ def initialize(prefix: 'pf')
13
+ super
14
+ @store = {}
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ def process_local?
19
+ true
20
+ end
21
+
22
+ # The entry under `key` (a deep copy, keys symbolized); nil when there
23
+ # is none or it has expired.
24
+ #
25
+ # @return [Object, nil]
26
+ def read(key)
27
+ entry = @mutex.synchronize { @store[prefixed_key(key)] }
28
+ return nil if entry.nil?
29
+
30
+ if entry.is_a?(Hash) && entry[:__expires_at__]
31
+ if Time.now >= entry[:__expires_at__]
32
+ @mutex.synchronize { @store.delete(prefixed_key(key)) }
33
+ return nil
34
+ end
35
+ value = entry[:__value__]
36
+ else
37
+ value = entry
38
+ end
39
+
40
+ # deep_dup for deep copy — callers can't mutate the cached object.
41
+ deep_symbolize(value.deep_dup)
42
+ end
43
+
44
+ # Stores a deep copy of `value`, expiring after `ttl` seconds (never
45
+ # without one).
46
+ #
47
+ # @return [Object] the value
48
+ def write(key, value, ttl: nil)
49
+ # dup on write too, so a caller mutating the object after write can't
50
+ # corrupt the stored copy.
51
+ stored = value.deep_dup
52
+ entry = ttl ? { __value__: stored, __expires_at__: Time.now + ttl } : stored
53
+ @mutex.synchronize { @store[prefixed_key(key)] = entry }
54
+ value
55
+ end
56
+
57
+ # @return [Object, nil] the entry removed, nil when there was none
58
+ def delete(key)
59
+ @mutex.synchronize { @store.delete(prefixed_key(key)) }
60
+ end
61
+
62
+ # Removes every entry of this process's store.
63
+ #
64
+ # @return [void]
65
+ def clear
66
+ @mutex.synchronize { @store.clear }
67
+ end
68
+
69
+ # rubocop:disable-next Naming/PredicateMethod -- removes the entries; the answer is whether the store could
70
+ def purge_entries(namespace)
71
+ regexp, = UserKeys.entry_patterns(namespace, prefix: prefix)
72
+ @mutex.synchronize { @store.delete_if { |key, _entry| key.match?(regexp) } }
73
+ true
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,40 @@
1
+ module PermissionForge
2
+ module Cache
3
+ # A backend that stores nothing (config.cache_backend = :null): every read misses, so every
4
+ # permission check rebuilds its hash. For tests and debugging.
5
+ #
6
+ # @api public
7
+ class NullCache < Base
8
+ def initialize(prefix: 'pf')
9
+ super
10
+ end
11
+
12
+ # @return [nil] always a miss
13
+ def read(_key)
14
+ nil
15
+ end
16
+
17
+ # Nothing is kept, on purpose: that is not a failed write (false or nil
18
+ # would say so — see Base#write).
19
+ def write(_key, value, **)
20
+ value
21
+ end
22
+
23
+ # @return [nil] there is nothing to remove
24
+ def delete(_key)
25
+ nil
26
+ end
27
+
28
+ # @return [nil] there is nothing to remove
29
+ def clear
30
+ nil
31
+ end
32
+
33
+ # Nothing is kept: nothing is left either.
34
+ # rubocop:disable-next Naming/PredicateMethod -- removes the entries; the answer is whether the store could
35
+ def clear_entries(_namespace)
36
+ true
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,231 @@
1
+ module PermissionForge
2
+ module Cache
3
+ # Wraps any ActiveSupport::Cache::Store backend.
4
+ # Works with every cache store Rails supports out of the box:
5
+ # - :memory_store
6
+ # - :file_store
7
+ # - :mem_cache_store (Memcached)
8
+ # - :redis_cache_store (Redis)
9
+ # - :solid_cache_store (SolidCache / Rails 8)
10
+ # - :null_store
11
+ # - Any custom ActiveSupport::Cache::Store subclass
12
+ #
13
+ # By default, uses Rails.cache. You can also pass a specific store instance.
14
+ #
15
+ # @api public
16
+ class RailsCache < Base
17
+ # Seconds the empty entry written by #expunge lives.
18
+ EMPTY_ENTRY_TTL = 1
19
+
20
+ # @return [Hash{String => Object}] the entries found, by prefixed key, symbolized as #read does
21
+ def read_multi(keys)
22
+ cache_store.read_multi(*keys.map { |key| prefixed_key(key) }).transform_values { |value| deep_symbolize(value) }
23
+ end
24
+
25
+ # Whether the store writes many entries in one call of its own, rather
26
+ # than ActiveSupport's default loop (which ignores each write's answer).
27
+ # Anything that is not an ActiveSupport store (a wrapper) is written key
28
+ # by key through its #write.
29
+ def self.multi_write?(store)
30
+ store.is_a?(ActiveSupport::Cache::Store) &&
31
+ store.class.instance_method(:write_multi_entries).owner != ActiveSupport::Cache::Store
32
+ end
33
+
34
+ def initialize(prefix: 'pf', store: nil)
35
+ super(prefix: prefix)
36
+ @store = store
37
+ end
38
+
39
+ # The entry under `key` in the Rails store, keys symbolized; nil for a
40
+ # miss (also what a store that failed the read answers).
41
+ #
42
+ # @return [Object, nil]
43
+ def read(key)
44
+ value = cache_store.read(prefixed_key(key))
45
+ return nil if value.nil?
46
+
47
+ deep_symbolize(value)
48
+ end
49
+
50
+ # Redis, Memcached and Solid Cache stores (and others built on
51
+ # ActiveSupport's LocalCache strategy) remember every read of a request,
52
+ # misses included. A nested local cache starts empty, so the read inside
53
+ # it reaches the store; what the request remembers is left as it is.
54
+ def read_fresh(key)
55
+ store = cache_store
56
+ return read(key) unless store.respond_to?(:with_local_cache)
57
+
58
+ store.with_local_cache { read(key) }
59
+ end
60
+
61
+ # One round trip for all the keys (read_multi), past the request's
62
+ # local copies as #read_fresh reads: a nested local cache starts empty.
63
+ def read_many(keys)
64
+ return {} if keys.empty?
65
+
66
+ store = cache_store
67
+ found = store.respond_to?(:with_local_cache) ? store.with_local_cache { read_multi(keys) } : read_multi(keys)
68
+ keys.to_h { |key| [key, found[prefixed_key(key)]] }
69
+ end
70
+
71
+ # Writes `value` under `key`, expiring after `ttl` seconds (the store's
72
+ # default expiry without one).
73
+ #
74
+ # @return [Boolean] the store's answer: false when it did not take it
75
+ def write(key, value, ttl: nil)
76
+ options = {}
77
+ options[:expires_in] = ttl if ttl
78
+ cache_store.write(prefixed_key(key), value, **options)
79
+ end
80
+
81
+ # Rails' :memory_store lives in each process.
82
+ def process_local?
83
+ cache_store.is_a?(ActiveSupport::Cache::MemoryStore)
84
+ end
85
+
86
+ # An explicit `expires_in: nil` overrides the store's default expiry.
87
+ def write_permanent(key, value)
88
+ cache_store.write(prefixed_key(key), value, expires_in: nil)
89
+ end
90
+
91
+ # @return [Boolean] the store's answer: false when there was nothing
92
+ # to remove, or the delete failed (see #expunge)
93
+ def delete(key)
94
+ cache_store.delete(prefixed_key(key))
95
+ end
96
+
97
+ # A delete that reports true removed the entry. One that reports false
98
+ # either found nothing or failed (Redis, Memcached and Solid Cache stores
99
+ # rescue their errors), so the key is overwritten with an empty entry
100
+ # that expires at once: a write says plainly whether it succeeded, and a
101
+ # read of that entry is a miss like any other.
102
+ # rubocop:disable-next Naming/PredicateMethod -- removes the key; the answer is whether the store confirmed it
103
+ def expunge(key)
104
+ name = prefixed_key(key)
105
+ store = cache_store
106
+ return true if store.delete(name)
107
+
108
+ store.write(name, nil, expires_in: EMPTY_ENTRY_TTL) ? true : false
109
+ end
110
+
111
+ # One round trip for all the entries when the store sends them together
112
+ # (Redis pipelines them, Solid Cache writes them in one statement): its
113
+ # answer is then the answer for every key. Any other store (memory,
114
+ # file, Memcached, null) is written one key at a time through #write,
115
+ # as Base does.
116
+ def write_many(entries, ttl: nil, &)
117
+ return super unless multi_write?
118
+ return {} if entries.empty?
119
+
120
+ written = write_multi(entries, ttl, &)
121
+ entries.transform_values { written }
122
+ end
123
+
124
+ # Overwrites the keys with empty entries that expire at once (as
125
+ # #expunge does when a delete reports nothing), in one #write_many: a
126
+ # write says plainly whether it succeeded, where a multi-delete only
127
+ # counts what it removed. It also replaces the request's local copy of
128
+ # each entry, which ActiveSupport's LocalCache leaves in place for a
129
+ # multi-delete (it only hooks single deletes): a later read in the same
130
+ # request would still find the entry. A read of an empty entry is a
131
+ # miss like any other.
132
+ def expunge_many(keys, &)
133
+ return super unless multi_write?
134
+
135
+ write_many(keys.to_h { |key| [key, nil] }, ttl: EMPTY_ENTRY_TTL, &)
136
+ end
137
+
138
+ # Where the store writes many entries in one call, the keys are
139
+ # overwritten in that one call (#expunge_many — whose answer is not
140
+ # needed here); else one delete per key, as Base does.
141
+ def delete_many(keys, &)
142
+ return super unless multi_write?
143
+
144
+ expunge_many(keys, &)
145
+ nil
146
+ end
147
+
148
+ # Removes every entry under the prefix — where the store can match
149
+ # keys: Redis, memory and file stores. Solid Cache and Memcached stores
150
+ # have no delete_matched, so there this removes nothing and says so in
151
+ # the log (it answers false). On those, invalidate the users instead
152
+ # (User#invalidate_permissions), or change config.cache_key_prefix,
153
+ # which leaves the old entries to expire.
154
+ def clear
155
+ cache_store.delete_matched(prefix_matcher)
156
+ rescue NotImplementedError, ArgumentError
157
+ # Log loudly rather than silently no-op, so a "revoke everyone now"
158
+ # call isn't mistaken for success.
159
+ Rails.logger&.warn(
160
+ "[PermissionForge] cache clear removed nothing: #{cache_store.class} does not support " \
161
+ 'delete_matched (Solid Cache and Memcached stores do not). Invalidate the users instead ' \
162
+ '(User#invalidate_permissions), or change config.cache_key_prefix and let the old entries expire.'
163
+ )
164
+ false
165
+ end
166
+
167
+ # Through the store's delete_matched: a Redis store takes a glob (its
168
+ # only form; the stamps go with the bare keys), the memory and file
169
+ # stores a Regexp (exact; the store's own namespace is put in front by
170
+ # ActiveSupport). A store that has no delete_matched (Memcached, Solid
171
+ # Cache) or refuses the matcher answers false. Whether the removal
172
+ # reached the keys is checked by Base#clear_entries.
173
+ def purge_entries(namespace)
174
+ entry_matchers(namespace).each { |matcher| cache_store.delete_matched(matcher) }
175
+ true
176
+ rescue NotImplementedError, ArgumentError
177
+ false
178
+ end
179
+
180
+ private
181
+
182
+ # A Redis store takes globs (its only form); the memory and file stores a Regexp (exact).
183
+ def entry_matchers(namespace)
184
+ regexp, globs = UserKeys.entry_patterns(namespace, prefix: prefix)
185
+ redis_store? ? globs : [regexp]
186
+ end
187
+
188
+ # Every key under the prefix: a glob for Redis; for the memory and file
189
+ # stores a Regexp anchored with `^` (the anchor ActiveSupport keeps when
190
+ # it puts the store's own namespace in front). A String there would be
191
+ # read as an unanchored Regexp ("pf:*" matches any key containing "pf")
192
+ # and raises on a namespaced store.
193
+ def prefix_matcher
194
+ redis_store? ? "#{prefix}:*" : /^#{Regexp.escape(prefix)}:/
195
+ end
196
+
197
+ def redis_store?
198
+ cache_store.class.name.to_s.include?('Redis')
199
+ end
200
+
201
+ def cache_store
202
+ @store || rails_cache
203
+ end
204
+
205
+ def multi_write?
206
+ self.class.multi_write?(cache_store)
207
+ end
208
+
209
+ # Whether the store took all the entries (HandledErrors.written); the
210
+ # error it handed its error handler, or raised, is yielded.
211
+ def write_multi(entries, ttl)
212
+ options = ttl ? { expires_in: ttl } : {}
213
+ store = cache_store
214
+ written, error = HandledErrors.written(store) do
215
+ store.write_multi(entries.transform_keys { |key| prefixed_key(key) }, options)
216
+ end
217
+ yield error if error && block_given?
218
+ written
219
+ rescue StandardError => e
220
+ yield e if block_given?
221
+ false
222
+ end
223
+
224
+ def rails_cache
225
+ raise 'Rails.cache is not available. Pass a store: option or ensure Rails is loaded.' unless defined?(Rails) && Rails.respond_to?(:cache)
226
+
227
+ Rails.cache
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Cache
5
+ # Stores a freshly built permission hash — or managed-actions index
6
+ # (ControllerActions::ManagedIndex) — unless it may have been built across
7
+ # an invalidation, in which case it is not left in the shared cache.
8
+ #
9
+ # The race: a rebuild reads the permission rows, a commit invalidates the
10
+ # user, and only then does the rebuild store what it read. Every
11
+ # invalidation leaves a per-user stamp (Cache::Invalidation); a rebuild
12
+ # reads the stamp before it builds and again after it stored. Only when
13
+ # both reads return the same stamp is the entry kept:
14
+ #
15
+ # - the stamp changed: the entry is removed at once (the repeat may take
16
+ # long, or raise) and the hash is built once more, ATTEMPTS times in all;
17
+ # - there is no stamp: the rebuild writes one first and reads it back. A
18
+ # missing stamp and a stamp that could not be read look the same, so a
19
+ # rebuild never concludes anything from two empty reads;
20
+ # - the store cannot keep a stamp (it is down, or it stores nothing): the
21
+ # hash is built once and not stored;
22
+ # - a store call raises (a store without a failsafe): taken as the
23
+ # answer an ActiveSupport store gives instead — nothing read, nothing
24
+ # written — so the request still gets the hash it built;
25
+ # - anything raises along the way: the entry is removed.
26
+ #
27
+ # The caller gets the hash it built in every case. Whether the shared cache
28
+ # kept it is the second value.
29
+ #
30
+ # A hash that must not be shared at all — it was built inside a database
31
+ # transaction that reads from a snapshot (OpenTransaction.withholding) —
32
+ # is built without a single call to the store (#withhold).
33
+ #
34
+ # @api private
35
+ class Rebuild
36
+ # A rebuild that raced an invalidation is repeated once. Racing a second
37
+ # one is rare (two commits within two rebuilds), and repeating without a
38
+ # limit could keep a request busy for as long as records are being saved.
39
+ ATTEMPTS = 2
40
+
41
+ def self.call(...) = new(...).call
42
+
43
+ # @param key [String] the cache key of the hash
44
+ # @param stamp_key [String] where the user's invalidation stamp lives
45
+ # @param user_id [Object, nil] for the failure report
46
+ # @param build [#call] returns [hash, ttl]; reads the rows — past
47
+ # ActiveRecord's query cache, or a repeat reads what the first build read
48
+ def initialize(key:, stamp_key:, user_id:, build:, cache: PermissionForge.cache)
49
+ @key = key
50
+ @stamp_key = stamp_key
51
+ @user_id = user_id
52
+ @build = build
53
+ @cache = cache
54
+ end
55
+
56
+ # @yieldreturn [Boolean] when a block is given, called after the stamp
57
+ # was read and before the entry is written: false keeps the entry out
58
+ # of the store (a variant whose index entry was not written)
59
+ # @return [Array(Hash, Boolean)] the hash as built, and whether the shared
60
+ # cache kept it
61
+ def call(&before_write)
62
+ @before_write = before_write
63
+ hash = nil
64
+ ATTEMPTS.times do
65
+ hash, outcome = attempt(current_stamp)
66
+ return [hash, true] if outcome == :kept
67
+ break unless outcome == :overtaken
68
+ end
69
+ [hash, false]
70
+ end
71
+
72
+ # Builds and leaves the store alone: no read, no write, no removal. An
73
+ # entry that is there stays there — it holds committed rows, and
74
+ # whoever changes them invalidates it after the commit.
75
+ #
76
+ # @return [Hash] the hash as built
77
+ def withhold
78
+ @build.call.first
79
+ end
80
+
81
+ private
82
+
83
+ # The stamp to check against: the one in the store, or a new one when
84
+ # there is none — nil when the store does not give it back.
85
+ def current_stamp
86
+ stamp = answered { @cache.read_fresh(@stamp_key) }
87
+ return stamp if stamp
88
+
89
+ stamp = SecureRandom.hex(8)
90
+ written = answered { @cache.write(@stamp_key, stamp, ttl: PermissionForge.configuration.cache_ttl) }
91
+ stamp if written && answered { @cache.read_fresh(@stamp_key) } == stamp
92
+ end
93
+
94
+ # @return [Array(Hash, Symbol)] the hash and what became of it:
95
+ # :kept, :overtaken (an invalidation happened meanwhile — worth another
96
+ # attempt) or :not_stored (the store did not take it — repeating the
97
+ # build would change nothing)
98
+ def attempt(stamp)
99
+ hash, ttl = @build.call
100
+ stored = stamp && ready_to_write? && answered { @cache.write(@key, hash, ttl: ttl) }
101
+ outcome = stored ? verify(stamp) : :not_stored
102
+ [hash, outcome]
103
+ ensure
104
+ # Without a stamp nothing was written. With one, a write was sent:
105
+ # also one the store said it did not take may have been applied.
106
+ discard if stamp && outcome != :kept
107
+ end
108
+
109
+ # What must happen before the entry is written, after the stamp was
110
+ # read (the block given to #call): an invalidation from then on either
111
+ # finds what it wrote or makes the verification fail.
112
+ def ready_to_write?
113
+ @before_write.nil? || answered { @before_write.call } ? true : false
114
+ end
115
+
116
+ # A stamp that cannot be read back is not verified, and not worth a
117
+ # second build either.
118
+ def verify(stamp)
119
+ current = answered { @cache.read_fresh(@stamp_key) }
120
+ return :not_stored if current.nil?
121
+
122
+ current == stamp ? :kept : :overtaken
123
+ end
124
+
125
+ # The store's answer; nil when the call raised.
126
+ def answered
127
+ yield
128
+ rescue StandardError
129
+ nil
130
+ end
131
+
132
+ # An entry this rebuild wrote and could not verify must not stay. When
133
+ # the store does not confirm its removal either, the removal is owed
134
+ # like an invalidation that failed (Backlog), and somebody has to know —
135
+ # once: while it is owed, the rebuilds that follow fail the same way.
136
+ def discard
137
+ removed = begin
138
+ @cache.expunge(@key)
139
+ rescue StandardError => e
140
+ error = e
141
+ false
142
+ end
143
+ return if removed || !Invalidation.owe(user_id: @user_id, stamp_key: @stamp_key, keys: [@key])
144
+
145
+ Failure.report(operation: :discard, user_id: @user_id, keys: [@key], error: error)
146
+ end
147
+ end
148
+ end
149
+ end