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,337 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Concerns
5
+ # Enforces row-level permission rules on including models.
6
+ #
7
+ # When a current user is set via {PermissionForge.current_user}, `find_by` and
8
+ # `find_by!` apply the user's cached permission set rules before returning a
9
+ # record, and `permission_scoped` returns a rule-constrained relation you can
10
+ # chain (`.where`, `.first`, `.find`, `.count`, …). If view_all or modify_all
11
+ # is enabled for the resource, rules are bypassed entirely.
12
+ #
13
+ # Level 1 — ENFORCE (config.auto_query_scoping = :filter_only): a
14
+ # default_scope applies the current user's FILTER-type rules to every
15
+ # relation built on the model (`where`, `all`, `find`, `first`, `count`,
16
+ # `pluck`, association traversal). Join-type rules are NOT auto-applied and
17
+ # still require `permission_scoped`. Denied rows are silently filtered
18
+ # (`find` raises RecordNotFound); attributed denials (NotAuthorizedError
19
+ # with rule messages) remain the job of `find_by` / `permission_scoped`.
20
+ #
21
+ # With auto-scoping :off (default), `find` (by primary key), bare `where`,
22
+ # `first`, `all`, and association traversal are NOT auto-scoped — use
23
+ # `find_by` / `find_by!` for single-record lookups, or start from
24
+ # `Model.permission_scoped` to carry the rules through a query chain.
25
+ # Level 2 (config.unscoped_query_detection) can log or raise on such
26
+ # unscoped queries to help find and prevent them.
27
+ #
28
+ # Escape hatches: `PermissionForge.without_permission_scope { ... }`
29
+ # (block-scoped, all levels) and `self.permission_forge_auto_scope = false`
30
+ # (per-model opt-out of Level 1).
31
+ #
32
+ # Query building is delegated to {PermissionForge::Services::PermissionRuleApplier}.
33
+ #
34
+ # @example
35
+ # class Customer < ApplicationRecord
36
+ # include PermissionForge::Concerns::PermissionScopedFindable
37
+ # end
38
+ #
39
+ # Customer.find_by(id: 1) # rules applied; nil if denied/missing
40
+ # Customer.find_by!(id: 1) # rules applied; raises if denied/missing
41
+ # Customer.permission_scoped.where(active: true).order(:name) # rules carried through
42
+ #
43
+ # @api public
44
+ module PermissionScopedFindable
45
+ extend ActiveSupport::Concern
46
+
47
+ include AutoPermissionScoping
48
+
49
+ # SQL annotation marking a query as permission-scoped. Level 2 detection
50
+ # treats any protected-table statement carrying this marker as compliant.
51
+ SCOPED_ANNOTATION = 'pf_scoped'
52
+
53
+ class << self
54
+ # Names of models that include this concern. Level 2 detection resolves
55
+ # the protected table set from this registry.
56
+ #
57
+ # @return [Set<String>]
58
+ def protected_model_names
59
+ @protected_model_names ||= Set.new
60
+ end
61
+
62
+ # Table names for all protected models, resolved lazily so that
63
+ # table_name_prefix configuration and class reloading can't produce
64
+ # stale names.
65
+ #
66
+ # @return [Array<String>]
67
+ def protected_table_names
68
+ protected_model_names.filter_map do |model_name|
69
+ model_name.safe_constantize&.table_name
70
+ end
71
+ end
72
+ end
73
+
74
+ included do
75
+ PermissionScopedFindable.protected_model_names << name if name
76
+
77
+ # Per-model opt-out of Level 1 auto-scoping.
78
+ class_attribute :permission_forge_auto_scope, default: true, instance_accessor: false
79
+
80
+ # Level 1 — ENFORCE. Installed unconditionally at include time; every
81
+ # enable/off decision happens INSIDE the block at query time (config is
82
+ # read per relation build), so initializer-vs-model load order can never
83
+ # leave a model silently unscoped. With auto_query_scoping :off the
84
+ # block early-returns after two cheap reads.
85
+ default_scope { pf_auto_permission_scope }
86
+
87
+ class << self
88
+ # `find_by` constrained by the current user's row-level rules.
89
+ # @return [ActiveRecord::Base, nil] nil for a missing record
90
+ # @raise [NotAuthorizedError] when a rule denies an existing record
91
+ def find_by(*args)
92
+ find_by_with_permission(*args)
93
+ end
94
+
95
+ # `find_by!` constrained by the current user's row-level rules.
96
+ # @raise [ActiveRecord::RecordNotFound] for a missing record
97
+ # @raise [NotAuthorizedError] when a rule denies an existing record
98
+ def find_by!(*args)
99
+ find_by_with_permission(*args) || raise(ActiveRecord::RecordNotFound)
100
+ end
101
+ end
102
+ end
103
+
104
+ # Raised when a row-level rule denies an existing record (find_by /
105
+ # find_by!) or there is no user under nil_user_strategy :raise.
106
+ #
107
+ # `user` is the user the rules were applied for, `record_id` the id of
108
+ # the record that was denied. `record` is nil when the gem raises: the
109
+ # row the user may not see is not loaded to be handed out with the
110
+ # error. All three are optional keywords; `NotAuthorizedError.new(details)`
111
+ # and `raise NotAuthorizedError, details` work as before.
112
+ #
113
+ # `details` keeps whatever was raised with (the gem raises the Array of
114
+ # rule failure messages; a Hash with :message for a missing user).
115
+ # `message`, `to_s` and `full_message` are always a String, as every
116
+ # error reporter and `rescue_from` handler expects: the failure
117
+ # messages joined, the :message of a Hash, or the class name.
118
+ #
119
+ # @api public
120
+ class NotAuthorizedError < StandardError
121
+ # Why the lookup was refused.
122
+ # @return [Hash, nil]
123
+ attr_reader :details, :user, :record, :record_id
124
+
125
+ NO_DETAILS = Object.new.freeze
126
+ READERS = %i[user record record_id].freeze
127
+ private_constant :NO_DETAILS, :READERS
128
+
129
+ # The String a details value reads as.
130
+ def self.text_for(details)
131
+ case details
132
+ when String then details
133
+ when Array then details.map { |item| text_for(item) }.join(', ')
134
+ when Hash then hash_text(details)
135
+ else details&.to_s
136
+ end
137
+ end
138
+
139
+ # The :message of a Hash, else the Hash without the user, record and
140
+ # record_id keywords (a user's inspect must not land in an error
141
+ # tracker), else nothing.
142
+ def self.hash_text(details)
143
+ text = text_for(details[:message] || details['message'])
144
+ return text if text
145
+
146
+ rest = details.except(*READERS)
147
+ rest.to_s unless rest.empty?
148
+ end
149
+ private_class_method :hash_text
150
+
151
+ # Keywords other than user:, record: and record_id: are details, as
152
+ # they always were (`new(message: 'x')`); without a positional
153
+ # argument every keyword given stays in details too, so a host's
154
+ # `new(record: r, message: 'x')` reads as before.
155
+ def initialize(details = NO_DETAILS, **keywords)
156
+ @user, @record, @record_id = keywords.values_at(:user, :record, :record_id)
157
+ if details.equal?(NO_DETAILS)
158
+ details = keywords
159
+ elsif !(extra = keywords.except(*READERS)).empty?
160
+ raise ArgumentError, "unknown keyword(s): #{extra.keys.map(&:inspect).join(', ')}"
161
+ end
162
+ @details = details
163
+ super(self.class.text_for(details))
164
+ end
165
+ end
166
+
167
+ class_methods do
168
+ def find_by_with_permission(*args)
169
+ raise ArgumentError, 'wrong number of arguments (given 0, expected 1+)' if args.empty?
170
+
171
+ host_suspended = AutoPermissionScoping.host_suspended?
172
+ # The entire body runs with auto-scoping suspended: every internal
173
+ # probe (rule application, the deny_or_missing existence check, the
174
+ # final take) must see the un-auto-scoped relation — otherwise a
175
+ # rule-denied row would look "missing" under Level 1 and find_by!
176
+ # would raise RecordNotFound instead of NotAuthorizedError.
177
+ pf_internally_unscoped do
178
+ current_user = PermissionForge.current_user
179
+
180
+ next handle_nil_user(*args, host_suspended: host_suspended) unless current_user
181
+
182
+ rules = pf_applicable_rules(current_user, 'find_by')
183
+ next tenant_safe_find_by(*args) unless rules
184
+
185
+ query, failure_messages = PermissionForge::Services::PermissionRuleApplier.call(
186
+ model_class: self, rules: rules, args: args, current_user: current_user
187
+ )
188
+
189
+ next deny_or_missing(failure_messages, current_user, *args) if failure_messages.any?
190
+
191
+ query.where(*args).take
192
+ end
193
+ end
194
+
195
+ # Returns a relation constrained by the current user's row-level rules, so
196
+ # any further chaining (`.where`, `.first`, `.find`, `.count`) stays scoped.
197
+ # Honors nil_user_strategy: :bypass -> all, :deny -> none, :raise -> raises.
198
+ # view_all/modify_all and the no-rules case return the unconstrained relation.
199
+ #
200
+ # Built with auto-scoping suspended (rules are applied here explicitly —
201
+ # including join-type rules — so the Level 1 filter-only scope must not
202
+ # stack on top). The relation is resolved eagerly inside that window and
203
+ # annotated as scoped, so lazy execution later stays correct and Level 2
204
+ # detection recognizes it.
205
+ #
206
+ # @return [ActiveRecord::Relation]
207
+ def permission_scoped
208
+ host_suspended = AutoPermissionScoping.host_suspended?
209
+ pf_internally_unscoped do
210
+ build_permission_scope(host_suspended).annotate(SCOPED_ANNOTATION)
211
+ end
212
+ end
213
+
214
+ private
215
+
216
+ # The gem's own suspension of Level 1 (the queries find_by and
217
+ # permission_scoped make while they apply the rules themselves): not
218
+ # reported as a scope_suspended bypass, which is about the host's
219
+ # PermissionForge.without_permission_scope.
220
+ def pf_internally_unscoped(&)
221
+ AutoPermissionScoping.internally_suspended(&)
222
+ end
223
+
224
+ def build_permission_scope(host_suspended)
225
+ current_user = PermissionForge.current_user
226
+ return permission_scoped_without_user(host_suspended) unless current_user
227
+
228
+ rules = pf_applicable_rules(current_user, 'permission_scoped')
229
+ return all unless rules
230
+
231
+ PermissionForge::Services::PermissionRuleApplier.scope(
232
+ model_class: self, rules: rules, current_user: current_user
233
+ )
234
+ end
235
+
236
+ # Distinguishes "denied by a rule" from "no such record". find_by must honor
237
+ # its nil contract for a genuinely missing record (matching stock Rails) and
238
+ # only raise NotAuthorizedError when the record exists but rules deny it.
239
+ def deny_or_missing(failure_messages, current_user, *args)
240
+ relation = tenant_safe_relation.where(*args)
241
+ return nil unless relation.exists?
242
+
243
+ record_id = pf_denied_record_id(relation)
244
+ PermissionForge::Audit::Runtime.denied(
245
+ PermissionForge::Audit::Runtime::DENIED, reason: :rule, model: self, via: 'find_by',
246
+ user: current_user, record_id: record_id,
247
+ data: { 'messages' => failure_messages }
248
+ )
249
+ raise NotAuthorizedError.new(failure_messages, user: current_user, record_id: record_id)
250
+ end
251
+
252
+ # The id of the row the rules denied, for access.denied and the error:
253
+ # read past the select, order and distinct of the relation (as exists?
254
+ # reads it). Not read for a table without a single-column key, nor
255
+ # under a grouping default scope.
256
+ def pf_denied_record_id(relation)
257
+ key = primary_key
258
+ return unless key.is_a?(String) && relation.group_values.empty?
259
+
260
+ relation.except(:select, :distinct, :order).pick(key)
261
+ end
262
+
263
+ def permission_scoped_without_user(host_suspended)
264
+ return all if pf_suspended_by_host?(host_suspended, 'permission_scoped')
265
+ return none if pf_nil_user_strategy('permission_scoped') == :deny
266
+
267
+ Rails.logger&.warn('[PermissionForge] current_user is nil — permission_scoped returns unscoped relation')
268
+ all
269
+ end
270
+
271
+ def handle_nil_user(*args, host_suspended:)
272
+ return tenant_safe_find_by(*args) if pf_suspended_by_host?(host_suspended, 'find_by')
273
+ return nil if pf_nil_user_strategy('find_by') == :deny
274
+
275
+ Rails.logger&.warn('[PermissionForge] current_user is nil — bypassing row-level rules')
276
+ tenant_safe_find_by(*args)
277
+ end
278
+
279
+ # Inside the host's PermissionForge.without_permission_scope block,
280
+ # nobody signed in is trusted code that opted out in so many words: no
281
+ # rules, whatever nil_user_strategy says (that strategy catches code
282
+ # that forgot to set a user). Published as rule.bypassed,
283
+ # scope_suspended.
284
+ def pf_suspended_by_host?(host_suspended, via)
285
+ return false unless host_suspended
286
+
287
+ PermissionForge::Audit::Runtime.bypassed(:scope_suspended, model: self, via: via)
288
+ true
289
+ end
290
+
291
+ # nil_user_strategy, reported: :raise raises, a denial (access.denied)
292
+ # is :deny, anything else lets the query past the rules
293
+ # (rule.bypassed) and is :bypass.
294
+ def pf_nil_user_strategy(via)
295
+ strategy = PermissionForge.configuration.nil_user_strategy
296
+ unless %i[raise deny].include?(strategy)
297
+ PermissionForge::Audit::Runtime.bypassed(:nil_user, model: self, via: via)
298
+ return :bypass
299
+ end
300
+
301
+ PermissionForge::Audit::Runtime.denied(PermissionForge::Audit::Runtime::DENIED, reason: :nil_user, model: self, via: via)
302
+ return :deny if strategy == :deny
303
+
304
+ raise NotAuthorizedError, { message: I18n.t('permission_forge.errors.no_authenticated_user', default: 'No authenticated user') }
305
+ end
306
+
307
+ # The user's rules for this model — those of its own Resource entry
308
+ # and of its STI ancestors' (ResourceChain) — or nil when none apply:
309
+ # view_all or modify_all on every entry that has rules, or no rules —
310
+ # which is reported as rule.bypassed (and, for view_all / modify_all,
311
+ # to the legacy on_rule_bypass hook: Audit::Subscribers::LegacyHooks::RuleBypass).
312
+ def pf_applicable_rules(current_user, via)
313
+ rules, reason = PermissionForge::ResourceChain.rules(current_user, PermissionForge::ResourceChain.names(self))
314
+ return rules unless reason
315
+
316
+ PermissionForge::Audit::Runtime.bypassed(reason, model: self, via: via, user: current_user)
317
+ nil
318
+ end
319
+
320
+ # where(...).take, which is what a relation's find_by does: never a
321
+ # relation's find_by, which a Rails could route back through this
322
+ # class's find_by (the override) and round in circles.
323
+ def tenant_safe_find_by(*args)
324
+ tenant_safe_relation.where(*args).take
325
+ end
326
+
327
+ # where(nil) preserves the model's default_scope (e.g. a soft-delete
328
+ # `default_scope { where(deleted_at: nil) }`) AND acts_as_tenant's tenant
329
+ # scope — unlike `unscoped`, which drops ALL scopes and would resurrect
330
+ # soft-deleted / cross-tenant rows through PermissionForge's find_by.
331
+ def tenant_safe_relation
332
+ where(nil)
333
+ end
334
+ end
335
+ end
336
+ end
337
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Concerns
5
+ # Mixin for user models providing permission checks, cache access, and field masking.
6
+ #
7
+ # Include this concern in your User model to enable permission-aware behavior:
8
+ #
9
+ # class User < ApplicationRecord
10
+ # include PermissionForge::Concerns::PermissionedUser
11
+ # end
12
+ #
13
+ # This adds associations to permission sets/groups and provides methods to check
14
+ # CRUD access, custom actions, and field-level masking for any resource.
15
+ #
16
+ # Composed of 3 focused sub-concerns:
17
+ # - PermissionCacheAccessor — cache read/write, shared hash navigation
18
+ # - ResourcePermissionChecker — CRUD checks, generic can?, hash accessors
19
+ # - FieldMaskChecker — field-level mask checks
20
+ #
21
+ # @api public
22
+ module PermissionedUser
23
+ extend ActiveSupport::Concern
24
+
25
+ include PermissionCacheAccessor
26
+ include ResourcePermissionChecker
27
+ include FieldMaskChecker
28
+
29
+ # `dependent: :destroy` on the :through associations makes Rails
30
+ # destroy the JOIN rows (assignments, memberships) with their
31
+ # callbacks when a set or group is removed from the collection
32
+ # (`delete`, `ids=`, `=`) — an audit event and a cache invalidation
33
+ # each. It never destroys the Permission Set or group itself.
34
+ # AuditedCollection does the same for `clear` / `delete_all`.
35
+ included do
36
+ has_many :permission_set_users, class_name: 'PermissionForge::PermissionSetUser', foreign_key: :user_id,
37
+ dependent: :destroy, extend: AuditedCollection
38
+ has_many :permission_sets, through: :permission_set_users, class_name: 'PermissionForge::PermissionSet',
39
+ dependent: :destroy, extend: AuditedCollection
40
+ has_many :permission_set_group_users, class_name: 'PermissionForge::PermissionSetGroupUser',
41
+ foreign_key: :user_id, dependent: :destroy, extend: AuditedCollection
42
+ has_many :permission_set_groups, through: :permission_set_group_users,
43
+ class_name: 'PermissionForge::PermissionSetGroup',
44
+ dependent: :destroy, extend: AuditedCollection
45
+
46
+ # Declared after the associations, so it runs after their
47
+ # `dependent: :destroy`: those reach the ambient tenant's rows only.
48
+ before_destroy :pf_destroy_permission_rows_in_other_tenants
49
+ end
50
+
51
+ private
52
+
53
+ # The user's assignments and memberships in the other tenants go with
54
+ # the user too (a reused id must not inherit them): each through its
55
+ # callbacks — an audit event and a cache invalidation in its own
56
+ # tenant — with that tenant ambient, so its set or group is found.
57
+ # With no tenant ambient the associations above reached every row.
58
+ def pf_destroy_permission_rows_in_other_tenants
59
+ config = PermissionForge.configuration
60
+ return unless config.tenant_enabled? && defined?(::ActsAsTenant) && ::ActsAsTenant.current_tenant
61
+
62
+ [PermissionForge::PermissionSetUser, PermissionForge::PermissionSetGroupUser].each do |model|
63
+ model.unscoped.where(user_id: id).find_each do |row|
64
+ pf_destroy_in_own_tenant(row, row.public_send(config.tenant_association_name))
65
+ end
66
+ end
67
+ end
68
+
69
+ # A row without a tenant (older than tenancy, or an optional tenant
70
+ # association) is destroyed with no tenant ambient, not the caller's.
71
+ def pf_destroy_in_own_tenant(row, tenant)
72
+ return ::ActsAsTenant.without_tenant { row.destroy! } if tenant.nil?
73
+
74
+ PermissionForge.with_ambient_tenant(tenant) { row.destroy! }
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Concerns
5
+ # The permission hash a user instance keeps for itself, and what retires
6
+ # it (PermissionCacheAccessor#permissions_hash).
7
+ #
8
+ # A memo is good for one cache key and one
9
+ # PermissionForge.permissions_cache_version: any invalidation or rebuild
10
+ # in the process retires every memo, which then reads the store again.
11
+ #
12
+ # An instance that rebuilt or invalidated inside a transaction whose
13
+ # hashes are withheld from the cache (OpenTransaction#withhold?) is tied
14
+ # to that transaction:
15
+ # - what it built there is good while the transaction it was built in
16
+ # has not been rolled back and the outermost one is still open;
17
+ # - until the outermost one has ended it does not read the store at
18
+ # all — the entry there holds the rows as they were before the
19
+ # transaction. A memo that was retired, or dropped by a rolled-back
20
+ # savepoint, is built again from the transaction's rows.
21
+ # Once the transaction has ended, the instance reads the store like any
22
+ # other.
23
+ #
24
+ # The memo is one frozen object (Memo), stored and read with one instance
25
+ # variable: an instance read on several threads at once never pairs the
26
+ # key or version of one thread's memo with another thread's hash (for
27
+ # another tenant, or from before an invalidation). The transaction is
28
+ # also the tie that keeps the instance away from the store (a Memo
29
+ # without a hash when there is none). `served` is nil for a user without
30
+ # conditional permission sets, and otherwise what the hash was selected
31
+ # by (ConditionalSets::Served): the accessor checks it against the
32
+ # request context on every read (Memo#for_context).
33
+ # The memo marshals with the user (an OpenTransaction dumps as one that
34
+ # has finished).
35
+ #
36
+ # The version is per process: an instance kept across requests learns
37
+ # only of the invalidations made in its own process. And an invalidation
38
+ # that lands after a rebuild's stamp check, before the rebuilding
39
+ # instance memoizes, is seen by that instance only at the next
40
+ # invalidation or rebuild in the process (docs/architecture.md).
41
+ #
42
+ # @api private
43
+ module PermissionsMemo
44
+ # The cache key the hash is for, the
45
+ # PermissionForge.permissions_cache_version it is good for, the open
46
+ # transaction it was built in when it was withheld from the cache (or
47
+ # nil), the hash, what selected it (nil without conditional sets), and
48
+ # when the soonest assignment that went into it ends (epoch seconds;
49
+ # nil: none ends).
50
+ Memo = Data.define(:key, :version, :transaction, :permissions, :served, :expires_at) do
51
+ def initialize(expires_at: nil, **)
52
+ super
53
+ end
54
+
55
+ # Whether the memo still serves reads for `key`: it holds a hash, for
56
+ # that key, under the version as it is now, no assignment that went
57
+ # into it has ended, and — for a hash built inside a transaction —
58
+ # that transaction still stands and this thread works on its
59
+ # connection (OpenTransaction#visible?).
60
+ def serves?(key)
61
+ return false unless permissions && self.key == key && version == PermissionForge.permissions_cache_version
62
+ return false if expires_at && Time.now.to_i >= expires_at
63
+
64
+ transaction.nil? || transaction.visible?
65
+ end
66
+
67
+ # The memo as the request context (yielded for, only when the user
68
+ # holds conditional sets) selects it: this one, a copy that remembers
69
+ # the context when it selects the same variant, or nil when it
70
+ # selects another (ConditionalSets::Served#reselect).
71
+ #
72
+ # @return [Memo, nil]
73
+ def for_context
74
+ return self unless served
75
+
76
+ reselected = served.reselect(yield)
77
+ return unless reselected
78
+
79
+ reselected.equal?(served) ? self : with(served: reselected)
80
+ end
81
+ end
82
+
83
+ # No hash, no tie.
84
+ EMPTY_MEMO = Memo.new(key: nil, version: nil, transaction: nil, permissions: nil, served: nil, expires_at: nil)
85
+
86
+ # Drops the memo alongside AR's own attribute/association reload, so a
87
+ # reloaded record never serves a permissions snapshot older than its
88
+ # attributes. (Inside a transaction it is tied to, the next read builds
89
+ # the hash again.)
90
+ def reload(*)
91
+ pf_clear_permissions_memo
92
+ super
93
+ end
94
+
95
+ private
96
+
97
+ # The memo, while it is still the one for `key`, no invalidation or
98
+ # rebuild in this process has retired it, and — for a hash built inside
99
+ # a transaction — that transaction still stands and this thread works
100
+ # on its connection (Memo#serves?). The memo is read once: the fields
101
+ # of two memos are never mixed.
102
+ #
103
+ # @return [Memo, nil]
104
+ def pf_memoized_permissions(key)
105
+ memo = pf_kept_permissions_memo
106
+ memo if memo&.serves?(key)
107
+ end
108
+
109
+ # The memo this instance keeps — nil for anything else under the
110
+ # variable: ActiveRecord's 6.1 marshalling format dumps instance
111
+ # variables, so a user cached by an earlier version of the gem brings
112
+ # back an Array, which is no memo. The one reader of the variable.
113
+ #
114
+ # @return [Memo, nil]
115
+ def pf_kept_permissions_memo
116
+ memo = @_pf_permissions_memo
117
+ memo if memo.is_a?(Memo)
118
+ end
119
+
120
+ # A memo tied to a transaction still open on another connection stays:
121
+ # the thread in that transaction keeps what it built from its rows.
122
+ #
123
+ # @param memo [Memo]
124
+ # @return [void]
125
+ def pf_memoize_permissions(memo)
126
+ return if pf_kept_permissions_memo&.transaction&.held_elsewhere?(memo.transaction)
127
+
128
+ @_pf_permissions_memo = memo
129
+ end
130
+
131
+ # Drops the hash; the tie to a transaction stays (see
132
+ # pf_withholding_permissions?).
133
+ def pf_clear_permissions_memo
134
+ pf_withhold_permissions_in(pf_kept_permissions_memo&.transaction, keep: false)
135
+ end
136
+
137
+ # Ties the instance to a transaction in which it invalidated: from here
138
+ # on the store may hold what another request rebuilt from the rows as
139
+ # they still are before the commit. A memo it keeps is good only while
140
+ # that transaction stands.
141
+ def pf_withhold_permissions_in(transaction, keep: true)
142
+ memo = (pf_kept_permissions_memo if keep) || EMPTY_MEMO
143
+ @_pf_permissions_memo = (memo.with(transaction: transaction) if transaction || memo.permissions)
144
+ end
145
+
146
+ # Whether the instance has to stay away from the store: the transaction
147
+ # it is tied to has not ended, and this thread works in it (another
148
+ # connection reads committed rows, as the store holds). Once it has
149
+ # ended, memo and tie are dropped.
150
+ def pf_withholding_permissions?
151
+ tie = pf_kept_permissions_memo&.transaction
152
+ return false unless tie
153
+ return tie.own_connection? unless tie.finished?
154
+
155
+ @_pf_permissions_memo = nil
156
+ false
157
+ end
158
+ end
159
+ end
160
+ end