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,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Concerns
5
+ # Which permission hash the request context selects for a user with
6
+ # conditional permission sets (PermissionForge::ConditionalSets): the
7
+ # base entry, or the variant of the conditional sets that pass — on a
8
+ # read (PermissionCacheAccessor#permissions_hash), on a rebuild
9
+ # (regenerate_permissions) — and how each entry, base or variant, is
10
+ # built. (What an invalidation removes is Cache::UserKeys; the variants
11
+ # go on erasure and cache:clear, Cache::VariantRemoval.) A user without
12
+ # conditional sets costs it nothing: one nil check.
13
+ #
14
+ # @api private
15
+ module PermissionsPerContext
16
+ # What a read produced: the hash, what selected it (nil without
17
+ # conditional sets), the open transaction it was built in (nil when it
18
+ # was read from the store, or built outside one), and how it came
19
+ # about — nil: read from the store; :base — regenerate_permissions
20
+ # built it, which bumped the version; :variant — a variant built
21
+ # read-through for a base entry read from the store, which did not;
22
+ # :reentrant — asked by the build itself, {} for that call alone. And
23
+ # when the soonest of the user's assignments that went into it ends
24
+ # (epoch seconds, nil for none: EXPIRY_KEY).
25
+ #
26
+ # @api private
27
+ Selected = Data.define(:permissions, :served, :transaction, :rebuilt, :expires_at) do
28
+ def initialize(expires_at: nil, **)
29
+ super
30
+ end
31
+
32
+ # What a memo holds, as a base rebuilt by regenerate_permissions.
33
+ def self.of_memo(memo)
34
+ new(permissions: memo.permissions, served: memo.served, transaction: nil, rebuilt: :base, expires_at: memo.expires_at)
35
+ end
36
+
37
+ # @return [PermissionsMemo::Memo] the memo of this hash, good for `version`
38
+ def memo(key, version)
39
+ PermissionsMemo::Memo.new(key: key, version: version, transaction: transaction, permissions: permissions, served: served,
40
+ expires_at: expires_at)
41
+ end
42
+ end
43
+
44
+ REENTRANT = Selected.new(permissions: {}.freeze, served: nil, transaction: nil, rebuilt: :reentrant)
45
+
46
+ # The fiber-local ExecutionState slot of the builds (and request-context
47
+ # resolutions) running on this fiber, by permission cache key.
48
+ BUILDING_KEY = :pf_building_permission_keys
49
+
50
+ # Where the stored base entry of a user with an assignment that ends
51
+ # (PermissionSetUser#expires_at) carries when the soonest one ends, in
52
+ # epoch seconds — as it carries the conditional sets: taken off on
53
+ # every read, never part of the hash. The memo of the hash is retired
54
+ # then (PermissionsMemo::Memo#serves?): an instance kept across
55
+ # requests stops granting what the assignment granted when it ends,
56
+ # as the store entry (whose TTL is clamped to it) does.
57
+ EXPIRY_KEY = :assignments_expire_at
58
+
59
+ private
60
+
61
+ # The memoized hash, while the request context still selects what it
62
+ # was built for. For a user without conditional sets: always. With
63
+ # them: when the context is the one that selected it (its signature,
64
+ # cheap), or another one that selects the same variant — remembered
65
+ # as such, so the next call is cheap again. nil when the context now
66
+ # selects another variant: the base entry is read again.
67
+ #
68
+ # @return [Hash, nil]
69
+ def pf_memoized_permissions_for_context(key, memo)
70
+ again = memo.for_context { pf_resolve_request_context(key) }
71
+ return unless again
72
+
73
+ pf_memoize_permissions(again) unless again.equal?(memo)
74
+ again.permissions
75
+ end
76
+
77
+ # What a base entry read from the store serves in the current request
78
+ # context: itself — or, when the context selects conditional sets, the
79
+ # variant for them, read from the store (when its slot holds the
80
+ # variant of this base entry and selection:
81
+ # ConditionalSets.variant_hash) or built read-through.
82
+ #
83
+ # @return [Selected, nil] nil for an entry that cannot be used (its
84
+ # conditional sets cannot be read): a miss
85
+ def pf_select_shared_permissions(key, entry)
86
+ hash, conditional, expires_at = pf_split_base_entry(entry)
87
+ return unless hash
88
+ return pf_read_permissions(hash, nil, expires_at) unless conditional
89
+
90
+ served = conditional.serve(pf_resolve_request_context(key))
91
+ variant = served.variant
92
+ return pf_read_permissions(hash, served, expires_at) unless variant
93
+
94
+ # a variant that is never stored (ConditionalSets::MAX_CACHED_SETS) is not looked for
95
+ stored = pf_read_shared_permissions(served.store_key(key)) if variant.cached
96
+ cached = PermissionForge::ConditionalSets.variant_hash(stored, variant)
97
+ return pf_read_permissions(cached, served, expires_at) if cached
98
+
99
+ pf_rebuild_variant_read_through(key, served, expires_at)
100
+ end
101
+
102
+ def pf_read_permissions(hash, served, expires_at)
103
+ Selected.new(permissions: hash, served: served, transaction: nil, rebuilt: nil, expires_at: expires_at)
104
+ end
105
+
106
+ # A stored base entry taken apart: the hash in the public format, the
107
+ # conditional sets it carries (ConditionalSets.split) and when the
108
+ # soonest assignment that went into it ends (EXPIRY_KEY).
109
+ #
110
+ # @return [Array(Hash, ConditionalSets|nil, Integer|nil), nil] nil for
111
+ # an entry that cannot be used
112
+ def pf_split_base_entry(entry)
113
+ expires_at = entry[EXPIRY_KEY]
114
+ hash, conditional = PermissionForge::ConditionalSets.split(expires_at ? entry.except(EXPIRY_KEY) : entry)
115
+ [hash, conditional, (expires_at if expires_at.is_a?(Integer))] if hash
116
+ end
117
+
118
+ # Builds the variant `served` names on a cache miss, under the same
119
+ # guard as a read-through rebuild of the base (the build reads this
120
+ # user's attributes too), and stores it unless it is withheld.
121
+ #
122
+ # @return [Selected]
123
+ def pf_rebuild_variant_read_through(key, served, expires_at)
124
+ outcome = pf_guarding_permission_build(key) do
125
+ transaction = PermissionForge::OpenTransaction.withholding
126
+ permissions = pf_rebuild_variant(key, transaction, served)
127
+ Selected.new(permissions: permissions, served: served, transaction: transaction, rebuilt: :variant, expires_at: expires_at)
128
+ end
129
+ outcome == :reentrant ? REENTRANT : outcome
130
+ end
131
+
132
+ # Builds the base entry and — when the request context selects
133
+ # conditional sets — the variant for them; stores each unless it is
134
+ # withheld inside a transaction (regenerate_permissions).
135
+ #
136
+ # @return [Selected] the hash that applies to the context, and what
137
+ # selected it (nil when the user holds no conditional set), as a
138
+ # base rebuilt
139
+ def pf_rebuild_permissions(key, transaction)
140
+ hash, conditional, expires_at = pf_split_base_entry(pf_rebuild_permission_entry(key, transaction))
141
+ served = conditional&.serve(pf_resolve_request_context(key))
142
+ hash = pf_rebuild_variant(key, transaction, served) if served&.variant
143
+ Selected.new(permissions: hash, served: served, transaction: transaction, rebuilt: :base, expires_at: expires_at)
144
+ end
145
+
146
+ # Builds one entry — the base under `key` (served nil), or the variant
147
+ # `served` selects — and, unless it is withheld inside a transaction
148
+ # or not cached at all (Variant#cached), stores it, verified against
149
+ # the user's invalidation stamp. A variant is written only after its
150
+ # index entry (#pf_variant_indexed?), which is written after the
151
+ # stamp was read. The WithheldNotice counts permission rebuilds only
152
+ # (Cache::Rebuild also builds the managed-actions index).
153
+ #
154
+ # @param key [String] the user's base key
155
+ # @param served [ConditionalSets::Served, nil] what selected a variant
156
+ # @return [Hash] the entry as built, with symbol keys like one read
157
+ # from the store (a variant's: #pf_rebuild_variant)
158
+ def pf_rebuild_permission_entry(key, transaction, served = nil)
159
+ variant = served&.variant
160
+ rebuild = PermissionForge::Cache::Rebuild.new(
161
+ key: served ? served.store_key(key) : key, stamp_key: pf_invalidation_stamp_key, user_id: id,
162
+ build: -> { pf_build_permission_entry(variant) }
163
+ )
164
+ notice = PermissionForge::Cache::WithheldNotice
165
+ transaction ? notice.withheld : notice.not_withheld
166
+ withheld = transaction || (variant && !variant.cached)
167
+ (withheld ? rebuild.withhold : pf_store_rebuild(rebuild, key, served)).deep_symbolize_keys
168
+ end
169
+
170
+ # Runs a rebuild that is stored: a variant's after its index entry.
171
+ #
172
+ # @return [Hash] the entry as built
173
+ def pf_store_rebuild(rebuild, key, served)
174
+ return rebuild.call.first unless served&.variant
175
+
176
+ rebuild.call { pf_variant_indexed?(key) }.first
177
+ end
178
+
179
+ # The hash a variant's rebuild built, without what its entry carries
180
+ # (ConditionalSets.variant_entry; nothing for a variant that is never
181
+ # stored).
182
+ def pf_rebuild_variant(key, transaction, served)
183
+ variant = served.variant
184
+ entry = pf_rebuild_permission_entry(key, transaction, served)
185
+ variant.cached ? PermissionForge::ConditionalSets.variant_hash(entry, variant) : entry
186
+ end
187
+
188
+ # Writes the index entry by which the enumerated cache:clear finds the
189
+ # variants of the base at `key` (Cache::VariantRemoval; erasure removes
190
+ # every slot without it) — after the variant's rebuild read the stamp,
191
+ # before the variant is written. A variant whose rebuild read the stamp
192
+ # before an invalidation is either verified before it, its index entry
193
+ # already there for the removal that follows the invalidation, or
194
+ # discarded by its verification. A variant whose index entry was not
195
+ # written is not stored: the clear would not find it. The entry
196
+ # outlives every variant written after it
197
+ # (ConditionalSets::INDEX_TTL_MARGIN).
198
+ #
199
+ # @return [Boolean] whether the store took the index entry
200
+ def pf_variant_indexed?(key)
201
+ sets = PermissionForge::ConditionalSets
202
+ ttl = PermissionForge.configuration.cache_ttl
203
+ PermissionForge.cache.write(sets.index_key(key), sets::INDEX, ttl: ttl && (ttl + sets::INDEX_TTL_MARGIN)) ? true : false
204
+ end
205
+
206
+ # Everything a build reads is read outside ActiveRecord's query cache.
207
+ # Inside a request the cache would answer the queries of a repeated
208
+ # build — or of a second rebuild later in the request — with the rows
209
+ # the first one read.
210
+ #
211
+ # The base entry (variant nil) is built from the unconditional sets and
212
+ # carries the conditional ones, and when the soonest assignment ends
213
+ # (EXPIRY_KEY); a variant from the unconditional sets plus those of the
214
+ # variant, and — when it is stored — carries what it was built for
215
+ # (ConditionalSets.variant_entry). The TTL is that of all the user's
216
+ # sets either way.
217
+ #
218
+ # @return [Array(Hash, Integer)] the entry and its TTL (nil for none)
219
+ def pf_build_permission_entry(variant)
220
+ PermissionForge::PermissionSet.uncached do
221
+ permission_sets = pf_active_non_expired_permission_sets
222
+ conditional = PermissionForge::ConditionalSets.of(permission_sets)
223
+
224
+ permission_data = PermissionForge::Services::PermissionHashBuilder.call(
225
+ user: self,
226
+ permission_sets: PermissionForge::ConditionalSets.build_sets(permission_sets, variant)
227
+ )
228
+
229
+ profile = PermissionForge.configuration.profile_builder.call(self)
230
+
231
+ soonest = pf_soonest_assignment_expiry
232
+ entry = { profile: profile, preferences: {}, permissions: permission_data }
233
+ if variant.nil?
234
+ pf_carry_in_base_entry(entry, conditional, soonest)
235
+ elsif variant.cached
236
+ entry = PermissionForge::ConditionalSets.variant_entry(entry, variant)
237
+ end
238
+ [entry, pf_permission_cache_ttl(permission_sets, soonest)]
239
+ end
240
+ end
241
+
242
+ # What the base entry carries beside the hash: the user's conditional
243
+ # sets (ConditionalSets::ENTRY_KEY) and when the soonest assignment
244
+ # ends (EXPIRY_KEY), each only when there is one.
245
+ def pf_carry_in_base_entry(entry, conditional, soonest)
246
+ entry[PermissionForge::ConditionalSets::ENTRY_KEY] = conditional.dump if conditional
247
+ entry[EXPIRY_KEY] = soonest.to_i if soonest
248
+ end
249
+
250
+ # The request context the activation conditions are evaluated against
251
+ # (config.request_context_resolver), resolved on every read of a user
252
+ # with conditional sets. A resolver that reads the permissions of the
253
+ # user it resolves for — a masked attribute of Current.user, a can_*?
254
+ # check — would be answered the hash being selected: one from another
255
+ # context, or {} (which switches row-level rules off). Neither is safe,
256
+ # so it raises instead — on every read of such a user.
257
+ #
258
+ # The context is a Hash; anything else the resolver returns (a Struct,
259
+ # an object with #to_h) is taken as its #to_h.
260
+ #
261
+ # @return [Hash]
262
+ # @raise [PermissionForge::ConfigurationError] when the resolver reads
263
+ # this user's permissions
264
+ def pf_resolve_request_context(key)
265
+ building = PermissionForge::ExecutionState.fetch(BUILDING_KEY) { {} }
266
+ build = building[key]
267
+ if build&.[](:resolving)
268
+ raise PermissionForge::ConfigurationError,
269
+ "config.request_context_resolver read the permissions of the user it resolves the context for (#{key}): " \
270
+ 'a resolver must not read permissions'
271
+ end
272
+
273
+ own = build.nil?
274
+ build = building[key] = {} if own
275
+ build[:resolving] = true
276
+ begin
277
+ pf_context_hash(PermissionForge.configuration.request_context_resolver.call)
278
+ ensure
279
+ own ? building.delete(key) : build.delete(:resolving)
280
+ end
281
+ end
282
+
283
+ def pf_context_hash(context)
284
+ context.is_a?(Hash) ? context : context.to_h
285
+ end
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,43 @@
1
+ module PermissionForge
2
+ module Concerns
3
+ # Invalidates the cached permission hash of every user affected by a change
4
+ # to the including permission model. The rebuild happens lazily on each
5
+ # user's next permissions_hash read (read-through in PermissionCacheAccessor),
6
+ # so activation conditions are evaluated in the affected user's own request
7
+ # context — not the context of whoever edited the permission records.
8
+ #
9
+ # Runs after commit through InvalidatesInBatch: the records written in one
10
+ # transaction invalidate each affected user once (per tenant), not once per
11
+ # record.
12
+ #
13
+ # @api private
14
+ module PermissionsRegenerator
15
+ extend ActiveSupport::Concern
16
+ include InvalidatesInBatch
17
+
18
+ # NOTE: The including model must define a private method `users_on_permission_set`
19
+ # that returns a collection of PermissionForge::PermissionSetUser records
20
+ # (or `user_ids_on_permission_set`, the ids of their users).
21
+
22
+ private
23
+
24
+ def invalidate_committed_caches
25
+ super
26
+ invalidate_permissions_for_users
27
+ end
28
+
29
+ def invalidate_permissions_for_users
30
+ invalidate_assigned_users(user_ids_on_permission_set, invalidation_batch, permission_record_tenant_id)
31
+ end
32
+
33
+ # The user ids of users_on_permission_set — plucked from a relation,
34
+ # without loading a row.
35
+ def user_ids_on_permission_set
36
+ holders = users_on_permission_set
37
+ return holders.distinct.pluck(:user_id) if holders.is_a?(ActiveRecord::Relation)
38
+
39
+ holders.filter_map(&:user_id).uniq
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Concerns
5
+ # Writes one audit event per create, update and destroy of a permission
6
+ # record (PermissionForge::Audit.record) — from after_create, after_update
7
+ # and after_destroy, inside the record's own transaction, so a change
8
+ # whose event cannot be written rolls back under the default
9
+ # `config.audit_change_failure_strategy = :raise`.
10
+ #
11
+ # What an event carries:
12
+ # create — data["snapshot"]: every attribute of the new row
13
+ # update — data["changes"]: saved_changes minus the bookkeeping columns
14
+ # (created_at, updated_at, created_by_id, updated_by_id); a
15
+ # save that changed nothing else writes no event. A model
16
+ # adds keys of its own through #audit_update_details (an
17
+ # assignment moved to another user: previous_subject_user_id)
18
+ # destroy — data["snapshot"]: every attribute of the deleted row
19
+ # plus the record reference (#audit_reference): type, id, uuid, label, the
20
+ # Permission Set, the subject user of an assignment or membership, and
21
+ # the record's tenant. Everything written in one transaction shares a
22
+ # correlation id (a cascade delete, a Review-and-save).
23
+ #
24
+ # A caller that means something more specific by a save renames and
25
+ # enriches the row's own event instead of adding a second one:
26
+ #
27
+ # assignment.audit_as('assignment.reviewed', data: { 'decision' => 'keep' }, reason: reason) do
28
+ # assignment.update!(reviewed_at: Time.current, reviewed_by_id: admin.id)
29
+ # end
30
+ #
31
+ # Paths that run no callbacks write no event: touch, update_column(s),
32
+ # update_all, delete_all, delete, insert_all, upsert_all. The gem's own
33
+ # code does not use them on these models; a test guards against new uses.
34
+ #
35
+ # Nothing is kept on the record outside a callback or an audit_as block:
36
+ # it can still be marshalled, and its associations are left as they were.
37
+ #
38
+ # @api private
39
+ module RecordsAuditEvents
40
+ extend ActiveSupport::Concern
41
+
42
+ BOOKKEEPING_COLUMNS = %w[created_at updated_at created_by_id updated_by_id].freeze
43
+ VERBS = { create: 'created', update: 'updated', destroy: 'destroyed' }.freeze
44
+
45
+ # Models whose events have their own names in the catalogue, by
46
+ # model key (base_class.model_name.element). Every other model's events
47
+ # are "<key>.created|updated|destroyed".
48
+ EVENT_NAMES = {
49
+ 'permission_set_user' => { create: 'assignment.granted', update: 'assignment.changed', destroy: 'assignment.revoked' },
50
+ 'permission_set_group_user' => { create: 'group_membership.added', update: 'group_membership.changed',
51
+ destroy: 'group_membership.removed' }
52
+ }.freeze
53
+
54
+ # The Permission Set an event about the record belongs to, by model key.
55
+ # Groups and memberships belong to none. Associations are read through
56
+ # #audit_associated: as loaded, else fetched in the record's own tenant.
57
+ OWN_SET = ->(record) { record.audit_associated(:permission_set) }
58
+ SET_OF_RESOURCE = ->(record) { record.audit_associated(:resource)&.audit_permission_set }
59
+ PERMISSION_SET = {
60
+ 'permission_set' => ->(record) { record },
61
+ 'resource' => OWN_SET, 'permission_set_rule' => OWN_SET, 'permission_set_user' => OWN_SET,
62
+ 'resource_field' => SET_OF_RESOURCE, 'resource_action' => SET_OF_RESOURCE
63
+ }.freeze
64
+
65
+ # How the record is labelled, by model key; the default is its name.
66
+ QUALIFIED_NAME = ->(record) { [record.audit_associated(:resource)&.name, record.name].compact.join('.') }
67
+ PERSON_NAME = lambda { |record|
68
+ user = record.audit_associated(:user)
69
+ PermissionForge::Audit::Actor.label_for(user) if user
70
+ }
71
+ LABELS = {
72
+ 'resource_field' => QUALIFIED_NAME, 'resource_action' => QUALIFIED_NAME,
73
+ 'permission_set_user' => PERSON_NAME, 'permission_set_group_user' => PERSON_NAME
74
+ }.freeze
75
+
76
+ # The names #audit_as may give a model's own events, by model key and
77
+ # action — a more specific name for the same row change, never another
78
+ # model's event. Adding one is one entry.
79
+ AUDIT_AS_RENAMES = {
80
+ 'permission_set_user' => { update: %w[assignment.reviewed] },
81
+ 'resource_field' => { update: %w[field.sensitive_acknowledged] }
82
+ }.freeze
83
+
84
+ # Keys of an event's data that the concern computes (#audit_as cannot set them).
85
+ AUDIT_AS_COMPUTED_KEYS = %w[changes snapshot].freeze
86
+
87
+ included do
88
+ after_create :record_audit_event_for_create
89
+ after_update :record_audit_event_for_update
90
+ after_destroy :record_audit_event_for_destroy
91
+ end
92
+
93
+ class_methods do
94
+ # @param action [Symbol] :create, :update or :destroy
95
+ # @return [String] the catalogue name of the event
96
+ def audit_event_name(action)
97
+ action = action.to_sym
98
+ EVENT_NAMES.dig(audit_model_key, action) || "#{audit_model_key}.#{VERBS.fetch(action)}"
99
+ end
100
+
101
+ # 'permission_set', 'resource_field', ... — also for a subclass.
102
+ def audit_model_key
103
+ base_class.model_name.element
104
+ end
105
+ end
106
+
107
+ # Records this record's events inside the block under `event` instead
108
+ # of their own name, with `data` added to theirs and `reason` on them —
109
+ # only for the actions AUDIT_AS_RENAMES lists for the model and that
110
+ # event (a review or an acknowledgement is an update); any other
111
+ # action inside the block keeps its own event. Instance-scoped,
112
+ # block-only, restored when the block ends or raises. Nothing is set
113
+ # when the arguments are refused.
114
+ #
115
+ # @param event [String] a change event the model may be renamed to
116
+ # @param data [Hash] event-specific keys; the computed 'changes' /
117
+ # 'snapshot' cannot be replaced
118
+ # @param reason [String, nil]
119
+ # @return the value of the block
120
+ # @raise [Audit::EventCatalog::UnknownEventError, ArgumentError]
121
+ def audit_as(event, data: {}, reason: nil)
122
+ override = audit_as_override(event, data, reason) # raises before anything is set
123
+ previous = @pf_audit_as
124
+ @pf_audit_as = override
125
+ begin
126
+ yield
127
+ ensure
128
+ @pf_audit_as = previous
129
+ end
130
+ end
131
+
132
+ # The columns an event about this record carries (Audit::Recorder).
133
+ #
134
+ # @return [Hash] record_type, record_id, record_uuid, record_label,
135
+ # permission_set_id, permission_set_uuid, subject_user_id, tenant_id
136
+ def audit_reference
137
+ with_audit_lookups do
138
+ set = audit_permission_set
139
+ {
140
+ record_type: self.class.base_class.name, record_id: id&.to_s, record_uuid: audit_uuid, record_label: audit_record_label,
141
+ permission_set_id: set&.id, permission_set_uuid: set&.uuid,
142
+ subject_user_id: audit_subject_user_id, tenant_id: audit_tenant_id
143
+ }
144
+ end
145
+ end
146
+
147
+ # Every attribute, JSON-ready.
148
+ def audit_snapshot
149
+ attributes.as_json
150
+ end
151
+
152
+ # saved_changes minus the bookkeeping columns, JSON-ready.
153
+ def audit_changes
154
+ saved_changes.except(*BOOKKEEPING_COLUMNS).as_json
155
+ end
156
+
157
+ # The Permission Set an event about this record belongs to, if any.
158
+ def audit_permission_set
159
+ PERMISSION_SET[self.class.audit_model_key]&.call(self)
160
+ end
161
+
162
+ # A belongs_to target as loaded, else fetched in this record's tenant
163
+ # and past every default scope — the ambient tenant may be another one
164
+ # or none (acts_as_tenant then raises under require_tenant), and the
165
+ # host's user model may hide the person behind a soft delete. What is
166
+ # fetched is not written to the association (the record would then
167
+ # answer with a hidden user, or with nothing for a row that is gone);
168
+ # inside #audit_reference it is looked up once.
169
+ #
170
+ # @param name [Symbol] a belongs_to association
171
+ # @return [ActiveRecord::Base, nil]
172
+ def audit_associated(name)
173
+ association = association(name)
174
+ return association.target if association.loaded?
175
+ return @pf_audit_lookups[name] if @pf_audit_lookups&.key?(name)
176
+
177
+ id = self[association.reflection.foreign_key]
178
+ found = id.nil? ? nil : audit_fetch(association.klass, id)
179
+ @pf_audit_lookups[name] = found if @pf_audit_lookups
180
+ found
181
+ end
182
+
183
+ private
184
+
185
+ # The validated #audit_as state: the event, the actions it renames, the
186
+ # data and the reason.
187
+ def audit_as_override(event, data, reason)
188
+ entry = PermissionForge::Audit::EventCatalog.fetch(event)
189
+ raise ArgumentError, "#{entry.name} is a #{entry.category} event: audit_as takes a change event" unless entry.category == 'change'
190
+
191
+ { event: entry.name, actions: audit_as_actions(entry.name), data: audit_as_data(data), reason: reason }
192
+ end
193
+
194
+ # The actions of this model that may be recorded as `name` (AUDIT_AS_RENAMES).
195
+ def audit_as_actions(name)
196
+ key = self.class.audit_model_key
197
+ actions = AUDIT_AS_RENAMES.fetch(key, {}).select { |_, names| names.include?(name) }.keys
198
+ raise ArgumentError, "#{key} events cannot be recorded as #{name}" if actions.empty?
199
+
200
+ actions
201
+ end
202
+
203
+ def audit_as_data(data)
204
+ data = data.as_json
205
+ reserved = data.keys & AUDIT_AS_COMPUTED_KEYS
206
+ raise ArgumentError, "audit_as data cannot set #{reserved.join(', ')}" if reserved.any?
207
+
208
+ data
209
+ end
210
+
211
+ # Memoizes association lookups for the duration of the block only.
212
+ def with_audit_lookups
213
+ @pf_audit_lookups = {}
214
+ yield
215
+ ensure
216
+ @pf_audit_lookups = nil
217
+ end
218
+
219
+ def audit_fetch(klass, id)
220
+ scope = klass.respond_to?(:of_tenant) ? klass.of_tenant(audit_tenant_id) : klass.unscoped
221
+ PermissionForge::Concerns::AutoPermissionScoping.internally_suspended { scope.find_by(klass.primary_key => id) }
222
+ end
223
+
224
+ def record_audit_event_for_create
225
+ record_audit_event(:create, 'snapshot' => audit_snapshot)
226
+ end
227
+
228
+ def record_audit_event_for_update
229
+ changes = audit_changes
230
+ return if changes.empty?
231
+
232
+ record_audit_event(:update, audit_update_details.merge('changes' => changes))
233
+ end
234
+
235
+ # Event data a model adds to its update events (the computed 'changes' wins).
236
+ def audit_update_details
237
+ {}
238
+ end
239
+
240
+ def record_audit_event_for_destroy
241
+ return unless audit_row_deleted?
242
+
243
+ record_audit_event(:destroy, 'snapshot' => audit_snapshot)
244
+ end
245
+
246
+ # Rails runs after_destroy also when the DELETE hit no row — an instance
247
+ # destroyed before, or already gone through a cascade. Nothing changed
248
+ # then, and the event for the row was written when it did go. Rails
249
+ # keeps the answer for its own commit callbacks (7.1 to 8.1).
250
+ def audit_row_deleted?
251
+ return true unless respond_to?(:_trigger_destroy_callback, true)
252
+
253
+ send(:_trigger_destroy_callback) ? true : false
254
+ end
255
+
256
+ # The row's own event, or the one an enclosing audit_as block asked for.
257
+ def record_audit_event(action, data)
258
+ override = @pf_audit_as if @pf_audit_as&.fetch(:actions)&.include?(action)
259
+ event = override ? override[:event] : self.class.audit_event_name(action)
260
+ data = override[:data].merge(data) if override # the computed keys win
261
+ PermissionForge::Audit.record(event, record: self, data: data, reason: override&.fetch(:reason))
262
+ end
263
+
264
+ def audit_uuid
265
+ has_attribute?(:uuid) ? self[:uuid] : nil
266
+ end
267
+
268
+ def audit_record_label
269
+ label = LABELS[self.class.audit_model_key]
270
+ label ? label.call(self) : try(:name)
271
+ end
272
+
273
+ def audit_subject_user_id
274
+ user_id&.to_s if respond_to?(:user_id)
275
+ end
276
+
277
+ def audit_tenant_id
278
+ config = PermissionForge.configuration
279
+ return unless config.tenant_enabled?
280
+
281
+ column = config.resolved_tenant_column
282
+ self[column] if has_attribute?(column)
283
+ end
284
+ end
285
+ end
286
+ end