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,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Audit
5
+ # The per-request (per-thread, or per-fiber under Rails' fiber isolation:
6
+ # ExecutionState) store of what every event recorded inside a block
7
+ # carries: the actor, the source, the request id and IP, a reason, and a
8
+ # correlation id.
9
+ #
10
+ # Audit::Context.with(source: 'admin_ui', request_id: request.request_id, ip: request.remote_ip) do
11
+ # ...
12
+ # end
13
+ #
14
+ # Blocks nest: an inner value overrides the outer one for the block, a
15
+ # nil leaves the outer one in place, and the outer context is restored
16
+ # when the block ends — also when it raises. Nothing is ever set outside
17
+ # a block, so nothing can leak to the next request; as a second line of
18
+ # defence the end of every executor run empties a context that was empty
19
+ # when the run started (ExecutionState::RunHook): a request or a job ends
20
+ # with none, and a block that wraps the run keeps its own
21
+ # (with_audit_actor around an inline job keeps its actor after the job).
22
+ #
23
+ # @api public
24
+ module Context
25
+ KEY = :pf_audit_context
26
+ TRANSACTION_KEY = :pf_audit_transaction_correlation
27
+ SLOTS = %i[actor source request_id ip reason correlation_id].freeze
28
+ EMPTY = {}.freeze
29
+
30
+ class << self
31
+ # @return [Hash] frozen; empty when nothing is set
32
+ def current
33
+ state[KEY] || EMPTY
34
+ end
35
+
36
+ # @param slot [Symbol] one of SLOTS
37
+ # @return [Object, nil] the value of that slot in the current context
38
+ def [](slot)
39
+ current[slot]
40
+ end
41
+
42
+ # @param slots [Hash] actor: (an Actor, a user or a Hash), source:,
43
+ # request_id:, ip:, reason:, correlation_id:
44
+ # @return the value of the block
45
+ # @raise [ArgumentError] for an unknown slot, actor type or source
46
+ def with(**slots, &)
47
+ ExecutionState.with(KEY, current.merge(normalize(slots)).freeze, &)
48
+ end
49
+
50
+ # Empties the context of this thread (tests, the end of a request).
51
+ def reset!
52
+ state.delete(KEY)
53
+ state.delete(TRANSACTION_KEY)
54
+ end
55
+
56
+ # One id per outermost open transaction (OpenTransaction), minted the
57
+ # first time an event asks for it inside that transaction: a cascade
58
+ # delete or a Review-and-save then shares one id.
59
+ #
60
+ # @return [String, nil] nil when no transaction is open
61
+ def transaction_correlation_id
62
+ transaction = OpenTransaction.current
63
+ return unless transaction
64
+
65
+ handle, id = state[TRANSACTION_KEY]
66
+ return id if handle && !handle.finished?
67
+
68
+ id = Audit.new_id
69
+ state[TRANSACTION_KEY] = [transaction, id]
70
+ id
71
+ end
72
+
73
+ private
74
+
75
+ def state
76
+ ExecutionState
77
+ end
78
+
79
+ def normalize(slots)
80
+ slots.each_with_object({}) do |(slot, value), out|
81
+ raise ArgumentError, "unknown audit context slot #{slot.inspect} (slots: #{SLOTS.join(', ')})" unless SLOTS.include?(slot)
82
+ next if value.nil?
83
+
84
+ out[slot] = cast(slot, value)
85
+ end
86
+ end
87
+
88
+ def cast(slot, value)
89
+ case slot
90
+ when :actor then Actor.coerce(value)
91
+ when :source then Source.validate!(value)
92
+ else value.to_s
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Audit
5
+ # Records an event that must outlive the transaction it was noticed in:
6
+ # a failed sign-in, a refused change. Those are evidence of something
7
+ # that did NOT happen, so a rollback of whatever the caller had open must
8
+ # not take them with it.
9
+ #
10
+ # Audit::Detached.record('admin.lock_refused', record: set)
11
+ #
12
+ # No transaction open (OpenTransaction): written at once, like
13
+ # Audit.record. Inside one: written once that transaction has ended,
14
+ # committed or rolled back (a savepoint that rolls back hands it to the
15
+ # transaction around it). Who, where, the request and the reason are
16
+ # taken at the moment of the call.
17
+ #
18
+ # @api private
19
+ module Detached
20
+ class << self
21
+ # @param event [String] an admin, change or system event of the catalogue
22
+ # @param payload [Hash] see Audit::Recorder
23
+ # @return [AuditEvent, nil] the stored event; nil when it was deferred
24
+ # or auditing is off
25
+ def record(event, **payload)
26
+ transaction = OpenTransaction.current(PermissionForge::AuditEvent)
27
+ return Audit.record(event, **payload) unless transaction
28
+
29
+ defer(transaction, event, pinned(payload))
30
+ nil
31
+ end
32
+
33
+ private
34
+
35
+ # Who, where and the tenant of the moment of the call: a tenant
36
+ # switched (or lost) before the transaction ends does not move it.
37
+ def pinned(payload)
38
+ { actor: Actor.current, source: Source.current, ambient_tenant_id: PermissionForge.ambient_tenant_id }.merge(payload)
39
+ end
40
+
41
+ # OpenTransaction#after_end: once, when the outermost transaction has
42
+ # ended either way (a savepoint that rolls back hands it on).
43
+ def defer(transaction, event, payload)
44
+ context = Context.current
45
+ transaction.after_end { Context.with(**context) { write_later(event, payload) } }
46
+ end
47
+
48
+ # Runs from Rails' commit or rollback of the caller's transaction:
49
+ # raising there would replace the caller's own exception, or fail a
50
+ # transaction that has already committed. Nothing can be kept from
51
+ # happening any more, so a failure is reported (or logged) and never
52
+ # raised, whatever the strategy.
53
+ def write_later(event, payload)
54
+ Audit.record(event, **payload)
55
+ rescue StandardError => e
56
+ strategy = PermissionForge.configuration.audit_hook_failure_strategy
57
+ Failure.handle(e, strategy: strategy == :raise ? :report : strategy, operation: :store, event: event)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Audit
5
+ # The registry of audit events: name → category, and for runtime (access)
6
+ # events the group that `config.audit_runtime_events` switches storage on
7
+ # for. Adding an event is one `register` call; an event that is not here
8
+ # cannot be recorded or published (a typo fails loudly, not silently).
9
+ #
10
+ # Notification name: "<event>.permission_forge".
11
+ #
12
+ # @api public
13
+ class EventCatalog
14
+ # @api private
15
+ class UnknownEventError < ArgumentError; end
16
+
17
+ # `notification` is computed once: Audit.instrument asks for it on hot paths.
18
+ #
19
+ # @api private
20
+ Entry = Struct.new(:name, :category, :runtime_group, :notification, keyword_init: true) do
21
+ # Runtime events are published by Audit.instrument and stored per group.
22
+ def runtime? = category == 'access'
23
+ end
24
+
25
+ CATEGORIES = %w[change admin system access].freeze
26
+ RUNTIME_GROUPS = PermissionForge::Configuration::AuditSettings::RUNTIME_GROUPS
27
+
28
+ REGISTRY_MUTEX = Mutex.new
29
+
30
+ @entries = {}
31
+
32
+ class << self
33
+ # @param name [String, Symbol] e.g. 'assignment.revoked'
34
+ # @param category [String] change, admin, system or access
35
+ # @param runtime_group [Symbol, nil] required for access events, refused
36
+ # for the others
37
+ # @return [Entry]
38
+ def register(name, category:, runtime_group: nil)
39
+ entry = Entry.new(name: name.to_s, category: category.to_s, runtime_group: runtime_group&.to_sym,
40
+ notification: "#{name}.permission_forge").freeze
41
+ validate!(entry)
42
+ REGISTRY_MUTEX.synchronize { @entries[entry.name] = entry }
43
+ end
44
+
45
+ # Removes an event from the catalog (for tests that register their own).
46
+ # @param name [String, Symbol]
47
+ def unregister(name)
48
+ REGISTRY_MUTEX.synchronize { @entries.delete(name.to_s) }
49
+ end
50
+
51
+ # @raise [UnknownEventError] for a name that is not registered
52
+ def fetch(name)
53
+ @entries[name.to_s] || raise(UnknownEventError, "unknown audit event #{name.inspect}: register it with " \
54
+ 'PermissionForge::Audit::EventCatalog.register')
55
+ end
56
+
57
+ def registered?(name)
58
+ @entries.key?(name.to_s)
59
+ end
60
+
61
+ # @return [Hash{String => Entry}] a copy
62
+ def entries
63
+ @entries.dup.freeze
64
+ end
65
+
66
+ # @return [Array<String>] the names of every registered event
67
+ def names
68
+ @entries.keys
69
+ end
70
+
71
+ private
72
+
73
+ def validate!(entry)
74
+ raise ArgumentError, "audit event category must be one of #{CATEGORIES.join(', ')} (got #{entry.category.inspect})" \
75
+ unless CATEGORIES.include?(entry.category)
76
+
77
+ if entry.runtime?
78
+ raise ArgumentError, "access event #{entry.name} needs a runtime group (#{RUNTIME_GROUPS.join(', ')})" \
79
+ unless RUNTIME_GROUPS.include?(entry.runtime_group)
80
+ elsif entry.runtime_group
81
+ raise ArgumentError, "#{entry.name} is a #{entry.category} event: only access events have a runtime group"
82
+ end
83
+ end
84
+ end
85
+
86
+ # --- the catalogue of the first release ------------------------------------------
87
+
88
+ %w[permission_set resource resource_field resource_action permission_set_rule permission_set_group]
89
+ .product(%w[created updated destroyed]).each { |model, verb| register("#{model}.#{verb}", category: 'change') }
90
+
91
+ %w[permission_set.cloned permission_set_group.cloned assignment.granted assignment.changed assignment.revoked assignment.reviewed
92
+ group_membership.added group_membership.changed group_membership.removed field.sensitive_acknowledged]
93
+ .each { |name| register(name, category: 'change') }
94
+
95
+ %w[erasure.performed consent.withdrawn cleanup.expired_assignments cache.cleared audit.purged audit.settings_observed
96
+ report.generated].each { |name| register(name, category: 'system') }
97
+
98
+ %w[admin.effective_access_viewed audit.viewed audit.exported admin.auth_failed api.auth_failed admin.lock_refused]
99
+ .each { |name| register(name, category: 'admin') }
100
+
101
+ {
102
+ 'access.denied' => :denied, 'action.denied' => :denied,
103
+ 'rule.bypassed' => :bypass, 'query.unscoped' => :bypass,
104
+ 'field.access' => :field_read,
105
+ 'params.filtered' => :params_filtered, 'write.masked_skipped' => :params_filtered,
106
+ 'permissions.rebuilt' => :cache, 'permissions.invalidated' => :cache
107
+ }.each { |name, group| register(name, category: 'access', runtime_group: group) }
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Audit
5
+ # Turns filter parameters (the API endpoint, the HTML audit log) into a
6
+ # relation of audit events. Knows nothing of controllers.
7
+ # Every value reaches the database as a bound value through `where`
8
+ # (never interpolated); a value that is not a plain string, a timestamp
9
+ # that is not ISO 8601, or a name that is not in the catalogue is an
10
+ # error naming the parameter, not a silently empty result.
11
+ #
12
+ # @api private
13
+ class EventQuery
14
+ class Invalid < StandardError
15
+ attr_reader :errors
16
+
17
+ # @param errors [Hash{Symbol => Array<String>}] parameter => messages
18
+ def initialize(errors)
19
+ @errors = errors
20
+ super(errors.inspect)
21
+ end
22
+ end
23
+
24
+ DATE_ONLY = /\A(\d{4})-(\d{2})-(\d{2})\z/
25
+ TIMESTAMP = /\A(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(\.\d{1,9})?)?(Z|[+-]\d{2}(?::?\d{2})?)?\z/
26
+
27
+ # @param params [ActionController::Parameters, Hash] from to event category actor_type actor_id
28
+ # subject_user_id permission_set record_type record_id correlation_id (others are ignored)
29
+ # @param scope [ActiveRecord::Relation] the events visible to the caller
30
+ # @return [ActiveRecord::Relation] newest first
31
+ # @raise [Invalid] with #errors, parameter => messages
32
+ def self.call(params, scope: PermissionForge::AuditEvent.all)
33
+ new(params, scope).call
34
+ end
35
+
36
+ def initialize(params, scope)
37
+ @params = params
38
+ @scope = scope
39
+ @errors = Hash.new { |hash, key| hash[key] = [] }
40
+ end
41
+
42
+ # @return [ActiveRecord::Relation] newest first
43
+ # @raise [Invalid]
44
+ def call
45
+ filter_times
46
+ filter_events
47
+ filter_inclusion(:category, PermissionForge::AuditEvent::CATEGORIES)
48
+ filter_inclusion(:actor_type, PermissionForge::AuditEvent::ACTOR_TYPES)
49
+ %i[actor_id subject_user_id correlation_id record_type].each { |name| filter_equal(name) }
50
+ filter_permission_set
51
+ filter_record
52
+ raise Invalid, @errors.to_h if @errors.any?
53
+
54
+ @scope.newest_first
55
+ end
56
+
57
+ private
58
+
59
+ def invalid(name)
60
+ problem(name, :invalid)
61
+ end
62
+
63
+ # Rails' own validation messages (errors.messages.<kind>), like the
64
+ # 422 bodies of the other endpoints.
65
+ def problem(name, kind)
66
+ @errors[name] << I18n.t("errors.messages.#{kind}")
67
+ end
68
+
69
+ # A parameter's value as a String, nil when absent; an array or hash
70
+ # (`?actor_id[]=1`, `?actor_id[a]=1`) is invalid.
71
+ def value(name)
72
+ raw = @params[name]
73
+ return nil if raw.nil? || raw == ''
74
+ return raw if plain?(raw)
75
+
76
+ invalid(name)
77
+ nil
78
+ end
79
+
80
+ def plain?(raw)
81
+ raw.is_a?(String) && !raw.include?("\0")
82
+ end
83
+
84
+ def filter_equal(name)
85
+ found = value(name)
86
+ @scope = @scope.where(name => found) if found
87
+ end
88
+
89
+ def filter_inclusion(name, allowed)
90
+ found = value(name)
91
+ return unless found
92
+
93
+ allowed.include?(found) ? @scope = @scope.where(name => found) : invalid(name)
94
+ end
95
+
96
+ def filter_events
97
+ raw = @params[:event]
98
+ return if raw.nil? || raw == ''
99
+
100
+ names = event_names(raw)
101
+ return invalid(:event) unless names&.all? { |name| PermissionForge::Audit::EventCatalog.registered?(name) }
102
+
103
+ @scope = @scope.where(event: names.uniq)
104
+ end
105
+
106
+ # nil when anything in the list is not a plain, non-blank string.
107
+ def event_names(raw)
108
+ items = Array.wrap(raw)
109
+ return nil unless items.all? { |item| plain?(item) }
110
+
111
+ names = items.flat_map { |item| item.split(',').map(&:strip) }
112
+ names.any?(&:blank?) ? nil : names
113
+ end
114
+
115
+ def filter_times
116
+ from = parse_time(:from, end_of_day: false)
117
+ to = parse_time(:to, end_of_day: true)
118
+ @scope = @scope.where(PermissionForge::AuditEvent.arel_table[:occurred_at].gteq(from)) if from
119
+ @scope = @scope.where(PermissionForge::AuditEvent.arel_table[:occurred_at].lteq(to)) if to
120
+ end
121
+
122
+ # ISO 8601, read in UTC unless the text carries an offset; an impossible
123
+ # calendar date or clock time is invalid (never rolled over).
124
+ def parse_time(name, end_of_day:)
125
+ text = value(name)
126
+ return nil unless text
127
+
128
+ time = DATE_ONLY.match(text) ? parse_day(text, end_of_day) : parse_timestamp(text)
129
+ invalid(name) unless time
130
+ time
131
+ end
132
+
133
+ def parse_day(text, end_of_day)
134
+ year, month, day = text.scan(/\d+/).map(&:to_i)
135
+ return nil unless year.positive? && Date.valid_date?(year, month, day)
136
+
137
+ moment = Time.utc(year, month, day)
138
+ end_of_day ? moment.end_of_day : moment
139
+ end
140
+
141
+ def parse_timestamp(text)
142
+ match = TIMESTAMP.match(text)
143
+ offset = match && utc_offset(match[8])
144
+ return nil unless offset
145
+
146
+ year, month, day, hour, minute = match.captures.first(5).map(&:to_i)
147
+ second = Rational("#{match[6] || 0}#{match[7]}")
148
+ return nil unless real_moment?([year, month, day], hour, minute, second)
149
+
150
+ Time.new(year, month, day, hour, minute, second, offset)
151
+ end
152
+
153
+ def real_moment?(date, hour, minute, second)
154
+ date.first.positive? && Date.valid_date?(*date) && hour < 24 && minute < 60 && second < 60
155
+ end
156
+
157
+ # "UTC" for Z or no offset; "+HH:MM" otherwise; nil when out of range.
158
+ def utc_offset(text)
159
+ return 'UTC' if text.nil? || text == 'Z'
160
+
161
+ sign, hours, minutes = text.match(/([+-])(\d{2}):?(\d{2})?/).captures
162
+ return nil unless hours.to_i < 24 && minutes.to_i < 60
163
+
164
+ format('%<sign>s%<hours>s:%<minutes>s', sign: sign, hours: hours, minutes: minutes || '00')
165
+ end
166
+
167
+ # A numeric value is the id, anything else the uuid: one indexed column each.
168
+ def filter_permission_set
169
+ found = value(:permission_set)
170
+ return unless found
171
+
172
+ @scope = if found.match?(/\A\d{1,18}\z/)
173
+ @scope.where(permission_set_id: found.to_i)
174
+ else
175
+ @scope.where(permission_set_uuid: found)
176
+ end
177
+ end
178
+
179
+ # record_type alone, or with record_id or record_uuid (each one column, so
180
+ # (record_type, record_id, occurred_at) serves the first). Either id needs
181
+ # record_type.
182
+ def filter_record
183
+ ids = { record_id: value(:record_id), record_uuid: value(:record_uuid) }.compact
184
+ return if ids.empty?
185
+
186
+ return problem(:record_type, :blank) if @params[:record_type].blank?
187
+
188
+ @scope = @scope.where(ids)
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Audit
5
+ # What happens when an audit write, a notification subscriber or a hook
6
+ # fails, per the configured strategy:
7
+ # :raise — the error propagates (a change event then rolls the change back)
8
+ # :report — logged at error level and handed to Rails.error as handled;
9
+ # execution continues
10
+ # :log — logged only
11
+ #
12
+ # @api private
13
+ module Failure
14
+ SOURCE = 'permission_forge.audit'
15
+
16
+ class << self
17
+ # @param error [Exception]
18
+ # @param strategy [Symbol] :raise, :report or :log
19
+ # @param operation [Symbol] :store or :publish
20
+ # @param details [Hash] event: and anything else worth knowing
21
+ # @return [nil]
22
+ def handle(error, strategy:, operation:, **details)
23
+ raise error if strategy == :raise
24
+
25
+ log(error, operation, details)
26
+ report(error, operation, details) if strategy == :report
27
+ nil
28
+ end
29
+
30
+ private
31
+
32
+ def log(error, operation, details)
33
+ return unless defined?(Rails) && Rails.logger
34
+
35
+ what = details.map { |key, value| "#{key}=#{value}" }.join(' ')
36
+ Rails.logger.error("[PermissionForge] audit #{operation} failed (#{what}) — #{error.class}: #{error.message}")
37
+ end
38
+
39
+ def report(error, operation, details)
40
+ return unless defined?(Rails) && Rails.respond_to?(:error)
41
+
42
+ Rails.error.report(error, handled: true, severity: :error, source: SOURCE, context: { operation: operation, **details })
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Audit
5
+ # field.access — one event per record read, listing the fields that were
6
+ # hidden, masked, unconfigured or bypassed (no user), and the fields read
7
+ # in the clear (`passed_fields`, with their count in `passed`).
8
+ #
9
+ # A read is the outermost guarded access to one record: a getter, `[]` /
10
+ # `read_attribute`, `attributes`, `serializable_hash` (`as_json`,
11
+ # `to_json`), a virtual or store reader. While it runs, every decision
12
+ # AttributeAccessGuard makes about that record goes into one Frame, kept
13
+ # in a fiber-local ExecutionState slot (never on the record), and the
14
+ # event is published when the read returns. A read nested inside another record's
15
+ # read (an association serialized with `include:`) gets its own frame and
16
+ # event. Three getters called one after the other are three reads, so
17
+ # three events: the gem cannot know when a view or serializer is done
18
+ # with a record. FiltersResponseAttributes publishes one per hash it
19
+ # filters.
20
+ #
21
+ # Nothing is collected unless the event is stored (config.audit_runtime_events
22
+ # includes :field_read) or someone listens: the read then costs one
23
+ # Audit::Gate check. When the legacy on_field_filtered hook is the only
24
+ # listener and nothing is stored, no event is built either: each decision
25
+ # goes straight to the hook (Direct), as before the audit event log. The frame records every decision, passed ones too —
26
+ # the legacy on_field_filtered hook is called for each of them
27
+ # (payload[:objects][:decisions]); the stored row keeps the field names
28
+ # of each kind of decision (never a value). The names are gathered only
29
+ # when the event is built.
30
+ #
31
+ # @api private
32
+ module FieldAccess
33
+ EVENT = 'field.access'
34
+ # The ExecutionState slot of the read in progress (its Frame).
35
+ KEY = :pf_audit_field_access
36
+
37
+ # One AttributeAccessGuard decision. `hook` is false for a decision the
38
+ # legacy on_field_filtered hook was never called for (an unconfigured
39
+ # field in FiltersResponseAttributes, a response filtered without user).
40
+ Decision = Struct.new(:resource_name, :field_name, :action, :user, :hook)
41
+
42
+ LISTED = %i[hidden masked unconfigured bypassed].freeze
43
+
44
+ ENTRY = EventCatalog.fetch(EVENT)
45
+ # Asked on every guarded read.
46
+ GATE = Gate.for(EVENT)
47
+
48
+ # The decisions of one read.
49
+ class Frame
50
+ attr_reader :owner
51
+
52
+ # @param owner [Object] what is read: a record, or the Hash a
53
+ # response filter works on
54
+ # @param resource_name [String]
55
+ # @param record [ActiveRecord::Base, nil] the record, when there is one
56
+ # @param record_id [Object, nil] the id, when there is no record
57
+ def initialize(owner, resource_name, record = nil, record_id = nil)
58
+ @owner = owner
59
+ @resource_name = resource_name.to_s
60
+ @record = record
61
+ @record_id = record_id
62
+ @decisions = []
63
+ end
64
+
65
+ # @return [void]
66
+ def add(resource_name, field_name, action, user, hook: true)
67
+ return if Audit.emitting?
68
+
69
+ @decisions << Decision.new(resource_name.is_a?(Symbol) ? resource_name.name : resource_name.to_s, field_name, action, user, hook)
70
+ end
71
+
72
+ # Publishes the read's event (nothing when no field was decided).
73
+ def emit
74
+ return if @decisions.empty?
75
+
76
+ decisions = @decisions.freeze
77
+ # the legacy hook alone needs only the decisions (Gate#direct_listeners)
78
+ light = GATE.direct_listeners
79
+ return Audit.emit_light(ENTRY, light, { event: EVENT, data: nil, objects: objects(decisions) }) if light
80
+
81
+ Audit.instrument(EVENT) { payload(decisions) }
82
+ end
83
+
84
+ private
85
+
86
+ def payload(decisions)
87
+ user = decisions.first.user
88
+ reference = @record ? { record: @record } : { record_type: @resource_name, record_id: @record_id }
89
+ reference.merge(subject_user_id: Runtime.user_id(user), data: data(decisions), objects: objects(decisions))
90
+ end
91
+
92
+ def objects(decisions)
93
+ { user: decisions.first.user, record: @record, decisions: decisions }
94
+ end
95
+
96
+ def data(decisions)
97
+ lists = LISTED.to_h { |action| [action.to_s, []] }
98
+ passed = []
99
+ decisions.each { |decision| add_name(decision.action == :passed ? passed : lists[decision.action.to_s], decision) }
100
+ { 'model' => @resource_name }.merge(lists).merge('passed' => passed.size, 'passed_fields' => passed)
101
+ end
102
+
103
+ def add_name(names, decision)
104
+ name = decision.field_name.to_s
105
+ names << name if names && !names.include?(name)
106
+ end
107
+ end
108
+
109
+ # Where decisions go when the legacy on_field_filtered hook is the only
110
+ # listener and nothing is stored (Gate#direct_listeners): straight to
111
+ # the hook, one call per decision as before the audit event log — no
112
+ # frame, no event. Re-entrancy guarded like any emission.
113
+ module Direct
114
+ def self.add(resource_name, field_name, action, user, hook: true)
115
+ return unless hook
116
+
117
+ listeners = GATE.direct_listeners
118
+ Audit.emit_decision(listeners, resource_name, field_name, action, user) if listeners
119
+ end
120
+ end
121
+
122
+ class << self
123
+ # Runs the block as one read of `owner`, and publishes its event.
124
+ # Yields the frame to record decisions in, or nil when nobody listens.
125
+ # A read inside a read of the same owner joins it.
126
+ #
127
+ # @param owner [Object] the record (or Hash) being read
128
+ # @param resource_name [String]
129
+ # @param record [Object, nil] the record, for the event's reference
130
+ # @param record_id [Object, nil] the id when there is no record
131
+ # @return the block's value
132
+ def read(owner, resource_name, record: nil, record_id: nil)
133
+ return yield(nil) unless GATE.open?
134
+
135
+ outer = ExecutionState[KEY]
136
+ return yield(outer) if outer&.owner.equal?(owner)
137
+ return yield(Audit.emitting? ? nil : Direct) if GATE.direct_listeners
138
+ return yield(nil) unless Audit.instrument?(EVENT)
139
+
140
+ frame = Frame.new(owner, resource_name, record, record_id)
141
+ run(frame) { yield(frame) }
142
+ end
143
+
144
+ # Fields a serialization leaves out because they are hidden
145
+ # (attribute_names_for_serialization): decided without the guard, so
146
+ # the legacy hook was never called for them. They join the read of
147
+ # `owner` in progress, if any.
148
+ #
149
+ # @return [void]
150
+ def omitted(owner, resource_name, names, user)
151
+ frame = ExecutionState[KEY]
152
+ return unless frame&.owner.equal?(owner)
153
+
154
+ names.each { |name| frame.add(resource_name, name, :hidden, user, hook: false) }
155
+ end
156
+
157
+ # A decision made outside any read (AttributeAccessGuard called by
158
+ # the host app): it joins the read in progress, or is a read of its own.
159
+ #
160
+ # @return [void]
161
+ def note(resource_name, field_name, action, user)
162
+ return unless GATE.open?
163
+
164
+ frame = ExecutionState[KEY]
165
+ return frame.add(resource_name, field_name, action, user) if frame
166
+ return Direct.add(resource_name, field_name, action, user) if GATE.direct_listeners && !Audit.emitting?
167
+ return unless Audit.instrument?(EVENT)
168
+
169
+ single = Frame.new(nil, resource_name)
170
+ single.add(resource_name, field_name, action, user)
171
+ single.emit
172
+ end
173
+
174
+ private
175
+
176
+ # The frame is the read in progress while the block runs; the outer
177
+ # read (or none) is put back before the event is published.
178
+ def run(frame, &)
179
+ result = ExecutionState.with(KEY, frame, &)
180
+ frame.emit
181
+ result
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end