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,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Cache
5
+ # What is invalidated once more when a transaction has committed.
6
+ #
7
+ # An invalidation made inside an open transaction comes too early for
8
+ # everybody else: the rows it is about are not visible to them yet, so a
9
+ # request that rebuilds right after it caches the rows as they were
10
+ # before the commit — and keeps them. (regenerate_permissions inside a
11
+ # transaction that reads from a snapshot leaves nothing in the cache at
12
+ # all, which tells nobody that something changed.) So the invalidation
13
+ # is made again when the outermost transaction has committed: once per
14
+ # key for each transaction that asked. A rollback drops it, because
15
+ # nothing changed.
16
+ #
17
+ # Used for a user's permission hash (PermissionCacheAccessor, inside a
18
+ # transaction that reads from a snapshot) and for the managed-actions
19
+ # index (ControllerActions::ManagedIndex, inside any open transaction).
20
+ #
21
+ # @api private
22
+ class Settlement
23
+ # The settlement of each open transaction, by the adapter's transaction
24
+ # object. Keys and values are held weakly: a settlement lives as long
25
+ # as the commit hook it is registered as.
26
+ PENDING = ObjectSpace::WeakMap.new
27
+
28
+ # @param transaction [PermissionForge::OpenTransaction]
29
+ # @return [Settlement] the one of this transaction
30
+ def self.for(transaction)
31
+ subject = transaction.subject
32
+ PENDING[subject] ||= new.tap { |settlement| transaction.after_commit { settlement.call } }
33
+ end
34
+
35
+ def initialize
36
+ @work = {}
37
+ end
38
+
39
+ # @param key [Object] what is invalidated (e.g. [:user, id, tenant_id]):
40
+ # the first block given for a key is the one that runs
41
+ # @return [void]
42
+ def add(key, &block)
43
+ @work[key] ||= block
44
+ end
45
+
46
+ # Runs among the commit callbacks of the transaction, so nothing is
47
+ # raised: Rails would skip the callbacks that come after it.
48
+ #
49
+ # @return [void]
50
+ def call
51
+ @work.each_value { |block| run(block) }
52
+ end
53
+
54
+ private
55
+
56
+ def run(block)
57
+ block.call
58
+ rescue StandardError => e
59
+ Failure.report(operation: :after_commit, error: e)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Cache
5
+ # Where a user's permission hash and invalidation stamp live, and what an
6
+ # invalidation of the user removes — one definition for
7
+ # User#invalidate_permissions (PermissionCacheAccessor) and for the
8
+ # invalidation of many holders by id (HolderInvalidation). A key added to
9
+ # one is added to both.
10
+ #
11
+ # The entries live under ENTRY_NAMESPACE ("v2:user_<id>",
12
+ # "v2:tenant_<tenant id>_user_<id>"), bumped when their format changes
13
+ # (0.14: the base entry of a user with conditional sets carries them under
14
+ # :conditional_sets), so that during a rolling deploy a process running
15
+ # another version never reads an entry it would misread. An invalidation
16
+ # removes the keys of the previous namespaces as well
17
+ # (PREVIOUS_ENTRY_NAMESPACES), so a change made through either version
18
+ # reaches both. The stamp is not namespaced: every version must see it.
19
+ #
20
+ # An invalidation removes the base entries, and reads nothing: the
21
+ # per-context variants of a user with conditional sets cannot be reached
22
+ # once the base entry that carries their token is gone, and are left to
23
+ # their TTL (ConditionalSets). Where they must go now — erasure,
24
+ # cache:clear — .variant_removals names them: the index entry of each
25
+ # base entry of this namespace is read (a few bytes, written only for a
26
+ # user who had a variant stored; one call for many users where the store
27
+ # can, Base#read_many), and every slot of a base entry that has one
28
+ # (ConditionalSets.variant_keys) goes with it.
29
+ #
30
+ # @api private
31
+ module UserKeys
32
+ ENTRY_NAMESPACE = 'v2'
33
+ # '' is the bare key of 0.13. Drop in 0.15.
34
+ PREVIOUS_ENTRY_NAMESPACES = [''].freeze
35
+
36
+ module_function
37
+
38
+ # Every namespace an entry may be under: this version's and the previous ones.
39
+ #
40
+ # @return [Array<String>]
41
+ def namespaces
42
+ [ENTRY_NAMESPACE, *PREVIOUS_ENTRY_NAMESPACES]
43
+ end
44
+
45
+ # What the permission entries of a namespace look like, for a store that
46
+ # removes keys by pattern (Base#clear_entries): the keys of every user
47
+ # — any id, a prefixed string one included — tenant-agnostic and per
48
+ # tenant, and their variants. Nothing else of the gem under the bare
49
+ # namespace matches the Regexp: not the stamps (`_invalidation`), not
50
+ # the managed-actions index. The globs, which cannot say "not followed
51
+ # by", take the stamps along with the bare keys. The Regexp starts with
52
+ # `^`, the one anchor ActiveSupport's key_matcher keeps when it puts the
53
+ # store's own namespace in front (a `\A` would be prefixed with `.*`
54
+ # and never match).
55
+ #
56
+ # @param namespace [String]
57
+ # @param prefix [String] the store's key prefix (Base#prefix)
58
+ # @return [Array(Regexp, Array<String>)] the Regexp, and the globs
59
+ def entry_patterns(namespace, prefix:)
60
+ lead = Regexp.escape(entry_lead(namespace, prefix))
61
+ regexp = /^#{lead}(tenant_.+_)?user_(?!.*_invalidation\z).+\z/
62
+ globs = ["#{entry_lead(namespace, prefix)}user_*", "#{entry_lead(namespace, prefix)}tenant_*_user_*"]
63
+ [regexp, globs]
64
+ end
65
+
66
+ # Two entries no user can have, one of each key shape, under
67
+ # `namespace`: written before a removal by pattern and read after it,
68
+ # they say whether the pattern reached the store's keys at all
69
+ # (Base#clear_entries).
70
+ #
71
+ # @return [Array<String>] the tenant-agnostic and the tenant key
72
+ def canary_keys(namespace)
73
+ [hash_key(CANARY, nil, namespace: namespace), hash_key(CANARY, CANARY, namespace: namespace)]
74
+ end
75
+
76
+ # An id no host key has (not an integer, not a uuid).
77
+ CANARY = 'pf-canary'
78
+
79
+ def entry_lead(namespace, prefix)
80
+ namespace.empty? ? "#{prefix}:" : "#{prefix}:#{namespace}:"
81
+ end
82
+
83
+ # The key of the user's base entry in a tenant — the ambient one unless
84
+ # named; nil: the tenant-agnostic key — in this namespace
85
+ # (PermissionCacheAccessor#permission_cache_key).
86
+ #
87
+ # @param user_id [Object]
88
+ # @param tenant_id [Object, nil]
89
+ # @param namespace [String] ENTRY_NAMESPACE, or a previous one
90
+ # @return [String]
91
+ def hash_key(user_id, tenant_id = PermissionForge.ambient_tenant_id, namespace: ENTRY_NAMESPACE)
92
+ key = tenant_id ? "tenant_#{tenant_id}_user_#{user_id}" : "user_#{user_id}"
93
+ namespace.empty? ? key : "#{namespace}:#{key}"
94
+ end
95
+
96
+ # One per user, whatever the tenant and the version (see Cache::Invalidation).
97
+ #
98
+ # @param user_id [Object]
99
+ # @return [String]
100
+ def stamp_key(user_id)
101
+ "user_#{user_id}_invalidation"
102
+ end
103
+
104
+ # The keys a base entry of the user may be under — in the ambient
105
+ # tenant, in no tenant, and in the tenant the caller named (a callback
106
+ # on a tenant-scoped record) — in this namespace, and in the previous
107
+ # ones.
108
+ #
109
+ # @param user_id [Object]
110
+ # @param tenant_id [Object, nil]
111
+ # @param ambient_key [String] the key under the ambient tenant, when
112
+ # the user instance names its own (#permission_cache_key)
113
+ # @return [Array(Array<String>, Array<String>)] this namespace's keys,
114
+ # the previous namespaces' keys
115
+ def base_keys(user_id, tenant_id: nil, ambient_key: hash_key(user_id))
116
+ current = ([ambient_key] + [nil, tenant_id].uniq.map { |tenant| hash_key(user_id, tenant) }).uniq
117
+ previous = PREVIOUS_ENTRY_NAMESPACES.flat_map { |namespace| current.map { |key| in_namespace(key, namespace) } }
118
+ [current, previous]
119
+ end
120
+
121
+ # `key`, of this namespace, in another one ('' : the bare key of 0.13).
122
+ def in_namespace(key, namespace)
123
+ bare = key.delete_prefix("#{ENTRY_NAMESPACE}:")
124
+ namespace.empty? ? bare : "#{namespace}:#{bare}"
125
+ end
126
+
127
+ # The arguments of the user's Invalidation: the stamp, and every key —
128
+ # this namespace's, then the previous namespaces'.
129
+ #
130
+ # @param user_id [Object]
131
+ # @param tenant_id [Object, nil]
132
+ # @param named [Hash] the keys a user instance names itself, when it
133
+ # does: ambient_key: (#permission_cache_key), stamp_key:
134
+ # (#pf_invalidation_stamp_key)
135
+ # @return [Hash] user_id:, stamp_key:, keys:
136
+ def invalidation(user_id, tenant_id: nil, **named)
137
+ invalidations([user_id], tenant_id: tenant_id, **named).first
138
+ end
139
+
140
+ # The invalidations of many users.
141
+ #
142
+ # @param user_ids [Array]
143
+ # @param tenant_id [Object, nil]
144
+ # @return [Array<Hash>] one per user (see .invalidation)
145
+ def invalidations(user_ids, tenant_id: nil, **named)
146
+ user_ids.map do |user_id|
147
+ current, previous = base_keys(user_id, tenant_id: tenant_id, **named.slice(:ambient_key))
148
+ target(user_id, current + previous, named)
149
+ end
150
+ end
151
+
152
+ # The removals of the per-context variants of many users
153
+ # (Cache::VariantRemoval): for each base entry of this namespace, its
154
+ # index entry and every slot (ConditionalSets.variant_keys). With
155
+ # `indexed_only` (cache:clear, many users) only the base entries whose
156
+ # index entry is found — read in one call — and a user none of whose
157
+ # base entries has one is left out. Without it (erasure, one person)
158
+ # nothing is read: a store that fails a read answers "nothing", and the
159
+ # store may have evicted the index entry.
160
+ #
161
+ # @param user_ids [Array]
162
+ # @param tenant_id [Object, nil]
163
+ # @param named [Hash] ambient_key:, stamp_key:, as for .invalidation;
164
+ # indexed_only: (default true)
165
+ # @return [Array<Hash>] user_id:, stamp_key:, keys: — the arguments of
166
+ # an Invalidation
167
+ def variant_removals(user_ids, tenant_id: nil, **named)
168
+ keyed = user_ids.map { |user_id| [user_id, base_keys(user_id, tenant_id: tenant_id, **named.slice(:ambient_key)).first] }
169
+ indexes = keyed.flat_map { |_user_id, current| current.map { |key| index_key(key) } }
170
+ found = named.fetch(:indexed_only, true) ? read_indexes(indexes) : EVERY_KEY
171
+ keyed.filter_map do |user_id, current|
172
+ keys = slot_keys(current, found)
173
+ target(user_id, keys, named) if keys.any?
174
+ end
175
+ end
176
+
177
+ # Answers every index entry as found.
178
+ EVERY_KEY = Hash.new(true).freeze
179
+
180
+ # The arguments of an Invalidation of the user (stamp_key: as named, else the user's own).
181
+ def target(user_id, keys, named)
182
+ { user_id: user_id, stamp_key: named.fetch(:stamp_key) { stamp_key(user_id) }, keys: keys }
183
+ end
184
+
185
+ # For each base key whose index entry was found: the index entry and every slot.
186
+ def slot_keys(base_keys, found)
187
+ conditional = PermissionForge::ConditionalSets
188
+ base_keys.select { |key| found[index_key(key)] }.flat_map { |key| [index_key(key), *conditional.variant_keys(key)] }
189
+ end
190
+
191
+ def index_key(key)
192
+ PermissionForge::ConditionalSets.index_key(key)
193
+ end
194
+
195
+ # A read that raises tells nothing about the index entries: each counts
196
+ # as found, so every slot is removed rather than none.
197
+ #
198
+ # @return [Hash{String => Object}]
199
+ def read_indexes(keys)
200
+ PermissionForge.cache.read_many(keys)
201
+ rescue StandardError
202
+ keys.to_h { |key| [key, true] }
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Cache
5
+ # Removes the per-context variants of users with conditional permission
6
+ # sets, where the personal data they hold (the profile) must go now:
7
+ # Services::DataSubjectErasure and the enumerated
8
+ # Services::PermissionCacheClearer. An invalidation leaves them to their
9
+ # TTL: no read can reach them once the base entry that carries their
10
+ # token is gone (ConditionalSets).
11
+ #
12
+ # For the clear (indexed_only, many users) the users' index entries are
13
+ # read (UserKeys.variant_removals, one call per SLICE users), and for each
14
+ # one found the index entry and every slot of its base entry go. For an
15
+ # erasure (one person) nothing is read: the index entry and every slot of
16
+ # each of the person's base entries go — a read that fails silently, or
17
+ # an index entry the store evicted, cannot leave a variant behind. Either
18
+ # way the keys are removed the way an invalidation removes them
19
+ # (BulkInvalidation: a fresh stamp first, every answer checked, retries,
20
+ # what fails reported through Cache::Failure and owed).
21
+ #
22
+ # Callers run it after the users' invalidation. A variant whose rebuild
23
+ # read the stamp before that invalidation was then either verified
24
+ # before it — its index entry, written first, is found here — or is
25
+ # discarded by its verification. A rebuild still in flight that reads the
26
+ # stamp after the removal's and writes after it leaves a variant built
27
+ # from the rows as they are then (its profile from that request's user
28
+ # instance, which may predate the change): unreachable, gone with its TTL.
29
+ #
30
+ # @api private
31
+ class VariantRemoval
32
+ def self.call(...) = new(...).call
33
+
34
+ # @param user_ids [Array]
35
+ # @param tenant_id [Object, nil] the tenant whose base keys to include
36
+ # besides the ambient and the tenant-agnostic ones (UserKeys.base_keys)
37
+ # @param indexed_only [Boolean] only the base entries whose index entry
38
+ # is found (false: every base entry, nothing read)
39
+ # @param named [Hash] ambient_key: — the key a single user instance
40
+ # names (#permission_cache_key)
41
+ def initialize(user_ids:, tenant_id: nil, indexed_only: true, **named)
42
+ @user_ids = user_ids
43
+ @tenant_id = tenant_id
44
+ @named = named.merge(indexed_only: indexed_only)
45
+ end
46
+
47
+ # @return [Array] the ids whose removal the store did not confirm
48
+ def call
49
+ @user_ids.each_slice(HolderInvalidation::SLICE).flat_map do |slice|
50
+ BulkInvalidation.unconfirmed(UserKeys.variant_removals(slice, tenant_id: @tenant_id, **@named))
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Cache
5
+ # Says once per process that permission hashes keep being built inside a
6
+ # database transaction that reads from a snapshot, where they are not
7
+ # cached (PermissionCacheAccessor#pf_rebuild_permissions, config.snapshot_transactions).
8
+ #
9
+ # One such rebuild is nothing to mention. A job that only reads
10
+ # permissions inside the transaction of each save, or an application that
11
+ # wraps every request in a transaction, never caches a hash, though, and
12
+ # rebuilds the permissions every time — and nothing else would tell it
13
+ # why. So the rebuilds that were withheld are counted until one runs
14
+ # outside such a transaction again, and the count reaching AFTER is
15
+ # logged, once.
16
+ #
17
+ # @api private
18
+ module WithheldNotice
19
+ AFTER = 20
20
+ MUTEX = Mutex.new
21
+
22
+ class << self
23
+ # @return [void]
24
+ def withheld
25
+ say = MUTEX.synchronize do
26
+ @in_a_row = (@in_a_row || 0) + 1
27
+ @in_a_row == AFTER && !@said && (@said = true)
28
+ end
29
+ log if say
30
+ end
31
+
32
+ # A rebuild ran outside a transaction: the count starts again.
33
+ #
34
+ # @return [void]
35
+ def not_withheld
36
+ MUTEX.synchronize { @in_a_row = 0 }
37
+ end
38
+
39
+ # For tests: the notice can be given again.
40
+ def reset!
41
+ MUTEX.synchronize { @in_a_row = @said = nil }
42
+ end
43
+
44
+ private
45
+
46
+ def log
47
+ return unless defined?(Rails) && Rails.logger
48
+
49
+ Rails.logger.warn(
50
+ "[PermissionForge] The last #{AFTER} permission rebuilds ran inside a database transaction that reads from " \
51
+ 'a snapshot, so none of them was cached: such a hash may be older than a change that was already ' \
52
+ 'invalidated. Read the permissions once (current_user.permissions_hash) before the loop of saves or the ' \
53
+ 'transaction — or, if this database runs at READ COMMITTED, set config.snapshot_transactions = false. ' \
54
+ 'This is logged once per process.'
55
+ )
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Concerns
5
+ # Association extension (`has_many ..., extend: AuditedCollection`) for
6
+ # the collections the gem declares on its models and on the host's User.
7
+ #
8
+ # Rails' `collection.delete_all` and `collection.clear` remove rows with
9
+ # one SQL statement and run no callbacks — even under
10
+ # `dependent: :destroy` — so an assignment or membership removed that way
11
+ # would leave no audit event and keep the person's cached permissions.
12
+ # Here they remove each record the way `collection.delete(record)` does
13
+ # (destroy under `dependent: :destroy`, the `before_remove` callbacks
14
+ # otherwise), inside one transaction.
15
+ #
16
+ # An explicit strategy (`delete_all(:delete_all)`, `delete_all(:nullify)`)
17
+ # and a narrowed relation (`collection.where(...).delete_all`, which
18
+ # Rails extends with this module too) are the caller asking for SQL and
19
+ # are left to Rails, like `Model.where(...).delete_all`.
20
+ #
21
+ # @api public
22
+ module AuditedCollection
23
+ # @return [Integer] the number of records removed
24
+ def delete_all(dependent = nil)
25
+ return super if dependent
26
+ return super() unless is_a?(ActiveRecord::Associations::CollectionProxy)
27
+
28
+ records = load_target.dup
29
+ delete(*records) if records.any?
30
+ records.size
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Concerns
5
+ # Level 1 — ENFORCE (config.auto_query_scoping = :filter_only).
6
+ #
7
+ # Class methods backing the default_scope installed by
8
+ # {PermissionScopedFindable}: every relation built on a protected model
9
+ # applies the current user's FILTER-type rules automatically. Extracted
10
+ # from PermissionScopedFindable for SRP — this module owns the auto-scope
11
+ # decision tree, the host concern owns find_by/permission_scoped.
12
+ #
13
+ # Every path that lets a query past the rules while Level 1 is on is
14
+ # published as rule.bypassed (scope_suspended, auto_scope_opt_out,
15
+ # nil_user, view_all, modify_all, no_rules) and a nil user under :deny /
16
+ # :raise as access.denied — at most once per request per model, reason
17
+ # and user (Audit::Runtime.once?): this runs on every relation built.
18
+ #
19
+ # @api private
20
+ module AutoPermissionScoping
21
+ extend ActiveSupport::Concern
22
+
23
+ # Set while the gem suspends Level 1 for its own queries
24
+ # (internally_suspended): that suspension is not the host's, is not
25
+ # reported, and gives nobody signed in no bypass.
26
+ INTERNAL_SUSPENSION_KEY = :pf_internal_scope_suspension
27
+
28
+ # Set while the default scope builds the rules (re-entrancy guard).
29
+ IN_HOOK_KEY = :pf_in_auto_scope_hook
30
+
31
+ # The gem's own suspension of the scoping, for the queries it makes for
32
+ # itself and the host code they run (callbacks, default scopes): Level 1
33
+ # and detection are off, as in the host's block, but it is not the
34
+ # host's opt-out — not reported as scope_suspended, and nobody signed in
35
+ # gets no bypass of nil_user_strategy. Both flags are :fiber slots of
36
+ # ExecutionState, so they move together.
37
+ def self.internally_suspended(&)
38
+ PermissionForge::ExecutionState.with(INTERNAL_SUSPENSION_KEY, true) do
39
+ PermissionForge.without_permission_scope(&)
40
+ end
41
+ end
42
+
43
+ # @return [Boolean] whether the gem suspended the scoping for itself
44
+ def self.internally_suspended?
45
+ PermissionForge::ExecutionState[INTERNAL_SUSPENSION_KEY] == true
46
+ end
47
+
48
+ # @return [Boolean] whether the host suspended the scoping
49
+ # (PermissionForge.without_permission_scope), as opposed to the gem
50
+ # for its own queries
51
+ def self.host_suspended?
52
+ PermissionForge.scope_suspended? && !internally_suspended?
53
+ end
54
+
55
+ class_methods do
56
+ # Level 1 hook body. Public because the default_scope block delegates
57
+ # to the model class through Relation#method_missing (public dispatch).
58
+ #
59
+ # Every path taken while Level 1 is enabled annotates the relation as
60
+ # scoped — including bypasses (view_all, no rules, nil-user :bypass):
61
+ # the query went through the permission check, so Level 2 detection
62
+ # must treat it as compliant and flag only true escapes
63
+ # (unscoped, find_by_sql, raw SQL).
64
+ #
65
+ # @return [ActiveRecord::Relation]
66
+ def pf_auto_permission_scope
67
+ return all unless pf_auto_scope_applicable?
68
+ # Re-entrancy guard: current_user_resolver or a permissions rebuild
69
+ # may itself query a protected model — that inner query runs unscoped
70
+ # (identity resolution precedes authorization) instead of recursing.
71
+ return all.annotate(PermissionScopedFindable::SCOPED_ANNOTATION) if PermissionForge::ExecutionState[IN_HOOK_KEY]
72
+
73
+ PermissionForge::ExecutionState.with(IN_HOOK_KEY, true) do
74
+ pf_build_auto_scope.annotate(PermissionScopedFindable::SCOPED_ANNOTATION)
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ # The same three conditions as ever; the order only decides which
81
+ # bypass is reported (none while Level 1 is off).
82
+ def pf_auto_scope_applicable?
83
+ return false unless PermissionForge.configuration.auto_query_scoping_enabled?
84
+
85
+ if PermissionForge.scope_suspended?
86
+ pf_auto_scope_bypassed(:scope_suspended) if AutoPermissionScoping.host_suspended?
87
+ return false
88
+ end
89
+ return true if permission_forge_auto_scope
90
+
91
+ pf_auto_scope_bypassed(:auto_scope_opt_out)
92
+ false
93
+ end
94
+
95
+ def pf_auto_scope_bypassed(reason, user = nil)
96
+ return unless PermissionForge::Audit::Runtime::BYPASS_GATE.open?
97
+
98
+ PermissionForge::Audit::Runtime.bypassed(reason, model: self, via: 'auto_scope', user: user, once: true)
99
+ end
100
+
101
+ # The rules do not apply: reported, and the relation as it is.
102
+ def pf_unscoped_by(reason, user)
103
+ pf_auto_scope_bypassed(reason, user)
104
+ all
105
+ end
106
+
107
+ def pf_build_auto_scope
108
+ current_user = PermissionForge.current_user
109
+ return pf_nil_user_auto_scope unless current_user
110
+
111
+ # Resolve the rules once — permissions_hash is a cache read per
112
+ # call, and this hook runs on every relation build: those of this
113
+ # class's Resource entry and of its STI ancestors' (ResourceChain).
114
+ # view_all/modify_all bypasses rules (parity with find_by). The
115
+ # on_rule_bypass hook is deliberately NOT fired here — it would fire
116
+ # on every query the user runs; rule.bypassed is, once per request.
117
+ rules, bypass = PermissionForge::ResourceChain.rules(current_user, PermissionForge::ResourceChain.names(self))
118
+ return pf_unscoped_by(bypass, current_user) if bypass
119
+
120
+ rules = rules.select { |rule| pf_filter_rule?(rule) }
121
+ return pf_unscoped_by(:no_rules, current_user) if rules.empty?
122
+
123
+ # INVARIANT: PermissionRuleApplier builds from @model_class.where(nil),
124
+ # which inside this default_scope block resolves against the in-flight
125
+ # current_scope — preserving acts_as_tenant and any host default_scope
126
+ # (soft-delete) in either declaration order. Never change the applier
127
+ # to unscoped/fresh relations.
128
+ PermissionForge::Services::PermissionRuleApplier.scope(
129
+ model_class: self, rules: rules, current_user: current_user
130
+ )
131
+ end
132
+
133
+ # Fail closed for :deny AND :raise — raising from inside a default_scope
134
+ # would detonate in consoles, rake tasks, and view-layer counts, so the
135
+ # strictest safe behavior is an empty relation (documented deviation).
136
+ def pf_nil_user_auto_scope
137
+ case PermissionForge.configuration.nil_user_strategy
138
+ when :deny, :raise
139
+ PermissionForge::Audit::Runtime.denied(PermissionForge::Audit::Runtime::DENIED, reason: :nil_user, model: self,
140
+ via: 'auto_scope', once: true)
141
+ none
142
+ else # :bypass
143
+ pf_auto_scope_bypassed(:nil_user)
144
+ all
145
+ end
146
+ end
147
+
148
+ # Filter-type rules only: join-type rules inside a default_scope would
149
+ # break includes/count/pluck and duplicate rows, so they remain the job
150
+ # of permission_scoped (a boot warning fires when join rules exist while
151
+ # Level 1 is enabled).
152
+ def pf_filter_rule?(rule)
153
+ PermissionForge::PermissionSetRule.filter_join_type?(rule[:join_type])
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Concerns
5
+ # Runtime guard for host controllers: checks every action against the
6
+ # current user's Permission Sets before it runs.
7
+ #
8
+ # class CustomersController < ApplicationController
9
+ # include PermissionForge::Concerns::ControllerActionGuard
10
+ # end
11
+ #
12
+ # class Customers::MergesController < ApplicationController
13
+ # include PermissionForge::Concerns::ControllerActionGuard
14
+ # permission_forge_actions resource: 'Customer', actions: { create: :merge }
15
+ # end
16
+ #
17
+ # The guard never renders or redirects. A denied action raises
18
+ # PermissionForge::ActionNotAuthorizedError — rescue it in the host app:
19
+ #
20
+ # rescue_from PermissionForge::ActionNotAuthorizedError do
21
+ # head :forbidden
22
+ # end
23
+ #
24
+ # The decision itself lives in {PermissionForge::ControllerActions::Authorizer}.
25
+ #
26
+ # @api public
27
+ module ControllerActionGuard
28
+ extend ActiveSupport::Concern
29
+
30
+ included do
31
+ # The controller's declaration, in the shape ControllerActions::Mapping
32
+ # reads. Class-level only: a public instance reader would be routable
33
+ # as a controller action.
34
+ class_attribute :pf_action_guard_options, instance_accessor: false, instance_predicate: false,
35
+ default: PermissionForge::ControllerActions::EMPTY_OPTIONS
36
+
37
+ before_action :pf_authorize_action!
38
+ end
39
+
40
+ class_methods do
41
+ # Declares which Resource and action keys this controller's actions
42
+ # mean. Subclasses inherit the declaration. Declaring again — in the
43
+ # same class or a subclass — adds to it: a given resource or
44
+ # rest_actions replaces, actions are merged.
45
+ #
46
+ # @param resource [String, Symbol, Class, nil] Resource name; nil keeps
47
+ # the convention (controller name → model)
48
+ # @param actions [Hash] controller action name => action key
49
+ # (`{ create: :merge }`); a CRUD word (`:edit`) checks that CRUD flag
50
+ # @param rest_actions [Boolean, nil] false leaves index/show/new/create/
51
+ # edit/update/destroy unchecked unless listed in `actions`; nil keeps
52
+ # the current setting (checked, unless an earlier declaration said false)
53
+ # @return [Hash] the normalized, frozen declaration
54
+ # @raise [ArgumentError] when the resource or a key is not a valid name
55
+ def permission_forge_actions(resource: nil, actions: {}, rest_actions: nil)
56
+ self.pf_action_guard_options = PermissionForge::ControllerActions.merge_options(
57
+ pf_action_guard_options, resource: resource, actions: actions, rest_actions: rest_actions
58
+ )
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ # A denial is published as action.denied, an allowed request that
65
+ # skipped the Permission Sets (no user under :bypass, the bypass lambda,
66
+ # an action nobody manages) as rule.bypassed.
67
+ #
68
+ # @raise [PermissionForge::ActionNotAuthorizedError] when the current
69
+ # user may not run this action
70
+ def pf_authorize_action!
71
+ user = PermissionForge.current_user
72
+ decision = PermissionForge::ControllerActions::Authorizer.call(
73
+ controller_path: controller_path, action_name: action_name, controller: self, user: user
74
+ )
75
+ return pf_action_allowed(decision, user) if decision.allowed?
76
+
77
+ target = decision.target
78
+ pf_action_denied(decision, user)
79
+ raise PermissionForge::ActionNotAuthorizedError.new(
80
+ resource: target.resource, action: target.action, controller_path: controller_path,
81
+ reason: decision.reason, user: user
82
+ )
83
+ end
84
+
85
+ def pf_action_allowed(decision, user)
86
+ reason = decision.bypass_reason
87
+ return unless reason
88
+
89
+ PermissionForge::Audit::Runtime.bypassed(reason, model: decision.target.resource, via: 'action_guard', user: user,
90
+ data: pf_action_event_data(decision))
91
+ end
92
+
93
+ def pf_action_denied(decision, user)
94
+ PermissionForge::Audit::Runtime.denied(
95
+ PermissionForge::Audit::Runtime::ACTION_DENIED, reason: decision.reason, model: decision.target.resource,
96
+ via: 'action_guard', user: user,
97
+ data: pf_action_event_data(decision)
98
+ )
99
+ end
100
+
101
+ def pf_action_event_data(decision)
102
+ { 'action' => decision.target.action, 'controller' => controller_path, 'action_name' => action_name }
103
+ end
104
+ end
105
+ end
106
+ end