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,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # Level 3 — Postgres Row-Level Security runtime plumbing.
5
+ #
6
+ # Policies are generated by {Services::RlsPolicyGenerator} and installed via
7
+ # `rails generate permission_forge:rls` (initial migration) or
8
+ # `rake permission_forge:rls:sync` (after rule edits). This module executes
9
+ # that DDL and manages the per-request session variable the policies read.
10
+ #
11
+ # Setting the user:
12
+ #
13
+ # # Block form — transaction-scoped (SET LOCAL), ideal for jobs/services:
14
+ # PermissionForge::RLS.with_user(user) { Customer.where(...) }
15
+ #
16
+ # # Controller form — session-scoped, cleared in ensure:
17
+ # around_action do |_controller, action|
18
+ # PermissionForge::RLS.set_user!(current_user)
19
+ # action.call
20
+ # ensure
21
+ # PermissionForge::RLS.clear_user!
22
+ # end
23
+ #
24
+ # Enforcement caveats (documented in docs/query-scoping.md): a superuser or
25
+ # BYPASSRLS role bypasses policies entirely — the app must connect as a
26
+ # regular role. Under RLS a denied row is invisible, so find_by reports
27
+ # missing (nil / RecordNotFound) rather than NotAuthorizedError.
28
+ #
29
+ # @api public
30
+ module RLS
31
+ module_function
32
+
33
+ def postgres?
34
+ with_connection { |connection| connection.adapter_name.match?(/postgresql/i) }
35
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
36
+ false
37
+ end
38
+
39
+ def ensure_postgres!
40
+ return if postgres?
41
+
42
+ raise PermissionForge::ConfigurationError,
43
+ I18n.t('permission_forge.errors.rls_requires_postgres',
44
+ default: 'PermissionForge RLS requires PostgreSQL — the current database ' \
45
+ 'adapter does not support row-level security policies.')
46
+ end
47
+
48
+ # Runs the block with the RLS session variable set transaction-locally
49
+ # (set_config(..., true) — automatically cleared when the transaction
50
+ # ends, so no reset bookkeeping and no leakage across pooled connections).
51
+ #
52
+ # The block runs on the connection the variable is set on: one the
53
+ # calling thread already holds, or one taken for the block and given
54
+ # back to the pool after it.
55
+ def with_user(user_or_id)
56
+ ensure_postgres!
57
+ with_connection do |connection|
58
+ connection.transaction do
59
+ apply_user(user_or_id, local: true, connection: connection)
60
+ yield
61
+ end
62
+ end
63
+ end
64
+
65
+ # Session-scoped variant for around_action use. MUST be paired with
66
+ # clear_user! in an ensure block — the setting survives on the pooled
67
+ # connection otherwise. Set on the connection the calling thread keeps
68
+ # (leased to it until the request ends), so the request's queries see it.
69
+ def set_user!(user_or_id)
70
+ ensure_postgres!
71
+ apply_user(user_or_id, local: false)
72
+ end
73
+
74
+ # Clears the database session variable that carries the current user (PostgreSQL only).
75
+ # @return [void]
76
+ def clear_user!
77
+ return unless postgres?
78
+
79
+ run_set_config('', local: false)
80
+ end
81
+
82
+ # Generates and executes RLS DDL for every protected model (enable/force +
83
+ # four policies + digest comment), one transaction per table.
84
+ #
85
+ # @return [Array<Hash>] per-table { table:, digest:, skipped_rules: }
86
+ def sync!
87
+ ensure_postgres!
88
+ protected_model_classes.map do |model_class|
89
+ result = Services::RlsPolicyGenerator.call(model_class: model_class)
90
+ with_connection { |connection| execute_in_transaction(connection, result[:statements]) }
91
+ log_skipped_rules(result)
92
+ result.slice(:table, :digest, :skipped_rules)
93
+ end
94
+ end
95
+
96
+ # Drops PermissionForge policies and disables RLS on every protected table
97
+ # (used by the generated migration's `down`).
98
+ #
99
+ # @return [Array<String>] the table names cleaned up
100
+ def remove!
101
+ ensure_postgres!
102
+ protected_model_classes.map do |model_class|
103
+ with_connection { |connection| drop_policies(connection, model_class.table_name) }
104
+ model_class.table_name
105
+ end
106
+ end
107
+
108
+ # Compares each protected table's live policy digest with the digest the
109
+ # current rule state would produce. A missing policy reports out-of-sync
110
+ # (stored: nil) — enabling Level 3 without installing policies is itself
111
+ # a misconfiguration worth surfacing.
112
+ #
113
+ # @return [Array<Hash>] { table:, expected:, stored:, in_sync: }
114
+ def drift_report
115
+ protected_model_classes.map do |model_class|
116
+ expected = Services::RlsPolicyGenerator.new(model_class: model_class).digest
117
+ stored = stored_digest(model_class.table_name)
118
+ { table: model_class.table_name, expected: expected, stored: stored, in_sync: expected == stored }
119
+ end
120
+ end
121
+
122
+ # @param table_name [String]
123
+ # @return [String, nil] the digest of the policy installed on the table
124
+ # @api private
125
+ def stored_digest(table_name)
126
+ row = with_connection do |connection|
127
+ connection.select_value(<<~SQL)
128
+ SELECT d.description
129
+ FROM pg_policy pol
130
+ JOIN pg_class c ON c.oid = pol.polrelid
131
+ JOIN pg_description d ON d.objoid = pol.oid
132
+ WHERE c.relname = #{connection.quote(table_name)}
133
+ AND pol.polname = #{connection.quote(Services::RlsPolicyGenerator::POLICY_NAMES[:select])}
134
+ SQL
135
+ end
136
+ row&.delete_prefix(Services::RlsPolicyGenerator::DIGEST_COMMENT_PREFIX)
137
+ end
138
+
139
+ # @return [Array<Class>] the models that include PermissionScopedFindable
140
+ # @api private
141
+ def protected_model_classes
142
+ Concerns::PermissionScopedFindable.protected_model_names.filter_map(&:safe_constantize)
143
+ end
144
+
145
+ # A connection for one unit of work: the one the calling thread holds,
146
+ # else one from the pool that goes back to it when the block ends.
147
+ # (`ActiveRecord::Base.connection` is soft-deprecated from Rails 7.2 and
148
+ # would keep a connection leased to the thread.)
149
+ def with_connection(&)
150
+ ActiveRecord::Base.connection_pool.with_connection(&)
151
+ end
152
+
153
+ # The connection the calling thread keeps until it is released (the end
154
+ # of the request, or of the job): where a session-scoped setting has to
155
+ # live. `lease_connection` from Rails 7.2; on 7.1 that is `connection`.
156
+ def leased_connection
157
+ pool = ActiveRecord::Base.connection_pool
158
+ pool.respond_to?(:lease_connection) ? pool.lease_connection : pool.connection
159
+ end
160
+
161
+ def execute_in_transaction(connection, statements)
162
+ connection.transaction do
163
+ statements.each { |statement| connection.execute(statement) }
164
+ end
165
+ end
166
+
167
+ def drop_policies(connection, table_name)
168
+ table = connection.quote_table_name(table_name)
169
+ connection.transaction do
170
+ Services::RlsPolicyGenerator::POLICY_NAMES.each_value do |name|
171
+ connection.execute("DROP POLICY IF EXISTS #{name} ON #{table}")
172
+ end
173
+ connection.execute("ALTER TABLE #{table} NO FORCE ROW LEVEL SECURITY")
174
+ connection.execute("ALTER TABLE #{table} DISABLE ROW LEVEL SECURITY")
175
+ end
176
+ end
177
+
178
+ # `connection` defaults to the one the calling thread keeps (set_user!).
179
+ def apply_user(user_or_id, local:, connection: leased_connection)
180
+ id = user_or_id.respond_to?(:id) ? user_or_id.id : user_or_id
181
+ run_set_config(id.to_s, local: local, connection: connection)
182
+ end
183
+
184
+ # set_config avoids interpolating the setting name as an identifier —
185
+ # both the name and value travel as quoted literals.
186
+ def run_set_config(value, local:, connection: leased_connection)
187
+ var = PermissionForge.configuration.rls_session_variable
188
+ connection.execute(
189
+ "SELECT set_config(#{connection.quote(var)}, #{connection.quote(value)}, #{local})"
190
+ )
191
+ end
192
+
193
+ # Logs the rules a policy sync left out because they cannot be expressed in SQL.
194
+ # @param result [Hash] the sync result
195
+ # @api private
196
+ def log_skipped_rules(result)
197
+ return if result[:skipped_rules].empty?
198
+
199
+ Rails.logger&.warn(
200
+ "[PermissionForge] RLS sync for '#{result[:table]}' skipped " \
201
+ "#{result[:skipped_rules].size} untranslatable rule(s) " \
202
+ "(#{result[:skipped_rules].map { |r| "#{r[:rule]}: #{r[:reason]}" }.join(', ')}) — " \
203
+ 'join-type rules, dotted field names and rules on an STI subclass resource are enforced at the application layer only.'
204
+ )
205
+ end
206
+
207
+ private_class_method :with_connection, :leased_connection, :execute_in_transaction, :drop_policies
208
+ end
209
+ end
@@ -0,0 +1,138 @@
1
+ module PermissionForge
2
+ module Services
3
+ # Decides what value an attribute reader should return for a given user, based on
4
+ # the user's field permissions. Used by {PermissionForge::Concerns::MaskedAttributes}.
5
+ #
6
+ # Returns a `[include?, value]` tuple so callers can distinguish "omit this key from
7
+ # the hash" from "substitute this value in place." This mirrors the asymmetry between
8
+ # Salesforce's `Security.stripInaccessible()` (omits the field) and a direct getter
9
+ # call (which has to return *something*).
10
+ #
11
+ # @example Single attribute
12
+ # include?, value = AttributeAccessGuard.call(
13
+ # resource_name: :Customer, attribute: :email, raw_value: 'a@b.com', user: current_user
14
+ # )
15
+ #
16
+ # Every decision is reported for the field.access audit event
17
+ # (Audit::FieldAccess), which also calls the legacy on_field_filtered hook:
18
+ # to `observer:` — the frame of the record read in progress, or nil when
19
+ # nobody listens (MaskedAttributes) — or, by default, to whatever read is
20
+ # in progress on this thread (a call of its own is a read of its own).
21
+ #
22
+ # `resource_name:` is one Resource name, or the chain of names the
23
+ # record's class answers to (ResourceChain: its own and its STI
24
+ # ancestors', nearest first). The field is looked up along the chain; the
25
+ # decision is reported under the first name, the record's own.
26
+ #
27
+ # @api private
28
+ class AttributeAccessGuard
29
+ ACTIONS = %i[hidden masked passed bypassed unconfigured].freeze
30
+
31
+ # The default observer: Audit::FieldAccess.note.
32
+ STANDALONE = Object.new.freeze
33
+
34
+ # Called on every guarded read: the instance is set up without passing
35
+ # the keywords on a second time (half the cost of `new(...).call`).
36
+ # rubocop:disable Metrics/ParameterLists -- model: the record's class, so a read with no user looks up no constant
37
+ def self.call(resource_name:, attribute:, raw_value:, user: nil, observer: STANDALONE, model: nil)
38
+ allocate.decide(resource_name, attribute, raw_value, user, observer, model)
39
+ end
40
+
41
+ # @param resource_name [Symbol, String, Array<Symbol>] a name, or the chain
42
+ # @param observer [Audit::FieldAccess::Frame, nil] where decisions go
43
+ # @param model [Class, nil] the record's class (default: the class the
44
+ # first name names)
45
+ def initialize(resource_name:, attribute:, raw_value:, user: nil, observer: STANDALONE, model: nil)
46
+ assign(resource_name, attribute, raw_value, user, observer)
47
+ @model = model
48
+ end
49
+
50
+ # .call on an allocated instance. @api private
51
+ def decide(resource_name, attribute, raw_value, user, observer, model = nil)
52
+ assign(resource_name, attribute, raw_value, user, observer)
53
+ @model = model
54
+ call
55
+ end
56
+ # rubocop:enable Metrics/ParameterLists
57
+
58
+ # @return [Array(Boolean, Object)] tuple of (include_in_hash?, value_to_return)
59
+ def call
60
+ return without_user unless @user
61
+ return unconfigured unless field
62
+
63
+ return hidden unless field[:read_access]
64
+ return masked if field[:mask_access]
65
+
66
+ passed
67
+ end
68
+
69
+ private
70
+
71
+ def assign(resource_name, attribute, raw_value, user, observer)
72
+ @resource_names = resource_name.is_a?(Array) ? resource_name : resource_name.to_sym
73
+ @resource_name = resource_name.is_a?(Array) ? resource_name.first : @resource_names
74
+ @attribute = attribute.to_sym
75
+ @raw_value = raw_value
76
+ @user = user || PermissionForge.current_user
77
+ @observer = observer
78
+ end
79
+
80
+ def field
81
+ return @field if defined?(@field)
82
+
83
+ @field = PermissionForge::ResourceChain.field_permissions(@user, @resource_names, @attribute)
84
+ end
85
+
86
+ # Without a user there is no entry to decide by:
87
+ # config.masked_attributes_nil_user_strategy says whether every
88
+ # attribute passes (:bypass, the default) or is hidden (:hide) — except
89
+ # on the user model, which sign-in reads before anyone is signed in.
90
+ # The model is the record's class, or else the one the first name of the
91
+ # chain names (an anonymous subclass answers as its base).
92
+ def without_user
93
+ model = @model || @resource_name.to_s.safe_constantize
94
+ PermissionForge.configuration.hide_masked_attributes_without_user_for?(model) ? hidden : bypass
95
+ end
96
+
97
+ def bypass
98
+ notify(:bypassed)
99
+ [true, @raw_value]
100
+ end
101
+
102
+ def hidden
103
+ notify(:hidden)
104
+ [false, PermissionForge.configuration.hidden_attribute_value]
105
+ end
106
+
107
+ def masked
108
+ notify(:masked)
109
+ strategy = field[:mask_strategy] || 'full'
110
+ [true, MaskValue.call(@raw_value, strategy: strategy)]
111
+ end
112
+
113
+ def passed
114
+ notify(:passed)
115
+ [true, @raw_value]
116
+ end
117
+
118
+ def unconfigured
119
+ strategy_key = PermissionForge.configuration.unconfigured_field_strategy
120
+ # Unknown strategy keys fall back to :block (fail closed), never pass-through.
121
+ strategy = FiltersResponseAttributes::UNCONFIGURED_FIELD_STRATEGIES.fetch(
122
+ strategy_key,
123
+ FiltersResponseAttributes::UNCONFIGURED_FIELD_STRATEGIES[:block]
124
+ )
125
+ include_field, value = strategy.call(@resource_name.to_s, @attribute, @raw_value)
126
+ notify(:unconfigured)
127
+ [include_field, value]
128
+ end
129
+
130
+ def notify(action)
131
+ return if @observer.nil?
132
+ return PermissionForge::Audit::FieldAccess.note(@resource_name, @attribute, action, @user) if @observer.equal?(STANDALONE)
133
+
134
+ @observer.add(@resource_name, @attribute, action, @user)
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ # Retention: deletes audit events older than the retention period and
6
+ # records that it did (`audit.purged`: count, cutoff, period, batch).
7
+ #
8
+ # AuditEventPurger.call # config.audit_retention_period
9
+ # AuditEventPurger.call(older_than: 400.days, actor: 'nightly job')
10
+ # AuditEventPurger.call(dry_run: true) # counts, writes nothing
11
+ #
12
+ # Only events whose `occurred_at` is before now - period are deleted — an
13
+ # event inside the period is never touched, and neither is an
14
+ # `audit.purged` event (the record of purges outlives them). A Duration
15
+ # is subtracted by the calendar (30.days across a DST change, 1.year),
16
+ # a number is seconds. No period (nil: the default) deletes nothing and
17
+ # says so. With auditing off (nothing could record the purge) a real run
18
+ # raises ConfigurationError.
19
+ #
20
+ # Each batch is `delete_all` plus its own `audit.purged` event in ONE
21
+ # transaction: a batch whose event cannot be written is not deleted,
22
+ # under every audit_change_failure_strategy (a purge is always fail-closed),
23
+ # so no deletion is ever unrecorded. In a tenant context only that tenant's events are
24
+ # purged; with none, all of them. A run that finds nothing records nothing.
25
+ #
26
+ # A purge with no tenant (the rake task, a platform job) records one
27
+ # `audit.purged` per tenant whose events the batch deleted, in that
28
+ # tenant and with that tenant's count (`across_tenants: true`), plus one
29
+ # without a tenant for the events that had none: a tenant's auditors
30
+ # never see an event without a tenant, and must still see that their
31
+ # log was purged. Other tenants' counts are not in it.
32
+ #
33
+ # Reached only from Ruby and `rake permission_forge:audit_events:purge`:
34
+ # there is deliberately no controller, route or API for it.
35
+ #
36
+ # @api public
37
+ class AuditEventPurger
38
+ include Concerns::AuditedOperation
39
+
40
+ BATCH_SIZE = 1000
41
+ EVENT = 'audit.purged'
42
+
43
+ # Deletes audit events past the retention period.
44
+ # @return [Hash] the counts of the run
45
+ def self.call(...) = new(...).call
46
+
47
+ # @param older_than [ActiveSupport::Duration, Numeric, nil] the retention period
48
+ # @param actor [Object, nil] a user, an Audit::Actor or a label String
49
+ # @param batch_size [Integer] rows deleted per statement (>= 1)
50
+ # @param now [Time] the clock (tests)
51
+ # @raise [PermissionForge::ConfigurationError] for a period that is not valid
52
+ # @raise [ArgumentError] for a batch_size below 1
53
+ # rubocop:disable-next Metrics/ParameterLists
54
+ def initialize(older_than: PermissionForge.configuration.audit_retention_period, actor: nil, reason: nil,
55
+ dry_run: false, batch_size: BATCH_SIZE, now: Time.current)
56
+ raise ArgumentError, "batch_size must be at least 1 (got #{batch_size.inspect})" unless batch_size.is_a?(Integer) && batch_size >= 1
57
+
58
+ @period = PermissionForge::Configuration::AuditSettings.retention_period(older_than)
59
+ @actor = actor
60
+ @reason = reason
61
+ @dry_run = dry_run
62
+ @batch_size = batch_size
63
+ @now = now
64
+ end
65
+
66
+ # @return [Hash] { purged:, retention_configured:, cutoff:, period:, dry_run: } (+ message: when not configured)
67
+ def call
68
+ return not_configured_result if @period.nil?
69
+
70
+ cutoff = @now - @period
71
+ return dry_run_result(cutoff) if @dry_run
72
+
73
+ refuse_without_auditing!
74
+ audited(actor: @actor, reason: @reason) { purge(cutoff) }
75
+ end
76
+
77
+ private
78
+
79
+ def dry_run_result(cutoff)
80
+ { purged: expired(cutoff).count, retention_configured: true, cutoff: cutoff, period: @period.to_i, dry_run: true }
81
+ end
82
+
83
+ def not_configured_result
84
+ { purged: 0, retention_configured: false, dry_run: @dry_run, message: 'retention not configured' }
85
+ end
86
+
87
+ def refuse_without_auditing!
88
+ return if PermissionForge::Audit.enabled?
89
+
90
+ raise PermissionForge::ConfigurationError,
91
+ 'audit events are switched off (or their table is missing): a purge would be unrecorded, so it is refused'
92
+ end
93
+
94
+ def expired(cutoff)
95
+ PermissionForge::AuditEvent.unscoped.in_tenant(PermissionForge.ambient_tenant_id)
96
+ .where(PermissionForge::AuditEvent.arel_table[:occurred_at].lt(cutoff))
97
+ .where.not(event: EVENT)
98
+ end
99
+
100
+ def purge(cutoff)
101
+ purged = 0
102
+ batch = 0
103
+ loop do
104
+ ids = expired(cutoff).order(:id).limit(@batch_size).pluck(:id)
105
+ break if ids.empty?
106
+
107
+ batch += 1
108
+ purged += purge_batch(ids, cutoff, batch)
109
+ end
110
+
111
+ { purged: purged, retention_configured: true, cutoff: cutoff, period: @period.to_i, dry_run: false }
112
+ end
113
+
114
+ # The deletion and its records commit together.
115
+ def purge_batch(ids, cutoff, batch)
116
+ PermissionForge::AuditEvent.transaction do
117
+ per_tenant = counts_by_tenant(ids)
118
+ deleted = PermissionForge::AuditEvent.unscoped.where(id: ids).delete_all
119
+ record_batch(per_tenant || { nil => deleted }, summary_data(cutoff, batch, across_tenants: !per_tenant.nil?))
120
+ deleted
121
+ end
122
+ end
123
+
124
+ def record_batch(per_tenant, data)
125
+ per_tenant.each do |tenant_id, count|
126
+ # fail closed whatever audit_change_failure_strategy says: a nil
127
+ # (:report / :log swallowed a failed write) rolls the batch back
128
+ recorded = PermissionForge::Audit.record(EVENT, tenant_id: tenant_id, data: data.merge('count' => count))
129
+ raise PermissionForge::Audit::EventNotRecordedError.new(event: EVENT) if recorded.nil?
130
+ end
131
+ end
132
+
133
+ # { tenant id => events of the batch } for a purge with no tenant
134
+ # ambient; nil (one record, in the ambient tenant) otherwise.
135
+ def counts_by_tenant(ids)
136
+ column = PermissionForge::AuditEvent.tenant_column
137
+ return if column.nil? || PermissionForge.ambient_tenant_id
138
+
139
+ PermissionForge::AuditEvent.unscoped.where(id: ids).group(column).count
140
+ end
141
+
142
+ def summary_data(cutoff, batch, across_tenants:)
143
+ data = { 'operation' => 'audit_events_purge', 'count' => 0, 'batch' => batch,
144
+ 'cutoff' => cutoff.utc.iso8601(6), 'period_seconds' => @period.to_i, 'dry_run' => false }
145
+ data['across_tenants'] = true if across_tenants
146
+ data
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,108 @@
1
+ module PermissionForge
2
+ module Services
3
+ # Assigns or revokes a permission set for multiple users in a single
4
+ # transaction. Each assignment created or destroyed invalidates its
5
+ # user's cached permissions after the commit (PermissionSetUser's own
6
+ # after_commit), once per user; each user's hash is rebuilt read-through
7
+ # on their next request, in their own context.
8
+ #
9
+ # @example Bulk assign
10
+ # result = BulkPermissionSetAssignment.call(
11
+ # permission_set: ps, user_ids: [1, 2, 3], action: :assign
12
+ # )
13
+ # # => { assigned: 3, revoked: 0, invalidated: 3 }
14
+ #
15
+ # `invalidated` counts the users assigned or revoked whose invalidation
16
+ # the cache store confirmed. Fewer means the store failed for the rest
17
+ # (reported through `config.on_cache_failure`): their cached permissions
18
+ # may live until cache_ttl. A user who already held the set (assign) or
19
+ # never did (revoke) changed nothing and is not invalidated. Inside a
20
+ # transaction of the caller the invalidations happen at its commit, after
21
+ # this returns: `invalidated` is then nil (not known).
22
+ #
23
+ # Every row is created or destroyed through its callbacks, so each person
24
+ # gets an assignment.granted / assignment.revoked audit event, all with
25
+ # one correlation id (the transaction's). The reason (a ticket, free
26
+ # text) comes from the surrounding Audit::Context.
27
+ #
28
+ # @api public
29
+ class BulkPermissionSetAssignment
30
+ ACTION_STRATEGIES = {
31
+ assign: :assign_users,
32
+ revoke: :revoke_users
33
+ }.freeze
34
+
35
+ VALID_ACTIONS = ACTION_STRATEGIES.keys.freeze
36
+
37
+ # Assigns or revokes a Permission Set for many users in one transaction.
38
+ # @return [Hash] `{ assigned:, revoked:, invalidated: }`
39
+ def self.call(...) = new(...).call
40
+
41
+ # @raise [ArgumentError] unless `action` is one of VALID_ACTIONS
42
+ def self.validate_action!(action)
43
+ action = action.presence&.to_sym
44
+ return action if VALID_ACTIONS.include?(action)
45
+
46
+ raise ArgumentError, "Invalid action: #{action}. Must be one of: #{VALID_ACTIONS.join(', ')}"
47
+ end
48
+
49
+ def initialize(permission_set:, user_ids:, action:, created_by: nil, expires_at: nil)
50
+ @permission_set = permission_set
51
+ # Host keys as given: `to_i` would turn a UUID into its leading digits
52
+ # and assign whoever owns that number.
53
+ @user_ids = Array(user_ids).compact_blank.map(&:to_s).uniq
54
+ @action = action&.to_sym
55
+ @created_by = created_by
56
+ @expires_at = expires_at
57
+ @assigned_count = 0
58
+ @revoked_count = 0
59
+ @changed_user_ids = []
60
+ end
61
+
62
+ # @return [Hash] `{ assigned:, revoked:, invalidated: }`
63
+ def call
64
+ validate_action!
65
+
66
+ deferred = PermissionForge::OpenTransaction.current
67
+ failures = PermissionForge::Cache::Failure.collect do
68
+ ActiveRecord::Base.transaction { send(ACTION_STRATEGIES.fetch(@action)) }
69
+ end
70
+
71
+ # what the store confirmed (Cache::Failure) — not what a host's override of invalidate_permissions returns
72
+ { assigned: @assigned_count, revoked: @revoked_count, invalidated: deferred ? nil : failures.confirmed(@changed_user_ids).size }
73
+ end
74
+
75
+ private
76
+
77
+ def validate_action!
78
+ self.class.validate_action!(@action)
79
+ end
80
+
81
+ def assign_users
82
+ users = PermissionForge.configuration.user_model.where(id: @user_ids)
83
+ existing_user_ids = @permission_set.permission_set_users.where(user_id: @user_ids).pluck(:user_id)
84
+
85
+ users.each do |user|
86
+ next if existing_user_ids.include?(user.id)
87
+
88
+ @permission_set.permission_set_users.create!(
89
+ user: user,
90
+ expires_at: @expires_at,
91
+ created_by_id: @created_by&.id,
92
+ updated_by_id: @created_by&.id
93
+ )
94
+ @assigned_count += 1
95
+ @changed_user_ids << user.id
96
+ end
97
+ end
98
+
99
+ # Through callbacks, as destroy_all; a row a callback refuses to
100
+ # destroy (destroy answers false) is neither revoked nor counted.
101
+ def revoke_users
102
+ revoked = @permission_set.permission_set_users.where(user_id: @user_ids).to_a.select(&:destroy)
103
+ @revoked_count = revoked.size
104
+ @changed_user_ids = revoked.map(&:user_id).uniq
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ # @api private
6
+ module Concerns
7
+ # A housekeeping service run with no tenant ambient (a rake task, a
8
+ # console, a platform job) works on every tenant's rows; with a tenant
9
+ # ambient, on that tenant's only.
10
+ #
11
+ # #across_tenants lifts acts_as_tenant's scope for the tenant-less run,
12
+ # as PermissionedUser's destroy and PermissionCacheClearer do, so
13
+ # `require_tenant = true` does not refuse it with NoTenantSet; with a
14
+ # tenant ambient, or tenancy off, the block runs as it is. No hard
15
+ # dependency on acts_as_tenant: without it there is nothing to lift.
16
+ #
17
+ # #record_per_tenant writes a run's summary event the way audit.purged
18
+ # and cache.cleared are written: with a tenant ambient, once (in it);
19
+ # tenant-less, once in each tenant the run touched, with that tenant's
20
+ # rows, plus the total without a tenant — each marked across_tenants —
21
+ # so every tenant's auditors see what was done in their tenant.
22
+ module AcrossTenants
23
+ # One summary event of a run over `rows`; `data` builds the event's
24
+ # data from the rows it covers.
25
+ Summary = Struct.new(:event, :rows, :payload, :data) do
26
+ def record(covered = rows, tenant_id: nil, more: {})
27
+ PermissionForge::Audit.record(event, **payload, tenant_id: tenant_id, data: data.call(covered).merge(more))
28
+ end
29
+
30
+ # In each tenant with its rows, then the total without a tenant.
31
+ def record_per_tenant(column)
32
+ by_tenant = rows.select { |row| row.has_attribute?(column) && !row[column].nil? }.group_by { |row| row[column] }
33
+ by_tenant.each { |tenant_id, tenant_rows| record(tenant_rows, tenant_id: tenant_id, more: { 'across_tenants' => true }) }
34
+ record(more: { 'across_tenants' => true, 'tenants' => by_tenant.size })
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def across_tenants(&)
41
+ return yield unless tenantless_run?
42
+
43
+ ::ActsAsTenant.without_tenant(&)
44
+ end
45
+
46
+ # Tenancy is on and no tenant is ambient.
47
+ def tenantless_run?
48
+ PermissionForge.configuration.tenant_enabled? && defined?(::ActsAsTenant) && PermissionForge.ambient_tenant.nil?
49
+ end
50
+
51
+ # @param event [String] the summary event
52
+ # @param rows [Array<ActiveRecord::Base>] the rows the run changed
53
+ # @param payload [Hash] more of Audit.record's payload (subject_user_id: …)
54
+ # @yieldparam rows [Array<ActiveRecord::Base>] the rows of one tenant, or all of them
55
+ # @yieldreturn [Hash] the event's data for those rows
56
+ def record_per_tenant(event, rows, **payload, &data)
57
+ summary = Summary.new(event, rows, payload, data)
58
+ return summary.record unless tenantless_run?
59
+
60
+ summary.record_per_tenant(tenant_column)
61
+ end
62
+
63
+ def tenant_column
64
+ PermissionForge.configuration.resolved_tenant_column.to_s
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end