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,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Audit
5
+ # The notification subscribers the engine installs once configuration is
6
+ # final (Audit.install!). Each installer is a callable that receives the
7
+ # configuration and returns the subscriber it installed, or nil when its
8
+ # option is off:
9
+ #
10
+ # Audit::Subscribers.register(:log, ->(config) {
11
+ # ActiveSupport::Notifications.subscribe(/\.permission_forge\z/) { |event| ... } if config.audit_log_subscriber
12
+ # })
13
+ #
14
+ # The log subscriber (config.audit_log_subscriber), the store subscriber
15
+ # and the adapters for the legacy on_rule_bypass / on_field_filtered hooks
16
+ # live in lib/permission_forge/audit/subscribers/ and register here.
17
+ #
18
+ # @api private
19
+ module Subscribers
20
+ @installers = {}
21
+ @installed = {}
22
+
23
+ class << self
24
+ def register(name, installer)
25
+ @installers[name.to_sym] = installer
26
+ end
27
+
28
+ # Idempotent: an installer whose subscriber is in place is not called again.
29
+ def install!(configuration = PermissionForge.configuration)
30
+ @installers.each do |name, installer|
31
+ next if @installed.key?(name)
32
+
33
+ subscriber = installer.call(configuration)
34
+ @installed[name] = subscriber if subscriber
35
+ end
36
+ end
37
+
38
+ def installed?(name)
39
+ @installed.key?(name.to_sym)
40
+ end
41
+
42
+ def uninstall!(name)
43
+ subscriber = @installed.delete(name.to_sym)
44
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
45
+ end
46
+
47
+ # Removes every installed subscriber (tests; a configuration change).
48
+ def reset!
49
+ uninstall!(@installed.keys.first) until @installed.empty?
50
+ end
51
+
52
+ def names
53
+ @installers.keys
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # The one emitter of audit events.
5
+ #
6
+ # PermissionForge::Audit.record('assignment.revoked', record: assignment, reason: 'left the team')
7
+ # PermissionForge::Audit.instrument('access.denied') { { data: { 'model' => 'Customer', 'id' => 42 } } }
8
+ #
9
+ # `record` stores a change, admin or system event (when auditing is on) and
10
+ # publishes it as the "<event>.permission_forge" notification once the
11
+ # transaction it was recorded in has committed. `instrument` publishes a
12
+ # runtime (access) event and stores it only when its group is listed in
13
+ # `config.audit_runtime_events`; when nobody subscribes and the group is not
14
+ # stored it returns before building anything.
15
+ #
16
+ # Who, where and why come from the audit context (Audit::Context, set by
17
+ # `PermissionForge.with_audit_actor`, `Audit::Context.with` and the
18
+ # controllers), the record (Concerns::RecordsAuditEvents#audit_reference)
19
+ # and the payload (Audit::Recorder lists the keys).
20
+ #
21
+ # What happens when an event cannot be built or stored:
22
+ # - `record` follows config.audit_change_failure_strategy. Under :raise
23
+ # (default) an EventNotRecordedError is raised — out of the change's
24
+ # callback, so the change rolls back — and the open transaction is made
25
+ # to fail at commit (OpenTransaction#before_commit), so a caller that
26
+ # rescues the error inside its own transaction cannot commit the change
27
+ # without its record. Under :report / :log the event is written in a
28
+ # savepoint (a failed INSERT must not abort the caller's transaction on
29
+ # PostgreSQL), the failure is reported and the change goes through.
30
+ # - `instrument` follows config.audit_hook_failure_strategy, always in a
31
+ # savepoint: a denial is never allowed to break the request that was
32
+ # denied.
33
+ #
34
+ # @api public
35
+ module Audit
36
+ # A change could not be recorded (audit_change_failure_strategy = :raise).
37
+ # `cause` is what building or storing the event raised. Raised twice at
38
+ # most: out of the change, and again at the commit of a transaction that
39
+ # rescued it.
40
+ #
41
+ # @api public
42
+ class EventNotRecordedError < StandardError
43
+ # @return [String, nil] the name of the event that could not be written
44
+ attr_reader :event
45
+
46
+ def initialize(message = nil, event: nil, at_commit: false)
47
+ @event = event
48
+ super(message || default_message(event, at_commit))
49
+ end
50
+
51
+ private
52
+
53
+ def default_message(event, at_commit)
54
+ if at_commit
55
+ "the transaction was rolled back at commit: the audit event #{event} of a change inside it could not be written"
56
+ else
57
+ "the audit event #{event} could not be written, so the change was not made"
58
+ end
59
+ end
60
+ end
61
+
62
+ # How long a missing table is believed before it is looked for again:
63
+ # a worker that started before the migration records once it has run.
64
+ MISSING_TABLE_RECHECK = 60
65
+
66
+ TABLE_CHECK_MUTEX = Mutex.new
67
+ FAILED = Object.new.freeze
68
+ private_constant :FAILED
69
+
70
+ @table_checks = {}
71
+ @warned_tables = {}
72
+ @missing_table_recheck = MISSING_TABLE_RECHECK
73
+
74
+ extend RuntimeEmission
75
+
76
+ class << self
77
+ # Seconds a missing table is believed before it is looked for again.
78
+ attr_accessor :missing_table_recheck
79
+
80
+ # @param event [String] a name from the catalogue (not an access event)
81
+ # @param payload [Hash] see Audit::Recorder
82
+ # @return [AuditEvent, nil] the stored event; nil when auditing is off
83
+ # or the write failed under a non-raising audit_change_failure_strategy
84
+ # @raise [EventCatalog::UnknownEventError, ArgumentError, EventNotRecordedError]
85
+ def record(event, **payload)
86
+ entry = EventCatalog.fetch(event)
87
+ raise ArgumentError, "#{entry.name} is an access event: publish it with Audit.instrument" if entry.runtime?
88
+
89
+ emit(Recorder.new(entry, payload), store: enabled?, strategy: configuration.audit_change_failure_strategy, change: true)
90
+ end
91
+
92
+ # @param event [String] an access event of the catalogue
93
+ # @param payload [Hash] see Audit::Recorder; source defaults to 'runtime'
94
+ # @yieldreturn [Hash] more payload, built only when the event is stored
95
+ # or someone listens — for hot paths:
96
+ # Audit.instrument('field.access') { { record: record, data: { 'hidden' => hidden } } }
97
+ # @return [AuditEvent, nil] the stored event, when its group is stored
98
+ #
99
+ # Published at once, stored now or when the open transaction has ended
100
+ # (committed or rolled back); nothing while this thread is already
101
+ # emitting a runtime event. See Audit::RuntimeEmission.
102
+ def instrument(event, **payload, &)
103
+ entry = EventCatalog.fetch(event)
104
+ raise ArgumentError, "#{entry.name} is a #{entry.category} event: store it with Audit.record" unless entry.runtime?
105
+
106
+ light = Gate.for(entry.name).direct_listeners
107
+ return instrument_light(entry, light, payload, &) if light
108
+
109
+ storing, listening = runtime_plan(entry)
110
+ return unless storing || listening
111
+
112
+ instrument_runtime(entry, payload, store: storing, &)
113
+ end
114
+
115
+ # Whether instrumenting this event would do anything right now: its
116
+ # group is stored, or someone listens — and this thread is not already
117
+ # emitting one. For callers whose payload is expensive to gather.
118
+ #
119
+ # @param event [String] an access event of the catalogue
120
+ # @return [Boolean]
121
+ def instrument?(event)
122
+ entry = EventCatalog.fetch(event)
123
+ entry.runtime? && Gate.for(entry.name).open? && !emitting? &&
124
+ ((configuration.store_runtime_group?(entry.runtime_group) && enabled?) || listening?(entry))
125
+ end
126
+
127
+ # Whether events are stored: config.audit_events, and under :auto
128
+ # whether the table exists. A table that exists is remembered for the
129
+ # process; one that does not is looked for again after
130
+ # MISSING_TABLE_RECHECK seconds (reset! forgets at once).
131
+ #
132
+ # @param configuration [Configuration]
133
+ # @return [Boolean]
134
+ def enabled?(configuration = PermissionForge.configuration)
135
+ case configuration.audit_events
136
+ when true then true
137
+ when false then false
138
+ else table_available?
139
+ end
140
+ end
141
+
142
+ # Gives every event inside the block one correlation id (an outer block's
143
+ # id, else `id`, else a new one).
144
+ #
145
+ # PermissionForge::Audit.correlate { change_set.apply! }
146
+ #
147
+ # @return the value of the block
148
+ def correlate(id = nil, &)
149
+ Context.with(correlation_id: Context[:correlation_id] || id || new_id, &)
150
+ end
151
+
152
+ # Logs the boot warning when :auto finds no table. Called by the engine
153
+ # once the database is reachable.
154
+ def verify_table!
155
+ enabled? if configuration.audit_events == :auto
156
+ end
157
+
158
+ # Forgets the table check and empties the context of this thread (tests,
159
+ # after a migration in the same process).
160
+ def reset!
161
+ TABLE_CHECK_MUTEX.synchronize do
162
+ @table_checks = {}
163
+ @warned_tables = {}
164
+ end
165
+ Context.reset!
166
+ Runtime.reset!
167
+ end
168
+
169
+ # Installs the notification subscribers and the executor hook of the
170
+ # gem's execution state (ExecutionState.install!): at the end of every
171
+ # executor run (request, job) whatever the run added to the audit
172
+ # context, to what runtime events remember for one request (Runtime)
173
+ # and to every block-scoped slot is removed — a request or a job ends
174
+ # with them empty. Called by the engine once configuration is final.
175
+ def install!(app)
176
+ Gate.install!
177
+ Subscribers.install!(configuration)
178
+ ExecutionState.install!(app)
179
+ end
180
+
181
+ # @return [String] a fresh id from `config.uuid_generator`
182
+ # @api private
183
+ def new_id
184
+ configuration.uuid_generator.call
185
+ end
186
+
187
+ # @param entry [EventCatalog::Entry]
188
+ # @return [Boolean] whether anyone subscribes to the event's notification
189
+ def listening?(entry)
190
+ ActiveSupport::Notifications.notifier.listening?(entry.notification)
191
+ end
192
+
193
+ private
194
+
195
+ def configuration
196
+ PermissionForge.configuration
197
+ end
198
+
199
+ # [whether the event is stored, whether anyone listens]. The cheap
200
+ # answers first; the table is consulted only for a group that is stored.
201
+ def runtime_plan(entry)
202
+ return [false, false] unless Gate.for(entry.name).open?
203
+
204
+ storing = configuration.store_runtime_group?(entry.runtime_group)
205
+ listening = listening?(entry)
206
+ return [false, false] unless storing || listening
207
+
208
+ [storing && enabled?, listening]
209
+ end
210
+
211
+ def emit(recorder, store:, strategy:, change:)
212
+ stored = write(recorder, store: store, strategy: strategy, change: change)
213
+ return if stored.equal?(FAILED)
214
+
215
+ publish(recorder, stored)
216
+ stored
217
+ end
218
+
219
+ # Builds the event once and stores it, in a savepoint: a failed INSERT
220
+ # aborts the transaction it ran in on PostgreSQL, and the caller's
221
+ # transaction must stay usable — to go on under :report / :log, and to
222
+ # fail at its commit under :raise rather than at its next statement.
223
+ # Whatever fails here — resolving the actor, a payload that cannot be
224
+ # read, the INSERT — follows `strategy`.
225
+ #
226
+ # @return [AuditEvent, nil, FAILED]
227
+ def write(recorder, store:, strategy:, change:)
228
+ recorder.attributes
229
+ return nil unless store
230
+
231
+ PermissionForge::AuditEvent.transaction(requires_new: true) { recorder.store! }
232
+ rescue StandardError => e
233
+ raise_not_recorded(recorder, e) if change && strategy == :raise
234
+
235
+ Failure.handle(e, strategy: strategy, operation: :store, event: recorder.name)
236
+ FAILED
237
+ end
238
+
239
+ # The change must not be committed: raise out of its callback, and make
240
+ # the transaction it runs in fail at commit for a caller that rescues.
241
+ def raise_not_recorded(recorder, error)
242
+ OpenTransaction.current&.before_commit do
243
+ raise EventNotRecordedError.new(event: recorder.name, at_commit: true), cause: error
244
+ end
245
+ raise EventNotRecordedError.new(event: recorder.name)
246
+ end
247
+
248
+ # After the open transaction commits (never after a rollback), else now.
249
+ def publish(recorder, stored)
250
+ return unless listening?(recorder.entry)
251
+
252
+ payload = recorder.notification_payload(stored)
253
+ transaction = OpenTransaction.current
254
+ if transaction
255
+ transaction.after_commit { notify(recorder.entry, payload) }
256
+ else
257
+ notify(recorder.entry, payload)
258
+ end
259
+ end
260
+
261
+ def notify(entry, payload)
262
+ ActiveSupport::Notifications.instrument(entry.notification, payload)
263
+ rescue StandardError => e
264
+ Failure.handle(e, strategy: configuration.audit_hook_failure_strategy, operation: :publish, event: entry.name)
265
+ end
266
+
267
+ # A table that was found is remembered without taking the lock. A
268
+ # missing one is believed for MISSING_TABLE_RECHECK seconds; a database
269
+ # that cannot be reached counts as no table and is asked again next time.
270
+ def table_available?
271
+ name = PermissionForge::AuditEvent.table_name
272
+ return true if @table_checks[name] == true
273
+
274
+ TABLE_CHECK_MUTEX.synchronize do
275
+ cached = @table_checks[name]
276
+ return true if cached == true
277
+ return false if cached.is_a?(Numeric) && Process.clock_gettime(Process::CLOCK_MONOTONIC) - cached < missing_table_recheck
278
+
279
+ check_table(name)
280
+ end
281
+ end
282
+
283
+ def check_table(name)
284
+ if PermissionForge::AuditEvent.table_exists?
285
+ @table_checks[name] = true
286
+ else
287
+ @table_checks[name] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
288
+ warn_missing_table(name) unless @warned_tables[name]
289
+ @warned_tables[name] = true
290
+ false
291
+ end
292
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished => e
293
+ Rails.logger&.warn("[PermissionForge] cannot check for the audit table '#{name}': #{e.class}") if defined?(Rails)
294
+ false
295
+ end
296
+
297
+ def warn_missing_table(name)
298
+ Rails.logger.warn(BootWarnings.missing_table(name)) if defined?(Rails) && Rails.logger
299
+ end
300
+ end
301
+ end
302
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # What the engine checks while the host app boots: run (from
5
+ # config.after_initialize) and ensure_tenancy_gem! (from an initializer).
6
+ #
7
+ # The configuration checks need nothing. The database steps are advisory
8
+ # warnings or idempotent work, and they must never abort a boot: a Docker
9
+ # build precompiles assets with no database, a worker can start while the
10
+ # database fails over. The database is pinged once (db_available?); a step
11
+ # that loses it midway is skipped (DATABASE_STEP_ERRORS) and the next step
12
+ # runs. Anything else a step raises (a ConfigurationError) still stops the
13
+ # boot.
14
+ #
15
+ # @api private
16
+ module BootChecks
17
+ # Errors that mean "the database is not there (yet)": gone, refused, not
18
+ # created, not migrated.
19
+ DATABASE_STEP_ERRORS = [ActiveRecord::ConnectionNotEstablished, ActiveRecord::StatementInvalid].freeze
20
+
21
+ # The boot steps that read the database, in order. Each runs on its
22
+ # own: one that loses the database is skipped (database_step).
23
+ DATABASE_STEPS = {
24
+ unmasked_gdpr_fields: ->(config) { warn_unmasked_gdpr_fields(config) unless config.gdpr_defaults },
25
+ conditional_sets: ->(config) { warn_conditional_sets_without_context(config) },
26
+ join_rules: ->(config) { QueryScopingWarnings.warn_join_rules_not_auto_scoped(config) },
27
+ row_level_security: ->(config) { QueryScopingWarnings.warn_rls_misconfiguration(config) },
28
+ # The missing-table warning, auditing switched off, one shared login.
29
+ audit: ->(config) { Audit::BootWarnings.call(config) },
30
+ # The audit settings in force, when they differ from the last recorded
31
+ # ones. Never in the test environment (it is invoked directly there).
32
+ audit_settings: ->(_config) { Audit::SettingsObserver.call unless Rails.env.test? },
33
+ # Auto-applied masking (MaskedModels), kept in step by MaskingSync.
34
+ masked_attributes: ->(_config) { MaskingSync.boot! }
35
+ }.freeze
36
+
37
+ class << self
38
+ # @param config [Configuration]
39
+ def run(config)
40
+ config.validate_gdpr_explicit_choice!
41
+ warn_configuration(config)
42
+ return database_unreachable unless db_available?
43
+
44
+ DATABASE_STEPS.each { |name, step| database_step(name) { step.call(config) } }
45
+ end
46
+
47
+ # HasTenantScoping silently skips the scope when the gem is absent,
48
+ # which would leave every tenant's permission data visible to every
49
+ # other tenant — refuse to boot instead.
50
+ #
51
+ # @param config [Configuration]
52
+ # @raise [ConfigurationError]
53
+ def ensure_tenancy_gem!(config)
54
+ return unless config.tenant_enabled?
55
+ return if defined?(::ActsAsTenant)
56
+
57
+ raise PermissionForge::ConfigurationError,
58
+ "config.tenant_model is set to '#{config.tenant_model}' but the acts_as_tenant gem is not loaded. " \
59
+ 'Add `gem "acts_as_tenant"` to your Gemfile, or set config.tenant_model = nil.'
60
+ end
61
+
62
+ # Conditional permission sets (session flag / IP / login hours) with a
63
+ # request_context_resolver that returns an empty context evaluate as
64
+ # unconditional (always active) unless conditional_sets_strict_mode is on.
65
+ def warn_conditional_sets_without_context(config)
66
+ # Strict mode denies conditional sets under blank context (fail closed),
67
+ # so the always-active footgun doesn't apply — no warning needed.
68
+ return if config.conditional_sets_strict_mode
69
+
70
+ conditional_sets = PermissionForge::PermissionSet.conditional
71
+ return unless conditional_sets.exists?
72
+ return unless resolved_context(config).empty?
73
+
74
+ Rails.logger.warn '[PermissionForge] SECURITY warning: ' \
75
+ "#{conditional_sets.count} permission set(s) use activation conditions " \
76
+ '(session flag, allowed_ips, or login hours) but request_context_resolver ' \
77
+ 'returns empty context. These sets will evaluate as unconditional ' \
78
+ '(ALWAYS ACTIVE) — IP/hours/consent restrictions are NOT enforced. ' \
79
+ 'Configure config.request_context_resolver, or set ' \
80
+ 'config.conditional_sets_strict_mode = true to fail closed.'
81
+ rescue ActiveRecord::StatementInvalid
82
+ # Tables don't exist yet (pre-migration)
83
+ end
84
+
85
+ def warn_api_authorization_gap(config)
86
+ return unless config.api_enabled
87
+ return if config.api_require_authorization
88
+ return if config.api_action_authorization && config.api_assignment_authorization
89
+
90
+ Rails.logger&.warn '[PermissionForge] SECURITY warning: API is enabled, ' \
91
+ 'config.api_require_authorization is false and ' \
92
+ 'api_action_authorization / api_assignment_authorization is not set. ' \
93
+ 'api_auth only proves identity — any authenticated caller can create ' \
94
+ 'permission sets and assign them (privilege escalation). Configure both ' \
95
+ 'authorization lambdas, or remove api_require_authorization = false ' \
96
+ '(the default, true, denies mutations until they are set).'
97
+ end
98
+
99
+ # One query for the distinct unmasked names (no record is built), and
100
+ # one more for the resource of the first match.
101
+ def warn_unmasked_gdpr_fields(config)
102
+ patterns = config.gdpr_field_patterns
103
+ return if patterns.blank?
104
+
105
+ unmasked = PermissionForge::ResourceField.where(mask_access: false)
106
+ name = unmasked.distinct.pluck(:name).find { |field_name| gdpr_match?(field_name, patterns) }
107
+ return unless name
108
+
109
+ resource = unmasked.where(name: name).first&.resource
110
+ Rails.logger.warn "[PermissionForge] GDPR warning: field '#{name}' on resource " \
111
+ "'#{resource&.name}' matches GDPR pattern but is not masked. " \
112
+ 'Consider enabling gdpr_defaults or masking this field manually.'
113
+ rescue ActiveRecord::StatementInvalid
114
+ # Table doesn't exist yet (pre-migration)
115
+ end
116
+
117
+ private
118
+
119
+ # Connects and pings. `ActiveRecord::Base.connection` alone is not enough:
120
+ # since Rails 7.2 it hands back a connection that has not connected yet,
121
+ # so an unreachable database looked available.
122
+ def db_available?
123
+ PermissionForge::PermissionSet.connection_pool.with_connection(&:verify!)
124
+ true
125
+ rescue ActiveRecord::ActiveRecordError
126
+ false
127
+ end
128
+
129
+ def database_step(name)
130
+ yield
131
+ rescue *DATABASE_STEP_ERRORS => e
132
+ Rails.logger&.warn "[PermissionForge] Boot check #{name} skipped: the database could not be read (#{e.class})."
133
+ end
134
+
135
+ # The boot step did not run: the first request or job that reaches the
136
+ # database applies auto-applied masking (MaskingSync).
137
+ def database_unreachable
138
+ MaskingSync.reset!
139
+ Rails.logger&.warn '[PermissionForge] The database could not be reached at boot; the boot checks that ' \
140
+ 'read it (security warnings) were skipped. Auto-applied masking is applied at the first ' \
141
+ 'request or job that reaches the database.'
142
+ end
143
+
144
+ # The boot warnings that only read the configuration.
145
+ def warn_configuration(config)
146
+ warn_memory_cache_in_production(config)
147
+ warn_basic_auth_without_credentials(config)
148
+ warn_api_authorization_gap(config)
149
+ config.tenantless_admin_warning&.then { |message| Rails.logger&.warn(message) }
150
+ QueryScopingWarnings.warn_unscoped_detection_raise_in_production(config)
151
+ end
152
+
153
+ def warn_memory_cache_in_production(config)
154
+ return unless Rails.env.production? && config.cache_backend == :memory
155
+
156
+ Rails.logger&.warn '[PermissionForge] WARNING: Using in-process MemoryCache in production. ' \
157
+ 'Permissions will be inconsistent across Puma workers. ' \
158
+ 'Configure config.cache_backend = :rails_cache with Redis, Memcached, or SolidCache.'
159
+ end
160
+
161
+ def warn_basic_auth_without_credentials(config)
162
+ return unless config.http_basic_auth_enabled
163
+ return if config.http_basic_auth_user.present? && config.http_basic_auth_password.present?
164
+
165
+ Rails.logger&.warn '[PermissionForge] HTTP Basic Auth is enabled but credentials are not configured. ' \
166
+ 'The admin UI will return 401 for all requests. ' \
167
+ 'Set config.http_basic_auth_user and config.http_basic_auth_password.'
168
+ end
169
+
170
+ def gdpr_match?(field_name, patterns)
171
+ patterns.any? { |regex, _| field_name.match?(regex) }
172
+ end
173
+
174
+ # Taken as the per-read selection takes it: a Hash, or anything with
175
+ # #to_h (a Struct; nil is blank). A resolver that raises gives none.
176
+ def resolved_context(config)
177
+ resolved = config.request_context_resolver.call
178
+ resolved.is_a?(Hash) ? resolved : resolved.to_h
179
+ rescue StandardError
180
+ {}
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Cache
5
+ # The invalidations this process could not get the store to confirm, kept
6
+ # so they can be made when the store answers again. An entry is listed by
7
+ # its stamp key: one per user (PermissionCacheAccessor), one for the
8
+ # managed-actions index (ControllerActions::ManagedIndex).
9
+ #
10
+ # Without it, an entry that should have been removed while the store was
11
+ # unreachable is a cache hit again as soon as the store is back, and
12
+ # stays one until cache_ttl. With it:
13
+ #
14
+ # - a user listed here is not served from the store by this process:
15
+ # the hash is rebuilt (PermissionCacheAccessor#pf_read_shared_permissions);
16
+ # neither is the managed-actions index while it is listed;
17
+ # - the listed invalidations are tried again after the next one the
18
+ # store confirms, and before a hash is read from the store while the
19
+ # list is not empty (Invalidation.retry_owed).
20
+ #
21
+ # This is extra effort on top of the failure report, not a guarantee. The
22
+ # list lives in this process: another process serves the entry as before,
23
+ # a restart forgets the list, and it holds LIMIT entries — the one owed
24
+ # longest is dropped for a new one. An edit to a set whose holders the
25
+ # store fails to invalidate (HolderInvalidation, during an outage) owes
26
+ # one entry per holder: past LIMIT holders only the last LIMIT are kept.
27
+ # For all of those cache_ttl remains the backstop, and every failure was
28
+ # reported when it happened.
29
+ #
30
+ # A pass over the list must not turn an outage — or the recovery from
31
+ # one — into a delay for one request: it tries PER_PASS entries at most
32
+ # (the next confirmed invalidation or store read goes on with the rest),
33
+ # it stops after GIVE_UP_AFTER failed tries, and after a failed try the
34
+ # next pass waits PAUSE seconds. A try that failed moves to the end of the
35
+ # list, so an entry whose key keeps failing cannot hold up the others.
36
+ #
37
+ # @api private
38
+ class Backlog
39
+ LIMIT = 500
40
+ PER_PASS = 25
41
+ GIVE_UP_AFTER = 3
42
+ PAUSE = 5
43
+
44
+ MONOTONIC = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
45
+
46
+ # @param clock [#call] seconds, monotonic
47
+ def initialize(clock: MONOTONIC)
48
+ @clock = clock
49
+ @entries = {}
50
+ @mutex = Mutex.new
51
+ @pass = Mutex.new
52
+ @paused_until = nil
53
+ end
54
+
55
+ # @return [Boolean] lock-free, for the path of every permission read
56
+ def empty?
57
+ @entries.empty?
58
+ end
59
+
60
+ # @param stamp_key [String] e.g. "user_1_invalidation"
61
+ # @return [Boolean]
62
+ def include?(stamp_key)
63
+ @mutex.synchronize { @entries.key?(stamp_key) }
64
+ end
65
+
66
+ # @param user_id [Object, nil] whose invalidation is owed (for the
67
+ # report; nil for the managed-actions index)
68
+ # @param stamp_key [String] where its invalidation stamp lives: what the
69
+ # entry is listed by
70
+ # @param keys [Array<String>] the cache keys that may still hold an entry
71
+ # @return [Boolean] whether this is news: false when the entry was listed
72
+ # with all of these keys already
73
+ def add(user_id:, stamp_key:, keys:)
74
+ @mutex.synchronize do
75
+ known = @entries.delete(stamp_key)
76
+ keys |= known[:keys] if known
77
+ @entries[stamp_key] = { user_id: user_id, stamp_key: stamp_key, keys: keys }.freeze
78
+ @entries.shift while @entries.size > LIMIT
79
+ known.nil? || keys.size > known[:keys].size
80
+ end
81
+ end
82
+
83
+ # One pass over the list, unless one is running or the last one failed
84
+ # less than PAUSE seconds ago.
85
+ #
86
+ # @yieldparam entry [Hash] user_id:, stamp_key:, keys:
87
+ # @yieldreturn [Boolean] whether the store confirmed the invalidation
88
+ # @return [void]
89
+ def try_again(&)
90
+ return if paused? || !@pass.try_lock
91
+
92
+ begin
93
+ pause if pass(&).positive?
94
+ ensure
95
+ @pass.unlock
96
+ end
97
+ end
98
+
99
+ # @return [void]
100
+ def clear
101
+ @mutex.synchronize { @entries.clear }
102
+ @paused_until = nil
103
+ end
104
+
105
+ private
106
+
107
+ # @return [Integer] the tries that failed
108
+ def pass
109
+ failures = 0
110
+ @mutex.synchronize { @entries.values.first(PER_PASS) }.each do |entry|
111
+ break if failures >= GIVE_UP_AFTER
112
+
113
+ confirmed = yield(entry)
114
+ failures += 1 unless confirmed
115
+ settle(entry, confirmed)
116
+ end
117
+ failures
118
+ end
119
+
120
+ # Takes a confirmed entry off the list and moves a failed one to its
121
+ # end — unless a new failure of the same entry has replaced it meanwhile.
122
+ def settle(entry, confirmed)
123
+ stamp_key = entry[:stamp_key]
124
+ @mutex.synchronize do
125
+ next unless @entries[stamp_key].equal?(entry)
126
+
127
+ @entries.delete(stamp_key)
128
+ @entries[stamp_key] = entry unless confirmed
129
+ end
130
+ end
131
+
132
+ def paused?
133
+ paused_until = @paused_until
134
+ !paused_until.nil? && @clock.call < paused_until
135
+ end
136
+
137
+ def pause
138
+ @paused_until = @clock.call + PAUSE
139
+ end
140
+ end
141
+ end
142
+ end