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,45 @@
1
+ module PermissionForge
2
+ module Concerns
3
+ # Field-level mask checks.
4
+ # Depends on PermissionCacheAccessor for pf_resource_name_permissions.
5
+ #
6
+ # @api public
7
+ module FieldMaskChecker
8
+ extend ActiveSupport::Concern
9
+
10
+ # @param resource [Symbol, String] resource name
11
+ # @param field_name [Symbol, String] field name
12
+ # @return [Boolean] true if the field should be masked
13
+ #
14
+ # Fail-closed for unconfigured fields: when the field has no entry in the
15
+ # permission hash, the answer follows `unconfigured_field_strategy` — a
16
+ # blocking or mask-all strategy reports the field as masked rather than
17
+ # defaulting to "not masked" (which would leak plaintext).
18
+ def masked_field?(resource, field_name)
19
+ field = field_permissions(resource, field_name)
20
+ return field[:mask_access] == true if field
21
+
22
+ %i[block mask_all].include?(PermissionForge.configuration.unconfigured_field_strategy)
23
+ end
24
+
25
+ # @param resource [Symbol, String] resource name
26
+ # @param field_name [Symbol, String] field name
27
+ # @return [String] mask strategy (e.g., 'full', 'email', 'last4')
28
+ def mask_strategy_for(resource, field_name)
29
+ field = field_permissions(resource, field_name)
30
+ field&.dig(:mask_strategy) || 'full'
31
+ end
32
+
33
+ # @param resource [Symbol, String] resource name
34
+ # @param field_name [Symbol, String] field name
35
+ # @return [Hash, nil] field permission hash with :read_access, :edit_access, :mask_access, :mask_strategy
36
+ def field_permissions(resource, field_name)
37
+ entry = pf_resource_name_permissions(resource)
38
+ return if entry.blank?
39
+
40
+ field_name = field_name.to_sym
41
+ entry.dig(:fields, field_name)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,12 @@
1
+ module PermissionForge
2
+ module Concerns
3
+ # @api private
4
+ module HasActiveScope
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ scope :active, -> { where(is_active: true) }
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,59 @@
1
+ module PermissionForge
2
+ module Concerns
3
+ # @api private
4
+ module HasAuditTrail
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ # class_name is config.audit_user_class as the model loads — after the
9
+ # host's config/initializers, since the models are autoloaded by the
10
+ # application (app/models). The callbacks are always registered but
11
+ # no-op at runtime when audit_trail_enabled is false — so
12
+ # config.audit_trail_enabled = false is a real toggle, not a
13
+ # load-time-frozen one.
14
+ belongs_to :created_by,
15
+ class_name: PermissionForge.configuration.audit_user_class,
16
+ optional: true
17
+
18
+ belongs_to :updated_by,
19
+ class_name: PermissionForge.configuration.audit_user_class,
20
+ optional: true
21
+
22
+ before_create :set_created_by
23
+ before_update :set_updated_by
24
+ end
25
+
26
+ private
27
+
28
+ # created_by is kept when given (a clone, an import); updated_by starts
29
+ # as whoever created the row.
30
+ def set_created_by
31
+ return unless PermissionForge.configuration.audit_trail_enabled
32
+
33
+ acting = audit_trail_user_id
34
+ self.created_by_id ||= acting
35
+ self.updated_by_id ||= acting || created_by_id
36
+ end
37
+
38
+ # Moves only to someone known: an edit by nobody (a job, a Basic-auth
39
+ # login, an API client) keeps the previous editor instead of blanking it.
40
+ def set_updated_by
41
+ return unless PermissionForge.configuration.audit_trail_enabled
42
+
43
+ acting = audit_trail_user_id
44
+ self.updated_by_id = acting if acting
45
+ end
46
+
47
+ # The id of the acting user: the audit actor (an explicit
48
+ # with_audit_actor block, the request's actor, else
49
+ # PermissionForge.current_user) when it was made from a record of
50
+ # config.audit_user_class — the class the two columns point at. An
51
+ # admin of another model, or an actor given as a Hash, stamps nothing:
52
+ # its id would name somebody else.
53
+ def audit_trail_user_id
54
+ actor = PermissionForge::Audit::Actor.current
55
+ actor.id if actor.user_of?(PermissionForge.configuration.audit_user_class.to_s.safe_constantize)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Concerns
5
+ # Provides `users_on_permission_set` required by {PermissionsRegenerator},
6
+ # and `user_ids_on_permission_set`, the ids of their users, plucked
7
+ # without loading a row (what the invalidation uses).
8
+ #
9
+ # Include this concern and declare the lookup strategy based on how the model
10
+ # relates to a permission set:
11
+ #
12
+ # permission_set_user_lookup :self # PermissionSet (has permission_set_users directly)
13
+ # permission_set_user_lookup :direct # Has permission_set_id column
14
+ # permission_set_user_lookup :via_resource # Has resource_id -> resource -> permission_set
15
+ #
16
+ # Inside after_commit the queries go through the commit's InvalidationBatch
17
+ # (InvalidatesInBatch), so the records of one Permission Set written in one
18
+ # transaction look its assignments up once. Anywhere else every call
19
+ # queries afresh. Every lookup follows the record's tenant, not the
20
+ # ambient one.
21
+ #
22
+ # @api private
23
+ module HasPermissionSetUsers
24
+ extend ActiveSupport::Concern
25
+ include InvalidatesInBatch
26
+
27
+ # Which Permission Sets the record's change affects, by strategy.
28
+ LOOKUPS = {
29
+ self: :self_permission_set_ids,
30
+ direct: :direct_permission_set_ids,
31
+ via_resource: :via_resource_permission_set_ids
32
+ }.freeze
33
+
34
+ included do
35
+ # a record moved to another set or resource: see #direct_permission_set_ids
36
+ after_save { %w[permission_set_id resource_id].each { |column| note_previous_value(column) } }
37
+ end
38
+
39
+ class_methods do
40
+ # @param strategy [:self, :direct, :via_resource]
41
+ def permission_set_user_lookup(strategy)
42
+ lookup = LOOKUPS.fetch(strategy) do
43
+ raise ArgumentError, "Unknown strategy: #{strategy}. Use :self, :direct, or :via_resource"
44
+ end
45
+
46
+ # on the class itself: these win over PermissionsRegenerator's defaults
47
+ define_method(:affected_permission_set_ids) { send(lookup) }
48
+ define_method(:users_on_permission_set) { assignments_of_affected_sets }
49
+ define_method(:user_ids_on_permission_set) { holder_ids_of_affected_sets }
50
+ private :affected_permission_set_ids, :users_on_permission_set, :user_ids_on_permission_set
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ # @return [Array<PermissionForge::PermissionSetUser>]
57
+ def assignments_of_affected_sets
58
+ affected_permission_set_ids.flat_map { |set_id| permission_set_assignments(set_id).to_a }
59
+ end
60
+
61
+ # @return [Array] the holders' user ids, without duplicates
62
+ def holder_ids_of_affected_sets
63
+ affected_permission_set_ids.flat_map { |set_id| permission_set_user_ids(set_id) }.uniq
64
+ end
65
+
66
+ def self_permission_set_ids
67
+ [id]
68
+ end
69
+
70
+ # A record moved to another Permission Set (or resource) changes what
71
+ # both grant, so the users of both are affected — every set it left
72
+ # since the last commit (InvalidatesInBatch#note_previous_value).
73
+ def direct_permission_set_ids
74
+ [permission_set_id, *previous_values('permission_set_id')].compact_blank.uniq.reject do |set_id|
75
+ shared_lookup(:inactive_permission_set, set_id) { permission_set_exists?(set_id, active: false) }
76
+ end
77
+ end
78
+
79
+ def via_resource_permission_set_ids
80
+ [resource_id, *previous_values('resource_id')].compact_blank.uniq.filter_map do |parent_id|
81
+ set_id = shared_lookup(:permission_set_of_resource, parent_id) do
82
+ in_record_tenant(PermissionForge::Resource).where(id: parent_id).pick(:permission_set_id)
83
+ end
84
+ next if set_id.blank?
85
+
86
+ set_id if shared_lookup(:active_permission_set, set_id) { permission_set_exists?(set_id, active: true) }
87
+ end
88
+ end
89
+
90
+ # The assignments of a Permission Set in this record's tenant (see
91
+ # InvalidatesInBatch#in_record_tenant).
92
+ def permission_set_assignments(set_id)
93
+ shared_lookup(:assignments, set_id) do
94
+ in_record_tenant(PermissionForge::PermissionSetUser).where(permission_set_id: set_id)
95
+ end
96
+ end
97
+
98
+ # Their user ids, plucked: a set may have thousands of holders, and
99
+ # nothing but the id is needed to invalidate them.
100
+ def permission_set_user_ids(set_id)
101
+ shared_lookup(:assignment_user_ids, set_id) { permission_set_assignments(set_id).distinct.pluck(:user_id) }
102
+ end
103
+
104
+ def permission_set_exists?(set_id, active:)
105
+ in_record_tenant(PermissionForge::PermissionSet).where(id: set_id, is_active: active).exists?
106
+ end
107
+
108
+ # The lookups depend on the record's tenant, so it is part of every key:
109
+ # records of two tenants in one commit do not share an answer.
110
+ def shared_lookup(*key, &)
111
+ invalidation_batch.fetch([permission_record_tenant_id, *key], &)
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Concerns
5
+ # Conditionally includes acts_as_tenant on PermissionForge models
6
+ # when `config.tenant_model` is set and the acts_as_tenant gem is available.
7
+ #
8
+ # All permission data is strictly scoped to the current tenant.
9
+ # Cross-tenant user sharing is supported: the same user can be assigned
10
+ # to permission sets in multiple tenants, each producing a separate cached hash.
11
+ #
12
+ # @example Enable in initializer
13
+ # config.tenant_model = 'Account' # enables multi-tenancy
14
+ # config.tenant_column = :account_id # optional, inferred from model
15
+ #
16
+ # @api private
17
+ module HasTenantScoping
18
+ extend ActiveSupport::Concern
19
+
20
+ class_methods do
21
+ # Declares the tenant association and default scope on the including
22
+ # model, on the configured column. Each model calls it as its class
23
+ # body loads — after the host's config/initializers (the models are
24
+ # autoloaded), and after its own macros, so acts_as_tenant's callbacks
25
+ # keep running after the model's own; a code reload declares it again.
26
+ # Idempotent: acts_as_tenant adds a default_scope on every call, so
27
+ # applying twice (the class body plus a host, or the test suite,
28
+ # calling it by hand) would filter the tenant twice.
29
+ def apply_tenant_scoping!
30
+ config = PermissionForge.configuration
31
+ return unless config.tenant_enabled? && defined?(ActsAsTenant)
32
+ return if tenant_scoping_applied?
33
+
34
+ acts_as_tenant config.tenant_association_name,
35
+ foreign_key: config.resolved_tenant_column,
36
+ class_name: config.tenant_model,
37
+ **tenant_scoping_options
38
+ @pf_tenant_model = config.tenant_model.to_s
39
+ end
40
+
41
+ # Extra acts_as_tenant options of the including model. A permission
42
+ # row needs a tenant whatever the host's belongs_to_required_by_default
43
+ # says (the models load after the host's initializers, where that
44
+ # setting may be anything); a model whose rows may belong to none (an
45
+ # audit event of a cross-tenant task) overrides this with
46
+ # `{ optional: true }`.
47
+ def tenant_scoping_options
48
+ { optional: false }
49
+ end
50
+
51
+ # The tenant model this class was scoped on as it loaded; nil while
52
+ # it is not scoped (ModelBindings compares it with the configuration).
53
+ #
54
+ # @return [String, nil]
55
+ def tenant_scoping_model
56
+ @pf_tenant_model if tenant_scoping_applied?
57
+ end
58
+
59
+ # acts_as_tenant defines scoped_by_tenant? on the class once applied.
60
+ def tenant_scoping_applied?
61
+ respond_to?(:scoped_by_tenant?)
62
+ end
63
+
64
+ # The rows of one tenant whatever tenant is ambient, together with the
65
+ # rows that have no tenant (written before tenancy was switched on).
66
+ # No tenant id means every row — and so does a table that has no
67
+ # tenant column (an install that predates it): its rows cannot be told
68
+ # apart, and a lookup that raised would invalidate nobody.
69
+ # Built without ActsAsTenant.with_tenant, which writes the tenant it
70
+ # resolved back into the request-level one.
71
+ #
72
+ # @param tenant_id [Object, nil]
73
+ # @return [ActiveRecord::Relation]
74
+ def of_tenant(tenant_id)
75
+ config = PermissionForge.configuration
76
+ return all unless config.tenant_enabled? && tenant_scoping_applied?
77
+
78
+ column = config.resolved_tenant_column
79
+ return unscoped if tenant_id.nil? || !column_names.include?(column.to_s)
80
+
81
+ unscoped.where(column => [tenant_id, nil])
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,24 @@
1
+ module PermissionForge
2
+ # The concerns a host includes in its own classes (`PermissionedUser`, `PermissionScopedFindable`,
3
+ # `MaskedAttributes`, `ControllerActionGuard`, ...); the ones the gem uses on its own models are `@api private`.
4
+ #
5
+ # @api public
6
+ module Concerns
7
+ # @api private
8
+ module HasUuid
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ before_create :generate_uuid, if: -> { respond_to?(:uuid) && uuid.blank? }
13
+
14
+ validates :uuid, uniqueness: true, allow_nil: true
15
+ end
16
+
17
+ private
18
+
19
+ def generate_uuid
20
+ self.uuid = PermissionForge.configuration.uuid_generator.call
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Concerns
5
+ # Runs a permission record's cache invalidation after commit, through the
6
+ # InvalidationBatch shared by the records written in the same transaction,
7
+ # so what one of them invalidated is not invalidated again by the next.
8
+ #
9
+ # The including concern or model says what to invalidate by overriding
10
+ # `invalidate_committed_caches` (calling super) and doing the work through
11
+ # `invalidation_batch`:
12
+ #
13
+ # def invalidate_committed_caches
14
+ # super
15
+ # invalidation_batch.once([:my_cache, id]) { MyCache.drop(id) }
16
+ # end
17
+ #
18
+ # The rule this concern keeps: a record's after_commit reuses a batch only
19
+ # when the record joined it for the write being reported.
20
+ # - Every write that can lead to after_commit sets the reference first:
21
+ # save and destroy join the open batch in a before callback (it runs
22
+ # before the row is written, whatever other callbacks do); touch, which
23
+ # has no before callback, drops the reference and so invalidates on its
24
+ # own.
25
+ # - Every such write also tells the batches of this process to forget
26
+ # what they remember (InvalidationBatch.note_write): it may be a write
27
+ # made by a callback between two after_commits of one commit.
28
+ # - after_commit takes the reference (it is used once) and claims the
29
+ # batch; a refused claim, or no reference, means a batch of its own.
30
+ # - Outside after_commit there is no batch to reuse: calling an
31
+ # invalidation method by hand always invalidates.
32
+ #
33
+ # Two more things hold for every invalidation made here:
34
+ # - It follows the RECORD's tenant, not the ambient one. A record of
35
+ # tenant A saved while tenant B (or none) is ambient still finds the
36
+ # assignments of tenant A (`in_record_tenant`), and their users
37
+ # whatever default scope the host's user model has.
38
+ # - Nothing is raised out of after_commit: Rails would skip the commit
39
+ # callbacks of the remaining records. A failure is reported through
40
+ # Cache::Failure instead.
41
+ #
42
+ # @api private
43
+ module InvalidatesInBatch
44
+ extend ActiveSupport::Concern
45
+
46
+ included do
47
+ before_save :join_invalidation_batch
48
+ before_destroy :join_invalidation_batch
49
+ after_commit :invalidate_after_commit, on: %i[create update destroy]
50
+ end
51
+
52
+ def touch(*, **)
53
+ PermissionForge::InvalidationBatch.note_write
54
+ @pf_joined_invalidation_batch = nil
55
+ super
56
+ end
57
+
58
+ private
59
+
60
+ def join_invalidation_batch
61
+ self.class.connection_pool.with_connection do |connection|
62
+ @pf_joined_invalidation_batch = PermissionForge::InvalidationBatch.join(connection)
63
+ end
64
+ end
65
+
66
+ def invalidate_after_commit
67
+ outer = @pf_invalidation_batch
68
+ joined = @pf_joined_invalidation_batch
69
+ @pf_joined_invalidation_batch = nil
70
+ @pf_invalidation_batch = joined&.claim
71
+ invalidate_committed_caches
72
+ rescue StandardError => e
73
+ PermissionForge::Cache::Failure.report(operation: :after_commit, error: e, record: "#{self.class.name}##{id}")
74
+ ensure
75
+ # nil, unless an invalidation wrote this very record again and its
76
+ # commit ran inside this one.
77
+ @pf_invalidation_batch = outer
78
+ end
79
+
80
+ # Leaves a note in the batch this record joined for the write in
81
+ # progress, next to the note the record keeps for itself (see
82
+ # InvalidationBatch#note).
83
+ def note_in_invalidation_batch(kind, value)
84
+ @pf_joined_invalidation_batch&.note(kind, value)
85
+ end
86
+
87
+ # A record moved by a save (another user, Permission Set or resource)
88
+ # changes what the previous one grants too. Call from after_save: the
89
+ # value `column` had before the save just made is noted when it
90
+ # changed — at every save, since after_commit only sees the last save
91
+ # of a transaction. Like InvalidatesManagedActions' note it is only ever
92
+ # consumed by a commit (#previous_values), never forgotten on rollback
93
+ # (a later save rolled back to a savepoint must not take an earlier
94
+ # move with it; an extra invalidation costs a rebuild), and it is also
95
+ # left in the batch, under the row it belongs to: of two instances of
96
+ # one row written in a transaction, Rails runs the commit callbacks of
97
+ # one.
98
+ def note_previous_value(column)
99
+ return unless has_attribute?(column) && saved_change_to_attribute?(column)
100
+
101
+ previous = attribute_before_last_save(column)
102
+ return if previous.nil?
103
+
104
+ ((@pf_previous_values ||= {})[column] ||= []) << previous
105
+ note_in_invalidation_batch(:"previous_#{column}", [invalidation_row, previous])
106
+ end
107
+
108
+ # The values `column` had before the saves this commit reports — this
109
+ # instance's, and those the batch holds for this row (another instance
110
+ # of it) — consumed: the next commit starts from nothing.
111
+ def previous_values(column)
112
+ own = @pf_previous_values&.delete(column) || []
113
+ row = invalidation_row
114
+ noted = invalidation_batch.noted(:"previous_#{column}").filter_map { |noted_row, value| value if noted_row == row }
115
+ (own + noted).uniq
116
+ end
117
+
118
+ # Which row a note in the batch belongs to.
119
+ def invalidation_row
120
+ [self.class.base_class.name, id]
121
+ end
122
+
123
+ # What to invalidate once the record's transaction has committed.
124
+ # Override, call super, and work through `invalidation_batch`.
125
+ def invalidate_committed_caches; end
126
+
127
+ # The batch of the commit being reported, inside after_commit; anywhere
128
+ # else a batch of its own each time, which remembers nothing.
129
+ def invalidation_batch
130
+ @pf_invalidation_batch || PermissionForge::InvalidationBatch.new
131
+ end
132
+
133
+ # Invalidates the cached hash of the users who hold the assignments —
134
+ # once per user and tenant in `batch`. By id, in slices, when nothing
135
+ # but the store calls of User#invalidate_permissions would happen
136
+ # (Cache::HolderInvalidation.by_id?); else through the public
137
+ # User#invalidate_permissions of each loaded user (host apps override
138
+ # it). A user whose invalidation raises is reported and counts as
139
+ # done: the others still get theirs, and the next record of the commit
140
+ # does not try again.
141
+ #
142
+ # @param user_ids [Array] the holders' user ids
143
+ def invalidate_assigned_users(user_ids, batch, tenant_id)
144
+ pending = user_ids.compact.uniq
145
+ pending.reject! { |user_id| batch.done?([:user, user_id, tenant_id]) }
146
+ return if pending.empty?
147
+
148
+ if PermissionForge::Cache::HolderInvalidation.by_id?(PermissionForge.configuration.user_model)
149
+ invalidate_user_ids(pending, batch, tenant_id)
150
+ else
151
+ assigned_users(pending).each do |user|
152
+ batch.once([:user, user.id, tenant_id]) { invalidate_user(user, tenant_id) }
153
+ end
154
+ end
155
+ end
156
+
157
+ # What the store did not confirm was reported and owed by
158
+ # HolderInvalidation, and counts as done like a user whose
159
+ # invalidate_permissions failed.
160
+ def invalidate_user_ids(user_ids, batch, tenant_id)
161
+ begin
162
+ PermissionForge::Cache::HolderInvalidation.call(user_ids: user_ids, tenant_id: tenant_id)
163
+ rescue StandardError => e
164
+ PermissionForge::Cache::Failure.report(operation: :invalidate, error: e, record: "#{self.class.name}##{id}")
165
+ end
166
+ user_ids.each { |user_id| batch.done!([:user, user_id, tenant_id]) }
167
+ end
168
+
169
+ def invalidate_user(user, tenant_id)
170
+ user.invalidate_permissions(tenant_id: tenant_id)
171
+ rescue StandardError => e
172
+ PermissionForge::Cache::Failure.report(operation: :invalidate, user_id: user.id, error: e)
173
+ end
174
+
175
+ # Loaded past the default scopes of the host's user model: an ambient
176
+ # tenant, a soft-delete or a permission scope there must not hide the
177
+ # holder of an assignment from an invalidation.
178
+ def assigned_users(user_ids)
179
+ return [] if user_ids.empty?
180
+
181
+ model = PermissionForge.configuration.user_model
182
+ PermissionForge::Concerns::AutoPermissionScoping.internally_suspended { model.unscoped.where(model.primary_key => user_ids).to_a }
183
+ end
184
+
185
+ # A relation over a permission model that follows this record's tenant
186
+ # instead of the ambient one (HasTenantScoping.of_tenant). The ambient
187
+ # default scope would find nothing for a record of tenant A saved under
188
+ # tenant B, and under no tenant it would also match another tenant's
189
+ # rows that merely share an id. A record without a tenant looks
190
+ # everywhere.
191
+ def in_record_tenant(model)
192
+ model.of_tenant(permission_record_tenant_id)
193
+ end
194
+
195
+ # Tenant id of the changed record, so invalidation reaches the
196
+ # tenant-scoped cache key even when no ambient tenant is set
197
+ # (e.g., callbacks fired from a background job).
198
+ def permission_record_tenant_id
199
+ config = PermissionForge.configuration
200
+ return nil unless config.tenant_enabled?
201
+
202
+ column = config.resolved_tenant_column
203
+ respond_to?(column) ? public_send(column) : nil
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Concerns
5
+ # Drops ControllerActions::ManagedIndex when a record that defines it
6
+ # changes: a row that counts being created, destroyed or renamed, or a row
7
+ # or Permission Set being switched on or off. Flag edits (CRUD toggles,
8
+ # is_permitted) leave the index alone — they change who is granted, not
9
+ # what is managed.
10
+ #
11
+ # The change is noted at each save and consumed at commit. after_commit on
12
+ # its own sees only the LAST save of a transaction: a row created (or
13
+ # switched on) and then saved again would look unchanged there, and the
14
+ # guard would keep treating its action as unmanaged.
15
+ #
16
+ # A note is only ever consumed by a commit, never forgotten: a later save
17
+ # rolled back to a savepoint must not take the note of an earlier save
18
+ # with it. After a rollback the note stays, and the record's next commit
19
+ # drops the index once more than needed — which costs one rebuild, where a
20
+ # missed drop lets requests through for as long as the index lives.
21
+ #
22
+ # The note is also left in the transaction's InvalidationBatch: of two
23
+ # instances of one row written in a transaction, Rails runs the commit
24
+ # callbacks of one, which may not be the one that changed what is managed.
25
+ #
26
+ # The index is dropped after commit through InvalidatesInBatch: once per
27
+ # transaction (and tenant), however many of its records changed what is
28
+ # managed.
29
+ #
30
+ # @api private
31
+ module InvalidatesManagedActions
32
+ extend ActiveSupport::Concern
33
+ include InvalidatesInBatch
34
+
35
+ NOTE = :managed_actions
36
+
37
+ included do
38
+ after_save :note_managed_actions_change
39
+ end
40
+
41
+ private
42
+
43
+ def note_managed_actions_change
44
+ return unless managed_actions_changed?
45
+
46
+ @pf_managed_actions_changed = true
47
+ note_in_invalidation_batch(NOTE, permission_record_tenant_id)
48
+ end
49
+
50
+ # The index first, and each of the two even when the other raises.
51
+ def invalidate_committed_caches
52
+ invalidate_managed_actions
53
+ ensure
54
+ super
55
+ end
56
+
57
+ def invalidate_managed_actions
58
+ batch = invalidation_batch
59
+ tenant_ids = batch.noted(NOTE)
60
+ tenant_ids |= [permission_record_tenant_id] if destroyed? ? is_active : @pf_managed_actions_changed
61
+
62
+ tenant_ids.each do |tenant_id|
63
+ batch.once([NOTE, tenant_id]) do
64
+ PermissionForge::ControllerActions::ManagedIndex.invalidate!(tenant_id: tenant_id)
65
+ end
66
+ end
67
+ @pf_managed_actions_changed = false
68
+ end
69
+
70
+ # Whether the save just made changed what is managed. For a Resource or
71
+ # ResourceAction: a new row that counts, a row switched on or off, or a
72
+ # rename of a row that counts. PermissionSet overrides this.
73
+ def managed_actions_changed?
74
+ return is_active if previously_new_record?
75
+
76
+ saved_change_to_is_active? || (is_active && saved_change_to_name?)
77
+ end
78
+ end
79
+ end
80
+ end