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,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # What the permission records committed by one transaction have already
5
+ # invalidated, so the next record of that commit does not repeat it: a
6
+ # "Review and save" that writes 20 records in a Permission Set assigned to
7
+ # 50 people invalidates 50 hashes, not 1,000.
8
+ #
9
+ # A batch never holds work that only it knows about. Each record still
10
+ # invalidates in its own after_commit (Concerns::InvalidatesInBatch); the
11
+ # batch remembers what was done after the commit — which users (#once) and
12
+ # which affected-user lookups (#fetch) — and keeps a second copy of what
13
+ # records noted for the commit (#note). Mistrusting or losing a batch
14
+ # therefore costs repeated invalidations, never one a record would have
15
+ # made on its own.
16
+ #
17
+ # Lifecycle:
18
+ # 1. A record joins the batch still open on its thread and connection when
19
+ # it is written (.join). An open batch has been used by no commit, so
20
+ # it remembers nothing — joining one cannot bring anything stale along.
21
+ # 2. The first after_commit of a record that joined claims it (#claim):
22
+ # from then on nothing can join, and the batch notes where the claim
23
+ # happened — the transaction depth of its connection and the ambient
24
+ # tenant.
25
+ # 3. Later records of that commit claim it from the same place and reuse
26
+ # what it remembers. A claim from anywhere else is refused, and that
27
+ # record invalidates on its own.
28
+ #
29
+ # Why the depth: a batch must only be reused by records whose transaction had
30
+ # committed before the batch remembered anything. While a record's
31
+ # transaction is open it sits on the connection's transaction stack; its
32
+ # after_commit runs once it has been popped. So everything remembered at the
33
+ # depth a record sees in its own after_commit was remembered after that
34
+ # record's transaction left the stack — after its commit. Without the check,
35
+ # a transaction nested in a non-joinable one (it runs its commit callbacks
36
+ # while the outer transaction is still open) would cover for the outer
37
+ # transaction's records, whose rows are not visible to anyone else yet.
38
+ #
39
+ # Why the tenant: lookups and cache keys follow the ambient tenant, so what
40
+ # was remembered under one tenant says nothing about another.
41
+ #
42
+ # What it remembers is forgotten as soon as another permission record is
43
+ # written (.note_write): the commit's after_commit callbacks are not the only
44
+ # code that runs between two of them. A callback in between may write a
45
+ # record whose own invalidation comes too early to count — in a transaction
46
+ # nested in a non-joinable one it runs at the savepoint release, before the
47
+ # COMMIT anyone else can see. The records still waiting then invalidate
48
+ # again, after that write, as they did before there were batches. The count
49
+ # of writes is kept for the whole process: such a write may be made in
50
+ # another fiber or thread than the one the commit runs on.
51
+ # (Notes — #note — are kept: they say what has to be done, not what was.)
52
+ #
53
+ # @api private
54
+ class InvalidationBatch
55
+ # The ExecutionState slot of the batch still open on this thread (fiber
56
+ # under Rails' fiber isolation).
57
+ THREAD_KEY = :pf_open_invalidation_batch
58
+
59
+ # Guards @writes increments; reads are lock-free.
60
+ WRITES_MUTEX = Mutex.new
61
+
62
+ class << self
63
+ # The batch a record being written on `connection` joins: the one still
64
+ # open on this thread for that connection, else a new one.
65
+ #
66
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
67
+ # @return [InvalidationBatch]
68
+ def join(connection)
69
+ note_write
70
+ current = ExecutionState[THREAD_KEY]
71
+ return current if current&.open_on?(connection)
72
+
73
+ ExecutionState[THREAD_KEY] = new(connection)
74
+ end
75
+
76
+ # A permission record is being written somewhere in this process: every
77
+ # batch forgets what it remembers before it is reused (see #claim).
78
+ #
79
+ # @return [void]
80
+ def note_write
81
+ WRITES_MUTEX.synchronize { @writes = writes + 1 }
82
+ end
83
+
84
+ # @return [Integer] permission-record writes this process has made
85
+ def writes
86
+ @writes || 0
87
+ end
88
+ end
89
+
90
+ # Without a connection the batch stands alone: it can be used at once and
91
+ # never joined or claimed (an invalidation outside any commit).
92
+ def initialize(connection = nil)
93
+ @connection = connection
94
+ @claimed_at = nil
95
+ @writes_seen = nil
96
+ @lookups = {}
97
+ @done = Set.new
98
+ @noted = {}
99
+ end
100
+
101
+ # @return [Boolean] whether a record written on `connection` may still join
102
+ def open_on?(connection)
103
+ !@claimed_at && @connection.equal?(connection)
104
+ end
105
+
106
+ # Called from a record's after_commit, before it uses the batch.
107
+ #
108
+ # @return [InvalidationBatch, nil] the batch when the commit being reported
109
+ # may reuse it; nil when it was claimed from somewhere else
110
+ def claim
111
+ return unless @connection
112
+
113
+ place = [@connection.open_transactions, PermissionForge.ambient_tenant_id]
114
+ unless @claimed_at
115
+ @claimed_at = place
116
+ release_thread
117
+ end
118
+ return unless @claimed_at == place
119
+
120
+ forget_if_written_since
121
+ self
122
+ end
123
+
124
+ # A lookup shared by the records of the commit.
125
+ #
126
+ # @param key [Object] what is looked up (e.g. [:assignments, permission_set_id])
127
+ # @return [Object] the block's value, from the first call for this key
128
+ def fetch(key)
129
+ return @lookups[key] if @lookups.key?(key)
130
+
131
+ @lookups[key] = yield
132
+ end
133
+
134
+ # Runs an invalidation unless the commit has already run it. The key is
135
+ # only remembered once the block has returned: an invalidation that raised
136
+ # was not done.
137
+ #
138
+ # @param key [Object] what is invalidated (e.g. [:user, user_id, tenant_id])
139
+ # @return [void]
140
+ def once(key)
141
+ return if @done.include?(key)
142
+
143
+ yield
144
+ @done << key
145
+ end
146
+
147
+ # Remembers an invalidation the commit has made without #once (many
148
+ # users in one call, Cache::HolderInvalidation) — after it was made.
149
+ #
150
+ # @param key [Object] what was invalidated (e.g. [:user, user_id, tenant_id])
151
+ # @return [void]
152
+ def done!(key)
153
+ @done << key
154
+ end
155
+
156
+ # @return [Boolean] whether #once (or #done!) has already run for this key
157
+ def done?(key)
158
+ @done.include?(key)
159
+ end
160
+
161
+ # Says that something has to be invalidated when the transaction commits.
162
+ # This never replaces the note a record keeps for itself — a record does
163
+ # its own invalidation in its own after_commit, whatever becomes of the
164
+ # batch. It is for the case that after_commit never runs: of two instances
165
+ # of one row written in a transaction, Rails runs the callbacks of one,
166
+ # and that one can find here what the other noted.
167
+ #
168
+ # @param kind [Symbol] which cache (e.g. :managed_actions)
169
+ # @param value [Object] what of it (e.g. the tenant id)
170
+ # @return [void]
171
+ def note(kind, value)
172
+ (@noted[kind] ||= Set.new) << value
173
+ end
174
+
175
+ # @param kind [Symbol]
176
+ # @return [Array] what the records that joined have noted for this cache
177
+ def noted(kind)
178
+ @noted.fetch(kind, []).to_a
179
+ end
180
+
181
+ # A record can be marshalled (cached) while it still references the batch
182
+ # it joined. Nothing of a batch is worth keeping, and its connection cannot
183
+ # be dumped: the copy stands alone.
184
+ def marshal_dump = nil
185
+
186
+ def marshal_load(_state)
187
+ initialize
188
+ end
189
+
190
+ private
191
+
192
+ def forget_if_written_since
193
+ writes = self.class.writes
194
+ return if @writes_seen == writes
195
+
196
+ @writes_seen = writes
197
+ @lookups.clear
198
+ @done.clear
199
+ end
200
+
201
+ # So the thread keeps nothing of a claimed batch (it is never joined again).
202
+ def release_thread
203
+ ExecutionState[THREAD_KEY] = nil if ExecutionState[THREAD_KEY].equal?(self)
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # Auto-applied masking: mixes MaskedAttributes into the models
5
+ # config.auto_apply_masked_attributes selects and wraps the virtual readers
6
+ # of the models config.mask_virtual_attributes selects. Each setting takes
7
+ # its models from the Resource rows (:auto / true) or from a list. Called
8
+ # by MaskingSync (at boot, after a Resource row names a model, and when
9
+ # another process named one).
10
+ #
11
+ # @api private
12
+ module MaskedModels
13
+ # Setting values that switch a step off.
14
+ OFF = [false, nil].freeze
15
+
16
+ class << self
17
+ # @return [Boolean] whether either setting is on (MaskingSync does
18
+ # nothing otherwise)
19
+ def enabled?(config = PermissionForge.configuration)
20
+ settings(config).any? { |setting| !OFF.include?(setting) }
21
+ end
22
+
23
+ # Whether a setting that is on takes its models from the Resource rows:
24
+ # a row committed in another process can then name a model this process
25
+ # has not masked. False when every setting that is on is a list — the
26
+ # models are fixed by the configuration.
27
+ #
28
+ # @return [Boolean]
29
+ def row_driven?(config = PermissionForge.configuration)
30
+ settings(config).any? { |setting| !OFF.include?(setting) && !setting.is_a?(Array) }
31
+ end
32
+
33
+ # Includes the concern on every selected model, then wraps the virtual
34
+ # readers of every selected model that has it (applied here, or
35
+ # included by the model itself). Idempotent. A model that cannot be
36
+ # masked (its table is missing, its database is down) is logged and
37
+ # skipped: the others are still masked.
38
+ #
39
+ # @param only [String, nil] one model, as long as the settings select it
40
+ # @return [Boolean] true when every selected model is masked; false when
41
+ # the Resource rows could not be read (no table yet, no database), a
42
+ # model could not be masked, or a listed name is no model here
43
+ # (MaskingSync then tries again)
44
+ def apply(only: nil)
45
+ config = PermissionForge.configuration
46
+ return true unless enabled?(config)
47
+ return false unless PermissionForge::Resource.table_exists?
48
+
49
+ apply_selected(config, only).all? & unknown_listed_names(config, only).empty?
50
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
51
+ # Tables may not exist yet during early boot or pre-migration.
52
+ false
53
+ end
54
+
55
+ # @return [Boolean, nil] false when the model is left unmasked
56
+ def apply_concern(model_name)
57
+ klass = model_class(model_name)
58
+ return if klass.nil? || klass.abstract_class?
59
+ return if masked_class?(klass)
60
+
61
+ # Including the concern runs its `included` block after the module is
62
+ # in the ancestors: a block that failed there (the table could not be
63
+ # read) leaves a class every later include skips. Never start one
64
+ # that cannot finish.
65
+ why = unmaskable_reason(klass)
66
+ if why
67
+ warn_unmaskable(klass, why)
68
+ return false
69
+ end
70
+
71
+ klass.include(PermissionForge::Concerns::MaskedAttributes)
72
+ unmaskable_warned.delete(klass.name)
73
+ end
74
+
75
+ # Wraps the virtual readers of a model that has MaskedAttributes. The
76
+ # wrappers re-check config.mask_virtual_attributes on every call. A
77
+ # model whose extra_fields_method raises keeps its declared readers
78
+ # unwrapped, and pf_mask_virtual_readers logs a SECURITY warning.
79
+ #
80
+ # @return [Array<String>, nil] the names wrapped
81
+ def mask_virtual_readers(model_name)
82
+ klass = model_class(model_name)
83
+ return unless klass.respond_to?(:pf_mask_virtual_readers)
84
+ return unless PermissionForge.configuration.mask_virtual_attributes_for?(klass.name)
85
+
86
+ klass.pf_mask_virtual_readers
87
+ end
88
+
89
+ # Whether the model `model_name` names has the concern in this process
90
+ # (applied, or included by the model or a class it inherits from);
91
+ # false for a name that resolves to no model here.
92
+ #
93
+ # @param model_name [String]
94
+ # @return [Boolean]
95
+ def masked?(model_name)
96
+ klass = model_class(model_name)
97
+ klass ? masked_class?(klass) : false
98
+ end
99
+
100
+ # Warns again about a model that still cannot be masked (MaskingSync.reset!).
101
+ def reset_warnings!
102
+ unmaskable_warned.clear
103
+ end
104
+
105
+ private
106
+
107
+ def settings(config)
108
+ [config.auto_apply_masked_attributes, config.mask_virtual_attributes]
109
+ end
110
+
111
+ # The concern on every selected model, then the virtual readers (the
112
+ # names of both read first).
113
+ #
114
+ # @return [Array<Boolean>] whether each step left its model masked
115
+ def apply_selected(config, only)
116
+ concern = model_names(config.auto_apply_masked_attributes, only: only)
117
+ virtual = model_names(config.mask_virtual_attributes, only: only)
118
+ concern.map { |name| guarded(name) { apply_concern(name) } } +
119
+ virtual.map { |name| guarded(name) { mask_virtual_readers(name) } }
120
+ end
121
+
122
+ # One model's failure is logged and the loop goes on; a
123
+ # ConfigurationError (the setup is wrong, not the model) still stops the
124
+ # boot, as everywhere in BootChecks.
125
+ def guarded(name)
126
+ yield != false
127
+ rescue PermissionForge::ConfigurationError
128
+ raise
129
+ rescue StandardError => e
130
+ warn_unmaskable(model_class(name) || name, "#{e.class}: #{e.message}")
131
+ false
132
+ end
133
+
134
+ # The models a setting selects: none when it is off, its list, or the
135
+ # names of the Resource rows — narrowed to `only`.
136
+ def model_names(setting, only: nil)
137
+ return [] if OFF.include?(setting)
138
+ return listed_names(setting, only) if setting.is_a?(Array)
139
+
140
+ only ? Array(only).map(&:to_s) : resource_names
141
+ end
142
+
143
+ def listed_names(list, only)
144
+ list = list.map(&:to_s)
145
+ only ? Array(only).map(&:to_s) & list : list
146
+ end
147
+
148
+ # The names a list setting gives that resolve to no model here (a typo,
149
+ # a class not loaded yet), each logged once. A name a Resource row
150
+ # gives (:auto) may name a controller-only resource: not reported.
151
+ #
152
+ # @return [Array<String>]
153
+ def unknown_listed_names(config, only)
154
+ { 'auto_apply_masked_attributes' => config.auto_apply_masked_attributes,
155
+ 'mask_virtual_attributes' => config.mask_virtual_attributes }.flat_map do |setting, value|
156
+ next [] unless value.is_a?(Array)
157
+
158
+ listed_names(value, only).reject { |name| model_class(name) }.each { |name| warn_unknown(setting, name) }
159
+ end
160
+ end
161
+
162
+ def warn_unknown(setting, name)
163
+ return if unmaskable_warned.put_if_absent("#{setting}=#{name}", true)
164
+
165
+ Rails.logger&.warn("[PermissionForge] SECURITY warning: '#{name}' is listed in config.#{setting} but is no " \
166
+ 'model in this process: nothing is masked for it. It is tried again at the next request or job.')
167
+ end
168
+
169
+ # Every tenant's rows: a model is masked for the whole process.
170
+ def resource_names
171
+ if PermissionForge.configuration.tenant_enabled? && defined?(ActsAsTenant)
172
+ ActsAsTenant.without_tenant { PermissionForge::Resource.distinct.pluck(:name) }
173
+ else
174
+ PermissionForge::Resource.distinct.pluck(:name)
175
+ end
176
+ end
177
+
178
+ # The class, or a class it inherits from, has the concern applied. A
179
+ # subclass is masked through its parent (the concern is never included
180
+ # into a subclass) and may have a Resource row of its own (STI).
181
+ def masked_class?(klass)
182
+ PermissionForge::Concerns::MaskedAttributes.applied_in_chain?(klass)
183
+ end
184
+
185
+ # Only reached when no class in the chain has the concern applied: the
186
+ # module in the ancestors then means an include that failed halfway.
187
+ #
188
+ # @return [String, nil] why the concern cannot be included now
189
+ def unmaskable_reason(klass)
190
+ return 'its table cannot be read' unless readable_table?(klass)
191
+
192
+ 'an earlier attempt failed halfway; restart the process' if klass.include?(PermissionForge::Concerns::MaskedAttributes)
193
+ end
194
+
195
+ # The table exists and can be read. A table found missing is looked up
196
+ # again next time (the cached answer is dropped): a migration may run.
197
+ def readable_table?(klass)
198
+ return true if klass.table_exists?
199
+
200
+ klass.reset_column_information
201
+ false
202
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
203
+ false
204
+ end
205
+
206
+ # Logs once per model and process (MaskingSync retries every request).
207
+ def warn_unmaskable(klass, why)
208
+ name = klass.to_s
209
+ return if unmaskable_warned.put_if_absent(name, true)
210
+
211
+ Rails.logger&.warn("[PermissionForge] SECURITY warning: #{name} has a Resource row but #{why}: its columns " \
212
+ 'are NOT masked in this process. It is tried again at the next request or job.')
213
+ end
214
+
215
+ # The models (and unknown listed names) whose warning this process has logged.
216
+ def unmaskable_warned
217
+ @unmaskable_warned ||= Concurrent::Map.new
218
+ end
219
+
220
+ def model_class(model_name)
221
+ klass = model_name.to_s.safe_constantize
222
+ klass if klass.is_a?(Class) && klass < ActiveRecord::Base
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module PermissionForge
6
+ # Carries auto-applied masking (MaskedModels) to every process. The
7
+ # concern is mixed into a model class in the process that loads it: at
8
+ # boot, and in the process that commits the first Resource row for a new
9
+ # model. Every other process would serve that model unmasked until it
10
+ # restarts — unless it hears of it (docs/masked-attributes.md).
11
+ #
12
+ # When a setting takes its models from the Resource rows
13
+ # (MaskedModels.row_driven?):
14
+ # - Writer: the process that commits a row naming a model for the first
15
+ # time (no other row in any tenant has the name, or this process had
16
+ # not masked it: two rows committed together) applies the concern and
17
+ # writes a new random token under KEY in the shared store, no expiry.
18
+ # - Reader: each executor run (request or job) reads KEY once (check!);
19
+ # a token it has not synced with makes it apply to every named model.
20
+ # - A process that reads no token (evicted, flushed) writes one and
21
+ # syncs; every other process then syncs once too. Losing the token
22
+ # costs one `pluck` per process, never a model left unmasked.
23
+ # - A store that does not answer is left alone for RETRY_PAUSE seconds,
24
+ # then one request or job per process probes it; logged once per
25
+ # outage. An announcement the store did not take is owed and written
26
+ # once it answers.
27
+ # When every setting that is on is a list, the models are fixed by the
28
+ # configuration: no token is read or written, and a process that has
29
+ # synced does nothing per request. When both are off, check! returns
30
+ # before reading anything (the hook stays: a setting may be switched on
31
+ # after the boot step).
32
+ #
33
+ # Either way a process that has not synced (its boot could not reach the
34
+ # database, a model could not be masked) applies at its next executor
35
+ # run, and a development code reload applies again (`to_prepare`).
36
+ # The store must be shared (not :memory or :null).
37
+ #
38
+ # @api private
39
+ module MaskingSync
40
+ KEY = 'masked_models_version'
41
+
42
+ # The token of a process that has not synced yet (no token can equal it).
43
+ UNSYNCED = Object.new.freeze
44
+
45
+ # Seconds the store is left alone after it did not answer.
46
+ RETRY_PAUSE = 30
47
+
48
+ # One sync (or apply) at a time; one probe of a failing store at a time.
49
+ SYNC_LOCK = Mutex.new
50
+ PROBE_LOCK = Mutex.new
51
+
52
+ # The token this process synced with (UNSYNCED, or nil for "synced
53
+ # without one"), whether it owes the store an announcement, and until
54
+ # when (monotonic seconds) which store is left alone. A failure while
55
+ # @failing_until is set belongs to the outage already logged.
56
+ @token = UNSYNCED
57
+ @announcement_owed = false
58
+ @failing_until = nil
59
+ @failing_store = nil
60
+
61
+ class << self
62
+ # Executor `to_run`. Row-driven: one store read; applies when another
63
+ # process named a model since this one last synced. The store is not
64
+ # read while it is failing (RETRY_PAUSE), nor at all for lists: the
65
+ # token is then unknown (nil), and only a process that has not synced
66
+ # applies (from the database). Never raises.
67
+ def check!
68
+ return unless MaskedModels.enabled?
69
+
70
+ token = MaskedModels.row_driven? ? answered_token : nil
71
+ sync(token) if changed?(token)
72
+ rescue StandardError => e
73
+ Rails.logger&.error("[PermissionForge] auto-applied masking could not be synced — #{e.class}: #{e.message}")
74
+ end
75
+
76
+ # After a Resource row naming `name` has been created or renamed and
77
+ # committed: tells the other processes (a new token) when the name is
78
+ # new to them — no other row has it — or when this process had not
79
+ # masked the model before: rows committed together each see the
80
+ # others, and the first to get here announces. Otherwise, and when
81
+ # the settings are lists, applies the concern here only.
82
+ #
83
+ # @param name [String] the model the row names
84
+ # @yieldreturn [Boolean] whether another Resource row has the name
85
+ # (asked only when it decides something)
86
+ def named!(name)
87
+ return unless MaskedModels.enabled?
88
+ return apply!(name) unless MaskedModels.row_driven?
89
+
90
+ masked_here?(name) && yield ? apply!(name) : announce!(name)
91
+ end
92
+
93
+ # The boot step: applies to every selected model and syncs with the
94
+ # current token (read first, so a name committed meanwhile is seen
95
+ # again by the next check). When a model could not be masked (or the
96
+ # rows could not be read) the process stays unsynced and the next
97
+ # request or job tries again.
98
+ #
99
+ # @return [Boolean] whether every selected model is masked
100
+ def boot!
101
+ done = sync(MaskedModels.row_driven? ? current_token : nil, always: true)
102
+ unless done
103
+ Rails.logger&.warn('[PermissionForge] Auto-applied masking is not complete at boot (see the warnings ' \
104
+ 'above, or the Resource table cannot be read): it is tried again at each request or job.')
105
+ end
106
+ done
107
+ end
108
+
109
+ # `to_prepare`: after a code reload in development the model classes
110
+ # are new and unmasked. At boot it runs before the boot step: nothing
111
+ # to re-apply yet.
112
+ def reloaded!
113
+ return unless Rails.application&.initialized?
114
+
115
+ reset!
116
+ check!
117
+ end
118
+
119
+ # Forgets the token (the next check applies whatever the token says)
120
+ # and that the store failed. Also what a boot that could not reach the
121
+ # database leaves (BootChecks).
122
+ def reset!
123
+ SYNC_LOCK.synchronize { @token = UNSYNCED }
124
+ @announcement_owed = false
125
+ @failing_until = nil
126
+ MaskedModels.reset_warnings!
127
+ end
128
+
129
+ # @return [Boolean] whether this process has synced once
130
+ def synced?
131
+ !@token.equal?(UNSYNCED)
132
+ end
133
+
134
+ # @return [Boolean] whether the store recently did not answer (the
135
+ # store in use: one that replaced it has not failed yet)
136
+ def store_failing?
137
+ until_time = @failing_until
138
+ !until_time.nil? && now < until_time && PermissionForge.cache.equal?(@failing_store)
139
+ end
140
+
141
+ private
142
+
143
+ # Applies the concern to one model in this process.
144
+ def apply!(name)
145
+ SYNC_LOCK.synchronize { MaskedModels.apply(only: name) }
146
+ end
147
+
148
+ # Applies the concern here, then tells the other processes. The token
149
+ # it writes is not taken as this process's own: another process may
150
+ # have written one since this one last checked, so the next check
151
+ # syncs once more.
152
+ #
153
+ # A token the store does not take (it is down, or raises) is owed:
154
+ # this process writes a new one at its first request or job after the
155
+ # store answers again (after RETRY_PAUSE). Never raises for the store.
156
+ def announce!(name)
157
+ apply!(name)
158
+ return if publish_token
159
+
160
+ Rails.logger&.warn('[PermissionForge] The cache store did not take the masking token: other processes ' \
161
+ "mask #{name} once the store answers again and this process has written it.")
162
+ end
163
+
164
+ # The token to check against — nil while the store is failing. A
165
+ # token this process owes the store (an announcement it did not take)
166
+ # is written first, once the store answers.
167
+ #
168
+ # @return [String, nil]
169
+ def answered_token
170
+ return if store_failing?
171
+ return owed_or_current_token unless @failing_until
172
+
173
+ probe_store
174
+ end
175
+
176
+ # The pause after a failure is over: one thread asks the store again;
177
+ # the others keep the pause (nil) instead of each waiting for a store
178
+ # that may still be gone.
179
+ def probe_store
180
+ return unless PROBE_LOCK.try_lock
181
+
182
+ begin
183
+ owed_or_current_token
184
+ ensure
185
+ PROBE_LOCK.unlock
186
+ end
187
+ end
188
+
189
+ def owed_or_current_token
190
+ token = current_token
191
+ token && @announcement_owed ? (publish_token || token) : token
192
+ end
193
+
194
+ # Writes a new token (no expiry). Remembers it as owed when the store
195
+ # does not take it, and starts the back-off.
196
+ #
197
+ # @return [String, nil] the token written
198
+ def publish_token
199
+ token = SecureRandom.uuid
200
+ written = PermissionForge.cache.write_permanent(KEY, token)
201
+ store_failed! unless written
202
+ @announcement_owed = !written
203
+ token if written
204
+ rescue StandardError => e
205
+ @announcement_owed = true
206
+ store_failed!(e)
207
+ nil
208
+ end
209
+
210
+ # The token in the store — or, when there is none, a new one written
211
+ # (with no expiry) and read back: whatever token is there then, also
212
+ # one another process wrote meanwhile. nil when the store did not
213
+ # answer: it is left alone for RETRY_PAUSE. A read that raised is not
214
+ # followed by a write.
215
+ #
216
+ # @return [String, nil]
217
+ def current_token
218
+ cache = PermissionForge.cache
219
+ token = cache.read_fresh(KEY) || (cache.read_fresh(KEY) if cache.write_permanent(KEY, SecureRandom.uuid))
220
+ token ? store_answered! : store_failed!
221
+ token
222
+ rescue StandardError => e
223
+ store_failed!(e)
224
+ nil
225
+ end
226
+
227
+ def store_answered!
228
+ @failing_until = nil
229
+ end
230
+
231
+ # Logged once per outage: the first failure after the store answered.
232
+ def store_failed!(error = nil)
233
+ first = @failing_until.nil?
234
+ @failing_until = now + RETRY_PAUSE
235
+ @failing_store = PermissionForge.cache
236
+ return unless first
237
+
238
+ why = error ? " (#{error.class}: #{error.message})" : ''
239
+ Rails.logger&.warn("[PermissionForge] The cache store did not give or keep the masking token#{why}: auto-applied " \
240
+ "masking is not synced with other processes for the next #{RETRY_PAUSE} seconds.")
241
+ end
242
+
243
+ def now
244
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
245
+ end
246
+
247
+ # Whether `token` is one this process has not synced with. An unknown
248
+ # one (nil: the store did not answer) only for a process that never
249
+ # synced.
250
+ def changed?(token)
251
+ token.nil? ? !synced? : token != @token
252
+ end
253
+
254
+ # Whether this process has the concern on the model (false for a name
255
+ # that resolves to no model here: not known to be masked).
256
+ def masked_here?(name)
257
+ MaskedModels.masked?(name)
258
+ end
259
+
260
+ def sync(token, always: false)
261
+ SYNC_LOCK.synchronize do
262
+ return true if !always && synced? && token == @token
263
+
264
+ applied = MaskedModels.apply
265
+ @token = token if applied
266
+ applied
267
+ end
268
+ end
269
+ end
270
+ end
271
+ end