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,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Audit
5
+ # Whether a runtime event could be stored or heard right now — asked on
6
+ # the hottest paths of the gem (every attribute read under
7
+ # MaskedAttributes asks it for field.access), so the answer is kept and
8
+ # only worked out again when something it depends on changed. The
9
+ # generation is bumped by:
10
+ #
11
+ # - the writers of the options that decide it (audit_runtime_events,
12
+ # on_field_filtered, on_rule_bypass, audit_log_subscriber);
13
+ # - every subscribe and unsubscribe: they go through
14
+ # ActiveSupport::Notifications::Fanout#clear_cache (FanoutHook), also
15
+ # for a pattern subscriber;
16
+ # - a new notifier (ActiveSupport::Notifications.notifier=, NotifierHook);
17
+ # - a new Configuration (PermissionForge.reset!, Configuration.new). A
18
+ # configuration object put back in place past PermissionForge's API
19
+ # (instance_variable_set with one made earlier) is not noticed.
20
+ #
21
+ # The answer is a superset: a subscriber of the host that says it is
22
+ # silenced still counts (it may change its mind without telling), and
23
+ # Audit.instrument asks the notifier again before it builds anything. The
24
+ # gem's own subscribers (Subscribers::Base) are silenced by their option,
25
+ # whose writer bumps the generation, so they count only while it is set.
26
+ # Where the notifier is not a Fanout with the hook, nothing is kept and
27
+ # the notifier is asked every time.
28
+ #
29
+ # Gate.for('field.access').open? # => false
30
+ #
31
+ # @api private
32
+ class Gate
33
+ # Bumps the generation whenever ActiveSupport changes who listens.
34
+ module FanoutHook
35
+ def clear_cache(...)
36
+ super
37
+ ensure
38
+ PermissionForge::Audit::Gate.changed!
39
+ end
40
+ end
41
+
42
+ # Bumps the generation when the notifier is replaced.
43
+ module NotifierHook
44
+ def notifier=(notifier)
45
+ super
46
+ ensure
47
+ PermissionForge::Audit::Gate.changed!
48
+ end
49
+ end
50
+
51
+ MUTEX = Mutex.new
52
+ # The generation, read on every check: an Array element in a constant
53
+ # is the cheapest read Ruby has.
54
+ GENERATION = [0] # rubocop:disable Style/MutableConstant -- bumped in place by changed!
55
+
56
+ @gates = {}
57
+
58
+ class << self
59
+ # @return [Integer] bumped by every change the answers depend on
60
+ def generation = GENERATION[0]
61
+
62
+ # @param event [String] an access event of the catalogue
63
+ # @return [Gate]
64
+ def for(event)
65
+ @gates[event] || MUTEX.synchronize { @gates[event.to_s] ||= new(EventCatalog.fetch(event).name) }
66
+ end
67
+
68
+ # Something an answer depends on changed: every gate works it out again.
69
+ def changed!
70
+ MUTEX.synchronize { GENERATION[0] += 1 }
71
+ end
72
+
73
+ # Hooks the generation to ActiveSupport's subscriptions and notifier
74
+ # (once; Audit.install!). Without the hooks nothing is kept.
75
+ def install!
76
+ fanout = ActiveSupport::Notifications::Fanout
77
+ fanout.prepend(FanoutHook) if fanout.method_defined?(:clear_cache) && !(fanout <= FanoutHook)
78
+ notifications = ActiveSupport::Notifications.singleton_class
79
+ notifications.prepend(NotifierHook) unless notifications <= NotifierHook
80
+ changed!
81
+ end
82
+
83
+ # Whether answers can be kept for this notifier.
84
+ def tracking?(notifier)
85
+ notifier.is_a?(FanoutHook) && ActiveSupport::Notifications.singleton_class <= NotifierHook
86
+ end
87
+ end
88
+
89
+ # @param name [String] a registered access event
90
+ def initialize(name)
91
+ @name = name
92
+ @snapshot = nil
93
+ end
94
+
95
+ # The kept answer is one frozen Array [generation, answer], replaced
96
+ # whole: a reader on another thread sees an old answer or a new one,
97
+ # never half of each.
98
+ #
99
+ # @return [Boolean] whether the event's group is stored or anyone may listen
100
+ def open?
101
+ snapshot = @snapshot
102
+ return snapshot[1] if snapshot && snapshot[0] == GENERATION[0]
103
+
104
+ refresh[1]
105
+ end
106
+
107
+ # The gem's own subscribers that take a light payload, when they are
108
+ # the only ones that hear the event and its group is not stored: the
109
+ # legacy hooks alone. Audit.instrument then hands them the payload
110
+ # without building an event (no Recorder, no notification) — a hook set
111
+ # for years must not cost what a full audit event costs. nil otherwise,
112
+ # and wherever nothing is kept (no tracking).
113
+ #
114
+ # @return [Array<Subscribers::Base>, nil]
115
+ def direct_listeners
116
+ snapshot = @snapshot
117
+ return snapshot[2] if snapshot && snapshot[0] == GENERATION[0]
118
+
119
+ refresh[2]
120
+ end
121
+
122
+ private
123
+
124
+ # The generation is read before the answer is worked out: a change made
125
+ # meanwhile leaves a snapshot that is already out of date.
126
+ #
127
+ # @return [Array(Integer, Boolean, Array|nil)]
128
+ def refresh
129
+ generation = GENERATION[0]
130
+ notifier = ActiveSupport::Notifications.notifier
131
+ entry = EventCatalog.fetch(@name)
132
+ stored = PermissionForge.configuration.store_runtime_group?(entry.runtime_group)
133
+ listeners = heard_by(notifier, entry.notification)
134
+ tracking = Gate.tracking?(notifier)
135
+ snapshot = [generation, stored || !listeners.empty?, (direct_of(listeners) if tracking && !stored)].freeze
136
+ @snapshot = (snapshot if tracking)
137
+ snapshot
138
+ end
139
+
140
+ # What hears the notification: a delegate per listener (the listener
141
+ # itself when it has none); [:unknown] when the notifier cannot say.
142
+ def heard_by(notifier, notification)
143
+ unless notifier.respond_to?(:all_listeners_for)
144
+ return notifier.listening?(notification) ? [:unknown] : []
145
+ end
146
+
147
+ notifier.all_listeners_for(notification).filter_map do |listener|
148
+ delegate = listener.respond_to?(:delegate) ? listener.delegate : listener
149
+ delegate if counts?(delegate, notification)
150
+ end
151
+ end
152
+
153
+ def counts?(delegate, notification)
154
+ return true unless delegate.is_a?(Subscribers::Base)
155
+
156
+ !delegate.silenced?(notification)
157
+ end
158
+
159
+ def direct_of(listeners)
160
+ return if listeners.empty?
161
+ return unless listeners.all? { |delegate| delegate.is_a?(Subscribers::Base) && delegate.light? }
162
+
163
+ listeners.freeze
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Audit
5
+ # Every ActiveJob job runs with source 'job' in the audit context. The
6
+ # actor stays what the context already says (a job performed inline
7
+ # inside an admin request keeps the admin) or what the job names with
8
+ # PermissionForge.with_audit_actor; otherwise the usual resolution
9
+ # (PermissionForge.current_user, else unknown).
10
+ #
11
+ # Installed by the engine through ActiveSupport.on_load(:active_job), so
12
+ # an app without ActiveJob never loads it.
13
+ #
14
+ # @api private
15
+ module JobContext
16
+ def self.install!
17
+ ActiveSupport.on_load(:active_job) { include PermissionForge::Audit::JobContext::Hook }
18
+ end
19
+
20
+ # Included into ActiveJob::Base.
21
+ module Hook
22
+ extend ActiveSupport::Concern
23
+
24
+ included do
25
+ around_perform do |_job, block|
26
+ PermissionForge::Audit::Context.with(source: 'job') { block.call }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Audit
5
+ # The one answer to "may this request read the audit log?", asked by the
6
+ # JSON API endpoint and by the HTML pages and exports alike.
7
+ #
8
+ # The request has already passed the surface's own gate (api_enabled and
9
+ # api_auth for the API, the admin authentication for the HTML pages) by the
10
+ # time this is asked, so with `config.audit_log_authorization` unset the
11
+ # answer is yes: reading the log needs no more than the admin access that
12
+ # reading every other admin endpoint needs.
13
+ #
14
+ # With a lambda(controller) set, only an exact `true` allows. Anything else
15
+ # (false, nil, a truthy non-true value) denies, and so does a lambda that
16
+ # raises: an authorization hook that fails must never open the log.
17
+ #
18
+ # @api private
19
+ module LogAccess
20
+ class << self
21
+ # @param controller [ActionController::Base] the request's controller
22
+ # @param configuration [PermissionForge::Configuration]
23
+ # @return [Boolean]
24
+ def allowed?(controller, configuration: PermissionForge.configuration)
25
+ hook = configuration.audit_log_authorization
26
+ return true if hook.nil?
27
+
28
+ true.equal?(hook.call(controller))
29
+ rescue StandardError => e
30
+ Failure.handle(e, strategy: :report, operation: :authorize, hook: 'audit_log_authorization')
31
+ false
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Audit
5
+ # Builds the row of one event from its catalogue entry, the audit context
6
+ # and the caller's payload, stores it (AuditEvent) and gives the
7
+ # notification payload. The row is built once (#attributes) and kept.
8
+ #
9
+ # Payload keys (anything else raises — a typo must not pass silently):
10
+ # record: the record the event is about. A permission model
11
+ # (Concerns::RecordsAuditEvents) fills its columns
12
+ # through #audit_reference. Any other record fills
13
+ # record_type (its base class), record_id and, when
14
+ # the table has a uuid column, record_uuid from the
15
+ # raw column — never through its readers, which a
16
+ # masked host model guards, and never a label
17
+ # record_label: the label of a host record, if any
18
+ # data: event-specific Hash, stored as JSON with string keys
19
+ # reason: free text or ticket reference
20
+ # actor: an Actor, a user or a Hash — overrides Actor.current
21
+ # source: overrides Source.current
22
+ # subject_user_id: the person whose access is affected
23
+ # permission_set: a PermissionSet (fills id and uuid) or an id
24
+ # occurred_at: defaults to now (UTC)
25
+ # record_type, record_id, record_uuid, permission_set_id,
26
+ # permission_set_uuid, tenant_id: column overrides
27
+ # ambient_tenant_id: the tenant that was ambient when the event
28
+ # happened, for an event written later
29
+ # (Audit::Detached); defaults to the one ambient now
30
+ # objects: live objects for the subscribers in this process
31
+ # (the user a decision was made for, …): handed over
32
+ # as payload[:objects], never stored or serialized
33
+ #
34
+ # @api private
35
+ class Recorder
36
+ OPTIONS = %i[record data reason actor source subject_user_id permission_set occurred_at
37
+ record_type record_id record_uuid record_label permission_set_id permission_set_uuid tenant_id
38
+ ambient_tenant_id objects].freeze
39
+ NO_OBJECTS = {}.freeze
40
+ REFERENCE_COLUMNS = %i[record_type record_id record_uuid record_label permission_set_id permission_set_uuid
41
+ subject_user_id tenant_id].freeze
42
+
43
+ attr_reader :entry
44
+
45
+ # @param entry [EventCatalog::Entry]
46
+ # @param payload [Hash] see OPTIONS
47
+ def initialize(entry, payload)
48
+ unknown = payload.keys - OPTIONS
49
+ raise ArgumentError, "unknown audit payload key(s) #{unknown.map(&:inspect).join(', ')} (known: #{OPTIONS.join(', ')})" \
50
+ unless unknown.empty?
51
+
52
+ @entry = entry
53
+ @payload = payload
54
+ end
55
+
56
+ def name
57
+ entry.name
58
+ end
59
+
60
+ # The event as a Hash: every column of AuditEvent (uuid included) plus
61
+ # tenant_id. What subscribers receive, with stored: and audit_event:.
62
+ # Every string column is cleaned (Audit::ColumnValues).
63
+ def attributes
64
+ @attributes ||= ColumnValues.clean(raw_attributes).freeze
65
+ end
66
+
67
+ # @return [AuditEvent] the stored event
68
+ def store!
69
+ PermissionForge::AuditEvent.store!(attributes.except(:tenant_id).merge(subject_tenant_id: attributes[:tenant_id]))
70
+ end
71
+
72
+ # @param stored [AuditEvent, nil]
73
+ # @param deferred [Boolean] the row is written later: a runtime event
74
+ # heard inside an open transaction is stored once the outermost
75
+ # transaction has ended (Audit.instrument)
76
+ def notification_payload(stored, deferred: false)
77
+ attributes.merge(stored: !stored.nil?, audit_event: stored, deferred: deferred,
78
+ objects: @payload[:objects] || NO_OBJECTS)
79
+ end
80
+
81
+ private
82
+
83
+ def raw_attributes
84
+ {
85
+ uuid: Audit.new_id, event: entry.name, category: entry.category,
86
+ **actor.to_columns, source: source,
87
+ **reference,
88
+ data: data, reason: reason,
89
+ request_id: Context[:request_id], ip: Context[:ip],
90
+ correlation_id: correlation_id, occurred_at: occurred_at
91
+ }
92
+ end
93
+
94
+ def actor
95
+ Actor.coerce(@payload[:actor]) || Actor.current
96
+ end
97
+
98
+ def source
99
+ @payload[:source] ? Source.validate!(@payload[:source]) : Source.current
100
+ end
101
+
102
+ def reason
103
+ (@payload[:reason] || Context[:reason])&.to_s
104
+ end
105
+
106
+ def data
107
+ (@payload[:data] || {}).as_json
108
+ end
109
+
110
+ def occurred_at
111
+ (@payload[:occurred_at] || Time.current).utc
112
+ end
113
+
114
+ # The context's id, else one per open transaction, else one of its own:
115
+ # a correlation id is never blank.
116
+ def correlation_id
117
+ Context[:correlation_id] || Context.transaction_correlation_id || Audit.new_id
118
+ end
119
+
120
+ # Columns from the record, then from permission_set:, then explicit overrides.
121
+ def reference
122
+ columns = REFERENCE_COLUMNS.index_with { nil }.merge!(record_reference, permission_set_reference,
123
+ @payload.slice(*REFERENCE_COLUMNS))
124
+ columns[:record_id] = columns[:record_id]&.to_s
125
+ columns[:subject_user_id] = columns[:subject_user_id]&.to_s
126
+ columns[:tenant_id] = ambient_tenant_id if columns[:tenant_id].nil?
127
+ columns
128
+ end
129
+
130
+ # Pinned when the event is built: a row written later (after the
131
+ # transaction it was noticed in has ended) keeps the tenant of the
132
+ # moment it happened, whatever is ambient at the write.
133
+ def ambient_tenant_id
134
+ @payload.key?(:ambient_tenant_id) ? @payload[:ambient_tenant_id] : PermissionForge.ambient_tenant_id
135
+ end
136
+
137
+ def record_reference
138
+ record = @payload[:record]
139
+ return {} unless record
140
+ return record.audit_reference if record.respond_to?(:audit_reference)
141
+
142
+ { record_type: record_type_of(record), record_id: record.id, record_uuid: raw_uuid_of(record) }
143
+ end
144
+
145
+ def record_type_of(record)
146
+ klass = record.class
147
+ klass.respond_to?(:base_class) ? klass.base_class.name : klass.name
148
+ end
149
+
150
+ # The uuid column as stored, past the record's readers (a host model
151
+ # with MaskedAttributes guards those, and a guard that audits reads
152
+ # would recurse).
153
+ def raw_uuid_of(record)
154
+ return unless record.respond_to?(:has_attribute?) && record.has_attribute?('uuid')
155
+
156
+ record.read_attribute_before_type_cast('uuid')
157
+ end
158
+
159
+ def permission_set_reference
160
+ set = @payload[:permission_set]
161
+ return {} unless set
162
+ return { permission_set_id: set.id, permission_set_uuid: set.uuid } if set.respond_to?(:uuid)
163
+
164
+ { permission_set_id: set }
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Audit
5
+ # The payloads of refusal events built from what a client sent, bounded:
6
+ # a refused request must not be able to make its own record too big to
7
+ # store (MySQL refuses an over-long value; a huge JSON row costs the
8
+ # store), nor shape it with nested parameters.
9
+ #
10
+ # @api private
11
+ module Refusals
12
+ # How many of the people concerned are listed; user_count has them all.
13
+ USER_IDS_LIMIT = 100
14
+ VALUE_LIMIT = 255
15
+
16
+ module_function
17
+
18
+ # The payload of a refused change to who holds a Permission Set or
19
+ # belongs to a Permission Set Group, for Audit.record / pf_audit_refused.
20
+ #
21
+ # @param target [PermissionForge::PermissionSet, PermissionForge::PermissionSetGroup, nil]
22
+ # @param user_ids [Object] as the client sent them: a scalar, an Array, anything
23
+ # @param action [Symbol, String, nil] e.g. :grant, :revoke, :add_member
24
+ # @param hook [String] the option that refused (data 'hook')
25
+ # @return [Hash] subject_user_id: (one person only), permission_set: or record:, data:
26
+ def assignment(target, user_ids, hook:, action: nil)
27
+ ids = user_ids_of(user_ids)
28
+ data = { 'reason' => 'assignment_not_authorized', 'hook' => hook,
29
+ 'user_ids' => ids.first(USER_IDS_LIMIT), 'user_count' => ids.size }
30
+ data['action'] = action.to_s if action
31
+ { subject_user_id: (ids.first if ids.one?), data: data, **target_reference(target) }
32
+ end
33
+
34
+ # Scalars only (strings, numbers), each as a String of at most
35
+ # VALUE_LIMIT characters; nested arrays are flattened, hashes dropped.
36
+ #
37
+ # @return [Array<String>]
38
+ def user_ids_of(user_ids)
39
+ return [] if user_ids.nil? || user_ids.is_a?(Hash) || user_ids.respond_to?(:to_unsafe_h)
40
+
41
+ Array(user_ids).flatten.filter_map { |id| scalar(id) }
42
+ end
43
+
44
+ # @return [String, nil] a non-empty String or number, capped
45
+ def scalar(value)
46
+ return unless value.is_a?(String) || value.is_a?(Numeric)
47
+
48
+ value.to_s[0, VALUE_LIMIT].presence
49
+ end
50
+
51
+ # A Permission Set fills permission_set_id/uuid; a group is the record.
52
+ def target_reference(target)
53
+ case target
54
+ when nil then {}
55
+ when PermissionForge::PermissionSet then { permission_set: target }
56
+ else { record: target }
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Audit
5
+ # The audit context of one admin UI or API request, set by the
6
+ # around-action of both base controllers:
7
+ #
8
+ # around_action :pf_audit_request
9
+ # def pf_audit_request(&) = PermissionForge::Audit::RequestContext.wrap(self, source: 'admin_ui', &)
10
+ #
11
+ # Inside the block every event carries the actor (Audit::ActorResolver),
12
+ # the source, the request id and IP (config.audit_request_context, else
13
+ # `request.request_id` and `request.remote_ip`) and, for a request that
14
+ # can change something (anything but GET and HEAD), the `reason`
15
+ # parameter and one correlation id for all its events. A read-only page
16
+ # in Audit::AuditedViews is recorded after a successful response.
17
+ #
18
+ # Nothing is set outside the block, and the block restores the previous
19
+ # context also when the action raises.
20
+ #
21
+ # @api private
22
+ class RequestContext
23
+ # The reason column is a varchar(255) (MySQL counts characters).
24
+ REASON_LIMIT = 255
25
+ REQUEST_VALUE_LIMIT = 255
26
+ READ_ONLY_METHODS = %w[GET HEAD].freeze
27
+
28
+ class << self
29
+ # @param controller [ActionController::Metal]
30
+ # @param source [String] 'admin_ui' or 'api'
31
+ # @return the value of the block
32
+ def wrap(controller, source:, &)
33
+ new(controller, source).wrap(&)
34
+ end
35
+
36
+ # The request id and IP to store: config.audit_request_context
37
+ # (lambda(controller) → { request_id:, ip: }; a key it returns — also
38
+ # as nil — replaces the default) over request.request_id and the
39
+ # remote IP (#remote_ip). A lambda that raises is reported and the
40
+ # defaults are used.
41
+ #
42
+ # @return [Hash] { request_id:, ip: }
43
+ def request_slots(controller, configuration: PermissionForge.configuration)
44
+ request = controller.request
45
+ slots = { request_id: request.request_id, ip: remote_ip(request).first }
46
+ slots.merge!(custom_request_slots(controller, configuration))
47
+ slots.transform_values { |value| value.presence && value.to_s[0, REQUEST_VALUE_LIMIT] }
48
+ end
49
+
50
+ # `request.remote_ip`, which raises IpSpoofAttackError when the
51
+ # Client-IP and X-Forwarded-For headers disagree. That must neither
52
+ # fail the request nor keep a refused sign-in out of the log: the
53
+ # peer address is used instead, and the second value says so.
54
+ #
55
+ # @return [Array(String, Boolean)] [ip, spoofing suspected?]
56
+ def remote_ip(request)
57
+ [request.remote_ip, false]
58
+ rescue ActionDispatch::RemoteIp::IpSpoofAttackError
59
+ [request.remote_addr, true]
60
+ end
61
+
62
+ # The `reason` parameter (form field or JSON body key) of a request
63
+ # that can change something: a String cleaned as an event column is
64
+ # (one line, no NUL or other control character — PostgreSQL refuses a
65
+ # NUL, and the assignment rows keep the reason too), stripped, nil
66
+ # when blank, capped at REASON_LIMIT characters.
67
+ #
68
+ # @return [String, nil]
69
+ def reason(controller)
70
+ return unless mutating?(controller.request)
71
+
72
+ value = controller.params[:reason]
73
+ ColumnValues.value(value.strip)[0, REASON_LIMIT].strip.presence if value.is_a?(String)
74
+ end
75
+
76
+ def mutating?(request)
77
+ !READ_ONLY_METHODS.include?(request.request_method)
78
+ end
79
+
80
+ # Records a refusal — a failed sign-in, a denied authorization, a
81
+ # locked record — through Audit::Detached, so that a transaction the
82
+ # caller rolls back does not take it along. Works before the request's
83
+ # context is set (authentication runs first): source, request id and
84
+ # IP are set for the event, and the actor is `actor:`, else the
85
+ # request's, else whoever the host names (ActorResolver, no fallback:
86
+ # an unidentified client stays unknown). `data` gets the request's
87
+ # method, path and endpoint, and 'ip_spoof_suspected' => true when
88
+ # the forwarding headers contradict each other (#remote_ip).
89
+ #
90
+ # @param event [String] e.g. 'admin.auth_failed', 'api.auth_failed', 'admin.lock_refused'
91
+ # @param payload [Hash] see Audit::Recorder
92
+ # @return [AuditEvent, nil]
93
+ def refused(controller, event, source:, actor: nil, **payload)
94
+ data = request_data(controller).merge(payload.delete(:data) || {})
95
+ data['ip_spoof_suspected'] = true if remote_ip(controller.request).last
96
+ Context.with(source: source, **request_slots(controller)) do
97
+ actor ||= Context[:actor] || ActorResolver.call(controller, source: source, fallback: false)
98
+ Detached.record(event, actor: actor, source: source, data: data, **payload)
99
+ end
100
+ end
101
+
102
+ # @return [Hash] 'method', 'path' and 'endpoint' ("controller_path#action")
103
+ def request_data(controller)
104
+ request = controller.request
105
+ { 'method' => request.request_method, 'path' => request.path,
106
+ 'endpoint' => "#{controller.controller_path}##{controller.action_name}" }
107
+ end
108
+
109
+ private
110
+
111
+ def custom_request_slots(controller, configuration)
112
+ custom = configuration.audit_request_context
113
+ return {} unless custom
114
+
115
+ answer = custom.call(controller)
116
+ answer.respond_to?(:to_h) ? answer.to_h.symbolize_keys.slice(:request_id, :ip) : {}
117
+ rescue StandardError => e
118
+ Failure.handle(e, strategy: configuration.audit_hook_failure_strategy, operation: :request_context)
119
+ {}
120
+ end
121
+ end
122
+
123
+ def initialize(controller, source)
124
+ @controller = controller
125
+ @source = source
126
+ end
127
+
128
+ # @return the value of the block
129
+ def wrap(&block)
130
+ Context.with(**slots) do
131
+ result = self.class.mutating?(@controller.request) ? Audit.correlate(&block) : block.call
132
+ AuditedViews.record(@controller)
133
+ result
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ # An actor or source the host has already named around the request
140
+ # (PermissionForge.with_audit_actor in its own around-action) wins, as
141
+ # an explicit block does everywhere else.
142
+ def slots
143
+ {
144
+ actor: Context[:actor] || ActorResolver.call(@controller, source: @source),
145
+ source: Context[:source] || @source,
146
+ reason: self.class.reason(@controller),
147
+ **self.class.request_slots(@controller)
148
+ }
149
+ end
150
+ end
151
+ end
152
+ end