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,223 @@
1
+ require_relative 'gdpr_field_auditor/text_report'
2
+
3
+ module PermissionForge
4
+ module Services
5
+ # Reports fields that look like personal data and are neither masked nor mapped. It reports and never changes anything.
6
+ #
7
+ # @api public
8
+ class GdprFieldAuditor
9
+ # @param configuration [Configuration]
10
+ # @return [Array<Hash>] one entry per finding
11
+ def self.call(configuration: PermissionForge.configuration)
12
+ new(configuration: configuration).call
13
+ end
14
+
15
+ def initialize(configuration: PermissionForge.configuration)
16
+ @configuration = configuration
17
+ end
18
+
19
+ # @return [Array<Hash>] one entry per finding
20
+ def call
21
+ report = []
22
+ audit_unmasked_fields(report)
23
+ audit_unmapped_columns(report)
24
+ audit_implicit_virtual_grants(report)
25
+ audit_view_all_on_pii_resources(report)
26
+ audit_cross_set_unmasking(report)
27
+ report
28
+ end
29
+
30
+ private
31
+
32
+ # Field rows that reach someone's permission hash: in effect, in a
33
+ # granting Permission Set. Every check reads rows through here, so a row
34
+ # the engine skips (switched off, or in a switched-off resource or set,
35
+ # or in a muting set) is never reported as both "unmasked" and "unmapped".
36
+ def granting_fields
37
+ PermissionForge::ResourceField.in_effect.merge(PermissionForge::PermissionSet.granting)
38
+ end
39
+
40
+ def audit_unmasked_fields(report)
41
+ granting_fields.where(mask_access: false).preload(resource: :permission_set).find_each do |field|
42
+ report << unmasked_finding(field) if gdpr_field?(field.name)
43
+ end
44
+ end
45
+
46
+ def unmasked_finding(field)
47
+ resource = field.resource
48
+ { type: :unmasked_field, resource_field_id: field.id, field_name: field.name, resource_name: resource.name,
49
+ suggested_strategy: gdpr_strategy(field.name), permission_set: resource.permission_set.name }
50
+ end
51
+
52
+ def audit_unmapped_columns(report)
53
+ PermissionForge::Resource.distinct.pluck(:name).each do |resource_name|
54
+ audit_unmapped_for_resource(report, resource_name)
55
+ end
56
+ rescue ActiveRecord::StatementInvalid
57
+ # Tables don't exist yet (pre-migration)
58
+ end
59
+
60
+ def audit_unmapped_for_resource(report, resource_name)
61
+ columns = model_columns_for(resource_name)
62
+ return unless columns
63
+
64
+ virtual = unmapped_virtual_names_for(resource_name)
65
+ mapped_names = mapped_field_names_for(resource_name)
66
+ (columns + virtual - mapped_names).each do |column_name|
67
+ match = gdpr_patterns.find { |regex, _| column_name.match?(regex) }
68
+ next unless match
69
+
70
+ finding = { type: :unmapped_column, resource: resource_name, column: column_name, pattern_matched: match[1] }
71
+ report << (virtual.include?(column_name) ? finding.merge(virtual: true) : finding)
72
+ end
73
+ end
74
+
75
+ # Virtual fields that follow unconfigured_field_strategy when nobody
76
+ # configured them. Implicitly granted ones are reported per Permission
77
+ # Set by audit_implicit_virtual_grants instead.
78
+ def unmapped_virtual_names_for(resource_name)
79
+ fields = virtual_fields(resource_name)
80
+ return [] unless fields
81
+
82
+ fields.names.reject { |name| fields.implicit_grant?(name) }
83
+ end
84
+
85
+ # Declared virtual fields that look like personal data and have no active
86
+ # field row in a granting resource: under extra_fields_default_access =
87
+ # :grant that set gives its people read and edit on them, unmasked.
88
+ def audit_implicit_virtual_grants(report)
89
+ return unless @configuration.grant_extra_fields_by_default?
90
+
91
+ granting_resources.each { |resource| report.concat(implicit_virtual_grants_in(resource)) }
92
+ rescue ActiveRecord::StatementInvalid
93
+ # Tables don't exist yet (pre-migration)
94
+ end
95
+
96
+ # A switched-off row counts as no row, because the engine skips it.
97
+ def implicit_virtual_grants_in(resource)
98
+ configured = resource.resource_fields.select(&:is_active).map(&:name)
99
+ implicit_pii_fields(resource.name).filter_map do |name, strategy|
100
+ next if configured.include?(name)
101
+
102
+ { type: :implicit_virtual_grant, resource_name: resource.name, field_name: name,
103
+ suggested_strategy: strategy, permission_set: resource.permission_set.name }
104
+ end
105
+ end
106
+
107
+ def granting_resources
108
+ granting_sets = PermissionForge::PermissionSet.active.granting.select(:id)
109
+ PermissionForge::Resource.active.where(read_access: true, permission_set_id: granting_sets)
110
+ .includes(:permission_set, :resource_fields)
111
+ end
112
+
113
+ # [[name, suggested strategy]] for the model's implicitly granted names
114
+ # that match a GDPR pattern. Names in ignored_fields are left out: the
115
+ # admin does not manage those.
116
+ def implicit_pii_fields(resource_name)
117
+ @implicit_pii_fields ||= {}
118
+ @implicit_pii_fields[resource_name] ||= begin
119
+ names = Array(virtual_fields(resource_name)&.implicit).uniq - Array(@configuration.ignored_fields).map(&:to_s)
120
+ names.filter_map { |name| [name, gdpr_strategy(name)] if gdpr_field?(name) }
121
+ end
122
+ end
123
+
124
+ # @return [PermissionForge::VirtualFields, nil] nil when the resource
125
+ # names no model whose columns can be read.
126
+ def virtual_fields(resource_name)
127
+ @virtual_fields ||= {}
128
+ @virtual_fields.fetch(resource_name) do
129
+ @virtual_fields[resource_name] = build_virtual_fields(resource_name)
130
+ end
131
+ end
132
+
133
+ def build_virtual_fields(resource_name)
134
+ return unless model_columns_for(resource_name)
135
+
136
+ PermissionForge::VirtualFields.new(resource_name.safe_constantize, @configuration)
137
+ end
138
+
139
+ def model_columns_for(resource_name)
140
+ model_class = resource_name.safe_constantize
141
+ return unless model_class
142
+ return unless model_class.respond_to?(:column_names)
143
+
144
+ model_class.column_names
145
+ rescue ActiveRecord::StatementInvalid
146
+ nil
147
+ end
148
+
149
+ # Names with a field row in effect in any granting Permission Set. A row
150
+ # the engine skips counts as no row.
151
+ def mapped_field_names_for(resource_name)
152
+ granting_fields.merge(PermissionForge::Resource.where(name: resource_name)).distinct.pluck(:name)
153
+ end
154
+
155
+ def audit_view_all_on_pii_resources(report)
156
+ resources_with_bypass.find_each do |resource|
157
+ pii_fields = resource.resource_fields.select { |field| gdpr_field?(field.name) }
158
+ next if pii_fields.empty?
159
+
160
+ report << build_view_all_finding(resource, pii_fields)
161
+ end
162
+ rescue ActiveRecord::StatementInvalid
163
+ # Tables don't exist yet (pre-migration)
164
+ end
165
+
166
+ # Resources in effect in a granting Permission Set. In a muting set
167
+ # view_all takes the bypass away, and a switched-off row gives nothing.
168
+ def resources_with_bypass
169
+ resources = PermissionForge::Resource.arel_table
170
+ PermissionForge::Resource.in_effect.merge(PermissionForge::PermissionSet.granting)
171
+ .where(resources[:view_all].eq(true).or(resources[:modify_all].eq(true)))
172
+ end
173
+
174
+ def build_view_all_finding(resource, pii_fields)
175
+ bypasses = []
176
+ bypasses << 'view_all' if resource.view_all
177
+ bypasses << 'modify_all' if resource.modify_all
178
+
179
+ { type: :view_all_on_pii_resource, resource_name: resource.name,
180
+ permission_set: resource.permission_set.name, bypasses: bypasses,
181
+ pii_fields: pii_fields.map(&:name) }
182
+ end
183
+
184
+ # Masks combine with AND, so an unmasked row wins over a masked row of
185
+ # the same field of the same resource in another Permission Set.
186
+ def audit_cross_set_unmasking(report)
187
+ masking_sets = sets_masking_pii_fields
188
+ granting_fields.where(mask_access: false).preload(resource: :permission_set).find_each do |field|
189
+ resource = field.resource
190
+ masked_elsewhere = masking_sets.fetch([resource.name, field.name], []) - [resource.permission_set_id]
191
+ report << cross_set_finding(resource, field.name) if masked_elsewhere.any?
192
+ end
193
+ rescue ActiveRecord::StatementInvalid
194
+ # Tables don't exist yet (pre-migration)
195
+ end
196
+
197
+ def cross_set_finding(resource, field_name)
198
+ { type: :cross_set_unmasking, field_name: field_name, resource_name: resource.name,
199
+ permission_set: resource.permission_set.name }
200
+ end
201
+
202
+ # { [resource name, field name] => ids of the granting sets that mask it }
203
+ def sets_masking_pii_fields
204
+ masked = granting_fields.where(mask_access: true).preload(:resource).select { |field| gdpr_field?(field.name) }
205
+ masked.each_with_object({}) do |field, sets|
206
+ (sets[[field.resource.name, field.name]] ||= []) << field.resource.permission_set_id
207
+ end
208
+ end
209
+
210
+ def gdpr_field?(name)
211
+ gdpr_patterns.any? { |regex, _| name.match?(regex) }
212
+ end
213
+
214
+ def gdpr_strategy(name)
215
+ gdpr_patterns.find { |regex, _| name.match?(regex) }&.last
216
+ end
217
+
218
+ def gdpr_patterns
219
+ @configuration.gdpr_field_patterns
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ # Refuses to read permissions or write assignments when a column that
6
+ # stores a host primary key cannot hold it.
7
+ #
8
+ # An integer column silently truncates a UUID or string key to its leading
9
+ # digits ('1aaa…' and '1bbb…' both become 1), so different users would
10
+ # share permission sets and different tenants would share permission data.
11
+ # The install generator types these columns from the host models; this
12
+ # guard covers installs where it could not, or that predate it.
13
+ #
14
+ # Checked lazily where the truncation would bite (PermissionCacheAccessor,
15
+ # the two assignment models) rather than at boot, so `db:migrate` can still
16
+ # run to repair an affected schema. A passing check is remembered until the
17
+ # configuration it depends on changes.
18
+ #
19
+ # @api private
20
+ class HostKeyGuard
21
+ Mismatch = Struct.new(:table, :column, :column_type, :host, :key_type) do
22
+ def to_s
23
+ "#{table}.#{column} is #{column_type} but #{host}'s primary key is #{key_type}"
24
+ end
25
+ end
26
+
27
+ class << self
28
+ # @return [true]
29
+ # @raise [PermissionForge::ConfigurationError] when a host key would be truncated
30
+ def verify!
31
+ signature = configuration_signature
32
+ return true if @verified == signature
33
+
34
+ found = mismatches
35
+ raise PermissionForge::ConfigurationError, message(found) if found.any?
36
+
37
+ @verified = signature
38
+ true
39
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
40
+ true # schema not readable yet (before the install migration); not remembered
41
+ end
42
+
43
+ def reset!
44
+ @verified = nil
45
+ end
46
+
47
+ # @return [Array<Mismatch>] columns that would truncate the key they store
48
+ def mismatches
49
+ key_columns.filter_map { |model, column, host_name| mismatch(model, column, host_name) }
50
+ end
51
+
52
+ private
53
+
54
+ def configuration_signature
55
+ config = PermissionForge.configuration
56
+ [config.user_class, config.table_name_prefix, config.tenant_model, config.tenant_column]
57
+ end
58
+
59
+ # [model, column, host class name] for every column holding a host key
60
+ # that an authorization decision depends on.
61
+ def key_columns
62
+ config = PermissionForge.configuration
63
+ user_class = config.user_class
64
+ columns = [[PermissionForge::PermissionSetUser, 'user_id', user_class],
65
+ [PermissionForge::PermissionSetGroupUser, 'user_id', user_class]]
66
+ return columns unless config.tenant_enabled?
67
+
68
+ tenant_column = config.resolved_tenant_column.to_s
69
+ columns + tenant_scoped_models.map { |model| [model, tenant_column, config.tenant_model] }
70
+ end
71
+
72
+ # The audit log is tenant-scoped as well (a truncated tenant id would
73
+ # show one tenant another's events); it is checked only once its table
74
+ # exists, so an install that has not run the upgrade yet keeps the
75
+ # rest of the check.
76
+ def tenant_scoped_models
77
+ models = [PermissionForge::PermissionSet, PermissionForge::PermissionSetUser,
78
+ PermissionForge::PermissionSetGroup, PermissionForge::PermissionSetGroupUser,
79
+ PermissionForge::Resource, PermissionForge::ResourceField,
80
+ PermissionForge::ResourceAction, PermissionForge::PermissionSetRule]
81
+ PermissionForge::AuditEvent.table_exists? ? models + [PermissionForge::AuditEvent] : models
82
+ end
83
+
84
+ def mismatch(model, column, host_name)
85
+ host = host_name.to_s.safe_constantize
86
+ key_type = primary_key_type(host)
87
+ column_type = model.columns_hash[column]&.type
88
+ return unless column_type == :integer && key_type && key_type != :integer
89
+
90
+ Mismatch.new(model.table_name, column, column_type, host.name, key_type)
91
+ end
92
+
93
+ # nil for a missing model or a composite primary key (not checked).
94
+ def primary_key_type(host)
95
+ return unless host.respond_to?(:columns_hash)
96
+
97
+ key = host.primary_key
98
+ host.columns_hash[key]&.type if key.is_a?(String)
99
+ end
100
+
101
+ def message(found)
102
+ "PermissionForge columns cannot hold the host primary keys they store: #{found.join('; ')}. " \
103
+ 'An integer column truncates a UUID or string key to its leading digits, so different users ' \
104
+ '(or tenants) would share permission data. Change these columns to the type of the key and ' \
105
+ 're-create the affected rows; see "UUID or string keys" in docs/migration-guide.md.'
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,93 @@
1
+ module PermissionForge
2
+ # The service objects hosts call (`Service.call(...)`): assignment, erasure, filtering, auditing and cloning.
3
+ # Each public service documents its arguments and result; helpers under this namespace are `@api private`.
4
+ #
5
+ # @api public
6
+ module Services
7
+ # WARNING: These strategies provide display-level obfuscation only.
8
+ # They do NOT constitute GDPR-compliant anonymization or pseudonymization.
9
+ # See docs/gdpr_masking_disclaimer.md for details.
10
+ #
11
+ # @api public
12
+ class MaskValue
13
+ BUILT_IN_STRATEGIES = {
14
+ 'full' => :mask_full,
15
+ 'email' => :mask_email,
16
+ 'last4' => :mask_last4,
17
+ 'first1_last4' => :mask_first1_last4
18
+ }.freeze
19
+
20
+ STRATEGIES = BUILT_IN_STRATEGIES.keys.freeze
21
+ MASK_CHAR = '*'.freeze
22
+
23
+ # @return [Hash{String => #call}] the built-in strategies merged with `config.custom_mask_strategies`
24
+ def self.strategies
25
+ custom = PermissionForge.configuration.custom_mask_strategies
26
+ return BUILT_IN_STRATEGIES if custom.blank?
27
+
28
+ BUILT_IN_STRATEGIES.merge(custom)
29
+ end
30
+
31
+ # Masks a value with a built-in or custom strategy.
32
+ # @param value [Object]
33
+ # @param strategy [String] `full`, `email`, `last4`, `first1_last4` or a custom one
34
+ # @return [Object] the masked value (nil stays nil)
35
+ def self.call(value, strategy: 'full')
36
+ return value if value.nil?
37
+
38
+ str = value.to_s
39
+ return str if str.empty?
40
+
41
+ new(str, strategy).call
42
+ end
43
+
44
+ def initialize(value, strategy)
45
+ @value = value
46
+ @strategy = strategy
47
+ all_strategies = self.class.strategies
48
+ return if all_strategies.key?(@strategy)
49
+
50
+ raise ArgumentError,
51
+ "Unknown mask strategy: #{strategy.inspect}. Valid strategies: #{all_strategies.keys.join(', ')}"
52
+ end
53
+
54
+ # @return [Object] the masked value
55
+ def call
56
+ all_strategies = self.class.strategies
57
+ strategy_ref = all_strategies[@strategy]
58
+
59
+ if strategy_ref.is_a?(Symbol)
60
+ send(strategy_ref)
61
+ else
62
+ strategy_ref.call(@value)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def mask_full
69
+ MASK_CHAR * 8
70
+ end
71
+
72
+ def mask_email
73
+ return mask_full unless @value.include?('@')
74
+
75
+ local, domain = @value.split('@', 2)
76
+ masked_local = local.length > 1 ? "#{local[0]}#{MASK_CHAR * 3}" : MASK_CHAR
77
+ "#{masked_local}@#{domain}"
78
+ end
79
+
80
+ def mask_last4
81
+ return mask_full if @value.length <= 4
82
+
83
+ "#{MASK_CHAR * (@value.length - 4)}#{@value[-4..]}"
84
+ end
85
+
86
+ def mask_first1_last4
87
+ return mask_full if @value.length <= 5
88
+
89
+ "#{@value[0]}#{MASK_CHAR * (@value.length - 5)}#{@value[-4..]}"
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ # Removes every cached permission hash — this version's entries, the
6
+ # previous versions' (Cache::UserKeys.namespaces), and the per-context
7
+ # variants — for every user, in every tenant. The step to run once the
8
+ # last process of the previous release is gone (0.14: an invalidation
9
+ # made by 0.13 cannot reach this version's entries, and one made after
10
+ # the clear would miss them again), and the way to revoke everything
11
+ # cached at once. Every user then rebuilds on their next request: a cold
12
+ # cache, one rebuild each. Two strategies, the first that works:
13
+ #
14
+ # :pattern — the store removes keys by pattern (Redis, memory and file
15
+ # stores: Cache::Base#clear_entries): one call per namespace, nothing
16
+ # enumerated. How many users or tenants that reached is not known
17
+ # (the result and the events say so). On a Redis store the
18
+ # invalidation stamps go with the bare keys — a rebuild writes a new
19
+ # one, one in flight is not kept.
20
+ # :enumerated — otherwise (Memcached, Solid Cache): every id of the
21
+ # user model, in slices of Cache::HolderInvalidation::SLICE, through
22
+ # the bulk invalidation (one stamp write and one removal per slice),
23
+ # or through each user's own invalidate_permissions where the bulk
24
+ # path must not be used (HolderInvalidation.by_id?: a host override,
25
+ # a listener, a snapshot transaction); then the per-context variants
26
+ # an invalidation leaves (Cache::VariantRemoval: one read of the
27
+ # slice's index entries, and a stamp write and a removal for the
28
+ # users who had a variant stored). With tenancy on: once per tenant of the
29
+ # tenant model, the users as the host scopes them under that tenant
30
+ # (a user model that is not tenant-scoped is visited under every
31
+ # tenant: users × tenants), and once more without a tenant for the
32
+ # tenant-agnostic keys and users of no tenant. A user a host default
33
+ # scope hides under a tenant keeps that tenant's key until cache_ttl.
34
+ #
35
+ # @example
36
+ # result = PermissionCacheClearer.call(actor: 'ops: TCK-12', reason: 'deploy 0.14')
37
+ # # => { strategy: :enumerated, users: 240, tenants: 2, unconfirmed: 0 }
38
+ # # => { strategy: :pattern, users: nil, tenants: 2, unconfirmed: 0 }
39
+ #
40
+ # `users` counts visits (a user once per tenant); `unconfirmed` the ones
41
+ # the cache store did not confirm (each also reported through
42
+ # `config.on_cache_failure`, and owed). The audit log: one `cache.cleared`
43
+ # event per tenant, in that tenant with that tenant's counts, plus one
44
+ # without a tenant with the totals (`across_tenants`); under :pattern
45
+ # the counts are not known and the events say `counted: false`.
46
+ # `actor:` and `reason:` as the other housekeeping services (rake:
47
+ # ACTOR=, REASON=).
48
+ #
49
+ # @api public
50
+ class PermissionCacheClearer
51
+ include Concerns::AuditedOperation
52
+
53
+ EVENT = 'cache.cleared'
54
+ SLICE = Cache::HolderInvalidation::SLICE
55
+
56
+ # Clears every cached permission hash (see the class comment).
57
+ #
58
+ # @param actor [Object, nil] who runs it, for the cache.cleared events
59
+ # @param reason [String, nil] why
60
+ # @return [Hash] strategy:, users:, tenants:, unconfirmed:
61
+ def self.call(...) = new(...).call
62
+
63
+ def initialize(actor: nil, reason: nil)
64
+ @actor = actor
65
+ @reason = reason
66
+ end
67
+
68
+ # @return [Hash] strategy:, users:, tenants:, unconfirmed:
69
+ def call
70
+ audited(actor: @actor, reason: @reason) { clear }
71
+ end
72
+
73
+ private
74
+
75
+ def clear
76
+ result = clear_by_pattern || clear_by_enumeration
77
+ # retire every memo in this process, as an invalidation does
78
+ PermissionForge.bump_permissions_cache_version!
79
+ result
80
+ end
81
+
82
+ # @return [Hash, nil] nil when the store cannot remove by pattern
83
+ def clear_by_pattern
84
+ cache = PermissionForge.cache
85
+ return unless Cache::UserKeys.namespaces.all? { |namespace| cache.clear_entries(namespace) }
86
+
87
+ tenant_ids = tenants.map(&:id)
88
+ tenant_ids.each { |tenant_id| record(tenant_id, strategy: :pattern, counted: false, unconfirmed: 0) }
89
+ record_without_tenant(strategy: :pattern, counted: false, unconfirmed: 0, tenants: tenant_ids.size)
90
+ { strategy: :pattern, users: nil, tenants: tenant_ids.size, unconfirmed: 0 }
91
+ end
92
+
93
+ def clear_by_enumeration
94
+ totals = { strategy: :enumerated, users: 0, tenants: 0, unconfirmed: 0 }
95
+ tenants.each { |tenant| add_counts(totals, clear_tenant(tenant), tenants: 1) }
96
+ add_counts(totals, without_tenant { invalidate_everyone(nil) })
97
+ record_without_tenant(**totals)
98
+ totals
99
+ end
100
+
101
+ # One tenant's users, under that tenant, and its event.
102
+ def clear_tenant(tenant)
103
+ counts = PermissionForge.with_ambient_tenant(tenant) { invalidate_everyone(tenant.id) }
104
+ record(tenant.id, strategy: :enumerated, **counts)
105
+ counts
106
+ end
107
+
108
+ def add_counts(totals, counts, **more)
109
+ totals.merge!(counts, more) { |_key, total, count| total + count }
110
+ end
111
+
112
+ # Every user the model lists here, in slices, by id where that is
113
+ # allowed and through each user's invalidate_permissions where not.
114
+ #
115
+ # @return [Hash] users:, unconfirmed:
116
+ def invalidate_everyone(tenant_id)
117
+ model = PermissionForge.configuration.user_model
118
+ by_id = Cache::HolderInvalidation.by_id?(model)
119
+ counts = { users: 0, unconfirmed: 0 }
120
+ users_scope(model, tenant_id).in_batches(of: SLICE) do |batch|
121
+ ids = batch.pluck(model.primary_key)
122
+ counts[:users] += ids.size
123
+ counts[:unconfirmed] += by_id ? by_id(ids, tenant_id).size : one_by_one(batch, tenant_id)
124
+ end
125
+ counts
126
+ end
127
+
128
+ # The invalidation leaves the variants to their TTL; they hold the
129
+ # profile, so the clear removes them too.
130
+ #
131
+ # @return [Array] the ids the store did not confirm
132
+ def by_id(ids, tenant_id)
133
+ Cache::HolderInvalidation.call(user_ids: ids, tenant_id: tenant_id) | Cache::VariantRemoval.call(user_ids: ids, tenant_id: tenant_id)
134
+ end
135
+
136
+ # @return [Integer] how many the store did not confirm
137
+ def one_by_one(batch, tenant_id)
138
+ batch.reject do |user|
139
+ Cache::Failure.none? do
140
+ user.invalidate_permissions(tenant_id: tenant_id)
141
+ Cache::VariantRemoval.call(user_ids: [user.id], tenant_id: tenant_id, ambient_key: user.permission_cache_key)
142
+ end
143
+ end.size
144
+ end
145
+
146
+ # Under a tenant: the users as the host scopes them (a tenant-scoped
147
+ # user model lists that tenant's). Without one: everyone.
148
+ def users_scope(model, tenant_id)
149
+ PermissionForge::Concerns::AutoPermissionScoping.internally_suspended { tenant_id ? model.all : model.unscoped }
150
+ end
151
+
152
+ # The tenants of the tenant model, when tenancy is on; none otherwise.
153
+ def tenants
154
+ return [] unless PermissionForge.configuration.tenant_enabled?
155
+
156
+ @tenants ||= PermissionForge.configuration.tenant_model.constantize.unscoped.to_a
157
+ end
158
+
159
+ def without_tenant(&)
160
+ defined?(ActsAsTenant) ? ActsAsTenant.without_tenant(&) : yield
161
+ end
162
+
163
+ def record(tenant_id, **data)
164
+ PermissionForge::Audit.record(EVENT, tenant_id: tenant_id,
165
+ data: data.transform_keys(&:to_s).merge('operation' => 'cache_clear'))
166
+ end
167
+
168
+ # The event without a tenant — recorded with none ambient, so it is one.
169
+ def record_without_tenant(**data)
170
+ without_tenant { record(nil, **data, across_tenants: true) }
171
+ end
172
+ end
173
+ end
174
+ end