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,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # What the audit event page (audit_events/show) lays out for one event: the
6
+ # event's data split into its before/after changes, its snapshot and
7
+ # whatever else it carries, and which of the who/what rows have a value.
8
+ # The template only draws it.
9
+ #
10
+ # @example
11
+ # detail = AuditEventDetail.new(event, AuditLookups.new([event]))
12
+ # detail.change_rows # => [['mask_strategy', 'full', 'last4']]
13
+ # detail.no_data? # => false
14
+ class AuditEventDetail
15
+ SNAPSHOT_ENDINGS = %w[destroyed revoked removed].freeze
16
+
17
+ attr_reader :event, :lookups
18
+
19
+ def initialize(event, lookups)
20
+ @event = event
21
+ @lookups = lookups
22
+ end
23
+
24
+ # The person who acted, when the actor is a user we can open.
25
+ def actor_user
26
+ lookups.person(event.actor_id) if event.actor_type == 'user'
27
+ end
28
+
29
+ def person = lookups.person(event.subject_user_id)
30
+ def permission_set = lookups.permission_set(event.permission_set_id)
31
+
32
+ def actor_id? = event.actor_id.present?
33
+ def record? = event.record_type.present?
34
+ def record_id? = event.record_id.present?
35
+ def record_uuid? = event.record_uuid.present?
36
+ def permission_set? = event.permission_set_id.present? || event.permission_set_uuid.present?
37
+ def permission_set_key = event.permission_set_id || event.permission_set_uuid
38
+ def subject_user? = event.subject_user_id.present?
39
+ def correlation? = event.correlation_id.present?
40
+
41
+ # [attribute, before, after] per changed attribute; a bare value (not a
42
+ # [before, after] pair) is an "after" with no before.
43
+ def change_rows
44
+ return [] unless changes
45
+
46
+ changes.map do |attribute, pair|
47
+ before, after = pair.is_a?(Array) && pair.size == 2 ? pair : [nil, pair]
48
+ [attribute, before, after]
49
+ end
50
+ end
51
+
52
+ def changes? = !changes.nil?
53
+ def changes_count = changes&.size.to_i
54
+ def snapshot? = !snapshot.nil?
55
+ def snapshot_rows = snapshot.to_a
56
+
57
+ # The event's snapshot is of something that went away.
58
+ def snapshot_of_removed? = event.event.end_with?(*SNAPSHOT_ENDINGS)
59
+
60
+ def other_rows = other.to_a
61
+ def other? = other.any?
62
+ def no_data? = changes.nil? && snapshot.nil? && other.empty?
63
+
64
+ private
65
+
66
+ def data = @data ||= event.data.is_a?(Hash) ? event.data : {}
67
+ def changes = @changes ||= (data['changes'] if data['changes'].is_a?(Hash))
68
+ def snapshot = @snapshot ||= (data['snapshot'] if data['snapshot'].is_a?(Hash))
69
+
70
+ def other
71
+ @other ||= data.reject { |key, _| (key == 'changes' && changes) || (key == 'snapshot' && snapshot) }
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/numeric/time' # KNOWN_PEOPLE_WINDOW, read as this file loads
4
+
5
+ module PermissionForge
6
+ module Admin
7
+ # The admin's audit log: the events that match a filter, newest first,
8
+ # one page at a time, with no cap on how far back the pages go.
9
+ # Filtering is Audit::EventQuery's (the JSON API uses the same), so an
10
+ # invalid value raises Audit::EventQuery::Invalid naming the parameter.
11
+ #
12
+ # log = AuditLog.new(params) # every event
13
+ # log = AuditLog.new(params, scope: AuditLog.for_permission_set(set))
14
+ # log.page(params[:page]) # => AuditLog::Page
15
+ # log.relation # the whole result (exports)
16
+ class AuditLog
17
+ FILTERS = %w[from to event category actor_type actor_id subject_user_id permission_set correlation_id
18
+ record_type record_id record_uuid].freeze
19
+ PER_PAGE = 50
20
+ MAX_PAGE = 10_000_000
21
+ KNOWN_PEOPLE = 500
22
+ # known_people looks this far back (the occurred_at index serves it).
23
+ KNOWN_PEOPLE_WINDOW = 90.days
24
+ # Every view of the log writes one; left out of the default list and
25
+ # the timelines so they do not bury the changes (exports keep them).
26
+ VIEW_EVENT = 'audit.viewed'
27
+
28
+ Page = Struct.new(:events, :total, :number, :per_page, keyword_init: true) do
29
+ def pages = [(total + per_page - 1) / per_page, 1].max
30
+ def first_index = total.zero? ? 0 : ((number - 1) * per_page) + 1
31
+ def last_index = [number * per_page, total].min
32
+ def previous? = number > 1
33
+ def next? = number < pages
34
+ end
35
+
36
+ # Whether the audit table exists (the upgrade migration has run). The
37
+ # log can be read while recording is switched off; without the table
38
+ # there is nothing to read and every list is empty.
39
+ def self.table?
40
+ PermissionForge::AuditEvent.table_exists?
41
+ rescue StandardError
42
+ false
43
+ end
44
+
45
+ # Why the log is not recording, or nil when it records:
46
+ # :off audit_events is false (with or without the table)
47
+ # :failing audit_events is true, the table is missing and
48
+ # audit_change_failure_strategy is :raise — every
49
+ # permission change fails
50
+ # :missing_table the table is missing otherwise — nothing is recorded
51
+ def self.recording_problem(configuration = PermissionForge.configuration)
52
+ return :off if configuration.audit_events == false
53
+ return if table?
54
+ return :failing if configuration.audit_events == true && configuration.audit_change_failure_strategy == :raise
55
+
56
+ :missing_table
57
+ end
58
+
59
+ # The events of a Permission Set: its own, its resources', fields',
60
+ # actions', rules' and assignments' (permission_set_id is filled on all).
61
+ def self.for_permission_set(permission_set)
62
+ return PermissionForge::AuditEvent.none unless table?
63
+
64
+ PermissionForge::AuditEvent.where(permission_set_id: permission_set.id)
65
+ end
66
+
67
+ # The events about a person (their access) or by them (as the actor).
68
+ def self.for_person(user_id)
69
+ events = PermissionForge::AuditEvent
70
+ return events.none unless table?
71
+
72
+ events.where(subject_user_id: user_id.to_s).or(events.where(actor_type: 'user', actor_id: user_id.to_s))
73
+ end
74
+
75
+ # The filter parameters that are set, by name. Values are kept as
76
+ # given (an array stays an array, so EventQuery can refuse it).
77
+ #
78
+ # @param params [ActionController::Parameters, Hash]
79
+ # @return [ActiveSupport::HashWithIndifferentAccess]
80
+ def self.filters(params)
81
+ indifferent(params).slice(*FILTERS).reject { |_, value| value.blank? }
82
+ end
83
+
84
+ def self.indifferent(params)
85
+ (params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h).with_indifferent_access
86
+ end
87
+
88
+ attr_reader :filters
89
+
90
+ # @param params [ActionController::Parameters, Hash] see FILTERS; `views: '1'`
91
+ # keeps the views of the log in the list
92
+ # @param scope [ActiveRecord::Relation] the events to filter
93
+ # @param include_views [Boolean] keep the views of the log whatever the params say (exports)
94
+ def initialize(params = {}, scope: PermissionForge::AuditEvent.all, include_views: false)
95
+ @filters = self.class.filters(params)
96
+ @views_asked = self.class.indifferent(params)[:views].to_s == '1'
97
+ @include_views = include_views
98
+ @scope = scope
99
+ end
100
+
101
+ # Views of the log are listed when asked for, when the filter names an
102
+ # event or a category (whatever it names is shown), and in exports.
103
+ def include_views?
104
+ @include_views || @views_asked || @filters.key?('event') || @filters.key?('category')
105
+ end
106
+
107
+ # The filters plus the views toggle, for links that keep the page's state.
108
+ def link_params
109
+ @views_asked ? @filters.merge('views' => '1') : @filters
110
+ end
111
+
112
+ # @return [ActiveRecord::Relation] newest first
113
+ # @raise [Audit::EventQuery::Invalid]
114
+ def relation
115
+ @relation ||= PermissionForge::Audit::EventQuery.call(AuditLogDays.localize(@filters), scope: base_scope)
116
+ end
117
+
118
+ def base_scope
119
+ return @scope.none unless self.class.table?
120
+
121
+ include_views? ? @scope : @scope.where.not(event: VIEW_EVENT)
122
+ end
123
+ private :base_scope
124
+
125
+ # A page past the last one shows the last page.
126
+ #
127
+ # @param number [Object] the page parameter (1 when absent or not a number)
128
+ # @return [Page]
129
+ # @raise [Audit::EventQuery::Invalid]
130
+ def page(number, per_page: PER_PAGE)
131
+ total = relation.count
132
+ current = self.class.page_number(number, total, per_page)
133
+ events = relation.offset((current - 1) * per_page).limit(per_page).to_a
134
+ Page.new(events: events, total: total, number: current, per_page: per_page)
135
+ end
136
+
137
+ # 1 for anything that is not a number; at most the last page.
138
+ def self.page_number(number, total, per_page)
139
+ return 1 unless number.to_s.match?(/\A\d{1,9}\z/)
140
+
141
+ last = [(total + per_page - 1) / per_page, 1].max
142
+ number.to_i.clamp(1, [last, MAX_PAGE].min)
143
+ end
144
+
145
+ # People the log names lately (KNOWN_PEOPLE_WINDOW), for the actor and
146
+ # person filters' suggestions: [[id, label], ...]
147
+ # by id. Actors keep the name the event recorded; people who are only
148
+ # subjects are named from the user model when they still exist.
149
+ #
150
+ # @return [Array<Array(String, String)>]
151
+ def self.known_people(limit: KNOWN_PEOPLE, configuration: PermissionForge.configuration)
152
+ return [] unless table?
153
+
154
+ actors = actor_labels(limit)
155
+ people = actors.merge(subject_names(subject_ids(limit) - actors.keys, configuration))
156
+ people.sort_by { |id, _| [id.to_s.rjust(20, '0'), id.to_s] }.first(limit)
157
+ end
158
+
159
+ # { actor id => the name the events recorded } of people acting lately.
160
+ def self.actor_labels(limit)
161
+ recent.where(actor_type: 'user').where.not(actor_id: nil).group(:actor_id).limit(limit).maximum(:actor_label)
162
+ end
163
+
164
+ def self.subject_ids(limit)
165
+ recent.where.not(subject_user_id: nil).group(:subject_user_id).limit(limit).count.keys
166
+ end
167
+
168
+ def self.recent
169
+ PermissionForge::AuditEvent.where(occurred_at: KNOWN_PEOPLE_WINDOW.ago..)
170
+ end
171
+
172
+ # { id => label (nil when the person is gone) } for every id.
173
+ def self.subject_names(ids, configuration)
174
+ return {} if ids.empty?
175
+
176
+ found = configuration.user_model.where(id: ids).to_h { |user| [user.id.to_s, PersonLabel.new(user).name] }
177
+ ids.index_with { |id| found[id.to_s] }
178
+ rescue StandardError
179
+ ids.index_with(nil)
180
+ end
181
+ private_class_method :actor_labels, :subject_ids, :subject_names, :recent
182
+
183
+ # Every catalogued event name, by category (the event filter).
184
+ # @return [Hash{String => Array<String>}]
185
+ def self.event_names_by_category
186
+ PermissionForge::Audit::EventCatalog.entries.values.group_by(&:category)
187
+ .transform_values { |entries| entries.map(&:name).sort }
188
+ .sort_by { |category, _| PermissionForge::AuditEvent::CATEGORIES.index(category) || 99 }
189
+ .to_h
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # The audit log page shows dates in the admin's time zone (Time.zone), so
6
+ # a date-only "from" / "to" means that day there: from its first to its
7
+ # last microsecond, with the offset written out for Audit::EventQuery
8
+ # (which reads a bare date as a UTC day — the JSON API keeps that).
9
+ # Anything else (a timestamp, an impossible date, a list) is left for
10
+ # EventQuery to accept or refuse.
11
+ module AuditLogDays
12
+ DATE = /\A(\d{4})-(\d{2})-(\d{2})\z/
13
+
14
+ module_function
15
+
16
+ # @param filters [Hash] the filters as given (string keys)
17
+ # @param zone [ActiveSupport::TimeZone]
18
+ # @return [Hash] the same, date-only from / to as local-day timestamps
19
+ def localize(filters, zone: Time.zone)
20
+ filters.merge(%w[from to].each_with_object({}) do |key, out|
21
+ day = local_day(filters[key], zone)
22
+ out[key] = (key == 'from' ? day.beginning_of_day : day.end_of_day).iso8601(6) if day
23
+ end)
24
+ end
25
+
26
+ def local_day(value, zone)
27
+ match = value.is_a?(String) && DATE.match(value)
28
+ return nil unless match && zone
29
+
30
+ year, month, day = match.captures.map(&:to_i)
31
+ zone.local(year, month, day) if year.positive? && Date.valid_date?(year, month, day)
32
+ end
33
+
34
+ # The zone's name for the page and the export metadata ("Pacific/Auckland").
35
+ def zone_name(zone = Time.zone)
36
+ name = zone.tzinfo&.name || zone.name
37
+ name == 'Etc/UTC' ? 'UTC' : name
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # The people, Permission Sets and Permission Set Groups a page of audit
6
+ # events names, loaded once for the page (one query per kind) so every
7
+ # row can say "Ana gave Bo the Permission Set Sales". A record that is
8
+ # gone is simply not found: the event's own label snapshots stand in.
9
+ #
10
+ # A set or group is named as it was when the event happened
11
+ # (#permission_set_name, #group_name): the label of its own latest event
12
+ # at or before that moment (a create, update or destroy keeps the name it
13
+ # had then), else the name its earliest later rename started from, else
14
+ # its name today (AuditNameHistory; no new column, a bounded read).
15
+ class AuditLookups
16
+ GROUP_KEYS = %w[permission_set_group_id].freeze
17
+
18
+ # @param events [Array<AuditEvent>]
19
+ def initialize(events, configuration: PermissionForge.configuration)
20
+ @events = Array(events)
21
+ @configuration = configuration
22
+ end
23
+
24
+ # @param id [String, Integer, nil] a user id
25
+ # @return [Object, nil] the host user
26
+ def person(id)
27
+ people[id.to_s] if id.present?
28
+ end
29
+
30
+ # @return [PermissionForge::PermissionSet, nil]
31
+ def permission_set(id)
32
+ permission_sets[id.to_i] if id.present?
33
+ end
34
+
35
+ # The Permission Set Group of a membership event (from its data).
36
+ # @return [PermissionForge::PermissionSetGroup, nil]
37
+ def group(event)
38
+ id = self.class.group_id(event)
39
+ groups[id.to_i] if id
40
+ end
41
+
42
+ # The Permission Set's name when `event` happened (nil: unknown).
43
+ # @return [String, nil]
44
+ def permission_set_name(event)
45
+ id = event.permission_set_id
46
+ return if id.blank?
47
+
48
+ set_history.name_as_of(id, event) || permission_set(id)&.name
49
+ end
50
+
51
+ # The name of a membership event's group when the event happened.
52
+ # @return [String, nil]
53
+ def group_name(event)
54
+ id = self.class.group_id(event)
55
+ return unless id
56
+
57
+ group_history.name_as_of(id, event) || groups[id.to_i]&.name
58
+ end
59
+
60
+ def self.group_id(event)
61
+ data = event.data || {}
62
+ snapshot = data['snapshot'].is_a?(Hash) ? data['snapshot'] : {}
63
+ changes = data['changes'].is_a?(Hash) ? data['changes'] : {}
64
+ GROUP_KEYS.each do |key|
65
+ value = snapshot[key] || Array(changes[key]).last
66
+ return value if value.present?
67
+ end
68
+ nil
69
+ end
70
+
71
+ private
72
+
73
+ def people
74
+ @people ||= begin
75
+ ids = @events.flat_map { |e| [e.subject_user_id, (e.actor_id if e.actor_type == 'user')] }.compact_blank.uniq
76
+ ids.empty? ? {} : @configuration.user_model.where(id: ids).index_by { |user| user.id.to_s }
77
+ rescue StandardError
78
+ {}
79
+ end
80
+ end
81
+
82
+ def permission_sets
83
+ @permission_sets ||= find_by_ids(PermissionForge::PermissionSet, @events.map(&:permission_set_id))
84
+ end
85
+
86
+ def groups
87
+ @groups ||= find_by_ids(PermissionForge::PermissionSetGroup, @events.map { |e| self.class.group_id(e) })
88
+ end
89
+
90
+ def find_by_ids(model, ids)
91
+ ids = ids.compact.map(&:to_i).uniq
92
+ ids.empty? ? {} : model.where(id: ids).index_by(&:id)
93
+ end
94
+
95
+ def set_history
96
+ @set_history ||= AuditNameHistory.new(PermissionForge::PermissionSet, @events.map(&:permission_set_id), @events)
97
+ end
98
+
99
+ def group_history
100
+ @group_history ||= AuditNameHistory.new(PermissionForge::PermissionSetGroup,
101
+ @events.map { |e| self.class.group_id(e) }, @events)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # The name a Permission Set or group had when each event of a page
6
+ # happened, from the label snapshots of its own events (a create, update
7
+ # or destroy keeps the name it had then): the latest at or before the
8
+ # event, else what its first recorded rename started from (or that
9
+ # event's own label), else nil (the caller falls back to today's name).
10
+ #
11
+ # Reads a bounded slice, never the whole history: the record's events
12
+ # inside the page's time span and its one latest event before it (two
13
+ # plucks of four columns), plus one full event per record that has no
14
+ # earlier event at all (for its `changes`).
15
+ class AuditNameHistory
16
+ Entry = Struct.new(:record_id, :occurred_at, :id, :record_label)
17
+ COLUMNS = %i[record_id occurred_at id record_label].freeze
18
+
19
+ # @param model [Class] PermissionSet or PermissionSetGroup
20
+ # @param ids [Array] the record ids the page names
21
+ # @param events [Array<AuditEvent>] the page
22
+ def initialize(model, ids, events)
23
+ @ids = ids.compact_blank.map(&:to_s).uniq
24
+ @first, @last = events.filter_map(&:occurred_at).minmax
25
+ @scope = PermissionForge::AuditEvent.where(record_type: model.base_class.name, record_id: @ids)
26
+ @first_events = {}
27
+ end
28
+
29
+ # @return [String, nil]
30
+ def name_as_of(id, event)
31
+ return if @ids.empty? || @first.nil? || event.occurred_at.nil?
32
+
33
+ rows = entries.fetch(id.to_s, [])
34
+ earlier = latest_at_or_before(rows, [event.occurred_at, event.id || 0])
35
+ return earlier.record_label.presence if earlier
36
+
37
+ name_before(first_event(id.to_s, rows))
38
+ end
39
+
40
+ private
41
+
42
+ def latest_at_or_before(rows, moment)
43
+ rows.reverse_each.find { |row| ([row.occurred_at, row.id] <=> moment) <= 0 }
44
+ end
45
+
46
+ # { "id" => [Entry, ...] oldest first }
47
+ def entries
48
+ @entries ||= (span + latest_before_span).uniq(&:id).sort_by { |row| [row.occurred_at, row.id] }.group_by(&:record_id)
49
+ rescue ActiveRecord::ActiveRecordError
50
+ {}
51
+ end
52
+
53
+ def span = pluck(@scope.where(occurred_at: @first..@last))
54
+
55
+ def latest_before_span
56
+ latest = @scope.where(occurred_at: ...@first).group(:record_id).maximum(:occurred_at)
57
+ return [] if latest.empty?
58
+
59
+ pluck(latest.map { |id, at| @scope.where(record_id: id, occurred_at: at) }.reduce(:or))
60
+ end
61
+
62
+ def pluck(relation) = relation.pluck(*COLUMNS).map { |values| Entry.new(*values) }
63
+
64
+ # The record's first recorded event (it has none at or before the
65
+ # event asking): the first one in the span, else the first after it.
66
+ def first_event(id, rows)
67
+ return @first_events[id] if @first_events.key?(id)
68
+
69
+ @first_events[id] =
70
+ if rows.any? then PermissionForge::AuditEvent.find_by(id: rows.first.id)
71
+ else @scope.where(record_id: id).where(occurred_at: @last..).order(:occurred_at, :id).first
72
+ end
73
+ rescue ActiveRecord::ActiveRecordError
74
+ @first_events[id] = nil
75
+ end
76
+
77
+ def name_before(event)
78
+ return unless event
79
+
80
+ renamed = event.data.dig('changes', 'name')
81
+ renamed.is_a?(Array) ? renamed.first.presence : event.record_label.presence
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # Who created and who last edited each permission record. Creation comes
6
+ # from created_by / created_at (HasAuditTrail); the last edit from the
7
+ # record's latest change event when the audit log has one (LastChanges),
8
+ # else from updated_by / updated_at — an event names the real editor,
9
+ # while updated_by keeps the previous one across an edit by nobody, an
10
+ # HTTP Basic login or an API client. The full history is the audit log
11
+ # (Admin::AuditLog).
12
+ class AuditTrail
13
+ TYPES = {
14
+ 'permission_set' => 'PermissionForge::PermissionSet',
15
+ 'resource' => 'PermissionForge::Resource',
16
+ 'field' => 'PermissionForge::ResourceField',
17
+ 'action' => 'PermissionForge::ResourceAction',
18
+ 'rule' => 'PermissionForge::PermissionSetRule',
19
+ 'assignment' => 'PermissionForge::PermissionSetUser',
20
+ 'group' => 'PermissionForge::PermissionSetGroup',
21
+ 'group_membership' => 'PermissionForge::PermissionSetGroupUser'
22
+ }.freeze
23
+
24
+ DAY_FILTERS = [7, 30, 90].freeze
25
+ CSV_HEADER = ['Type', 'Record', 'Where', 'Created by', 'Created at', 'Last edited by', 'Last edited at'].freeze
26
+
27
+ # `last_event` — the record's latest change event, or nil (no events:
28
+ # auditing off, or the record predates the audit log).
29
+ Entry = Struct.new(:type, :record, :last_event, keyword_init: true) do
30
+ def created_by = record.try(:created_by)
31
+ def updated_by = record.try(:updated_by)
32
+ def created_at = record.created_at
33
+ def created_by_id = record.try(:created_by_id)
34
+ def updated_by_id = record.try(:updated_by_id)
35
+
36
+ # When the last edit happened: the event's time when there is one.
37
+ def updated_at = last_event ? last_event.occurred_at : record.updated_at
38
+
39
+ # The last editor's user id as a String, or nil (not a person, or unknown).
40
+ def editor_id
41
+ return updated_by_id&.to_s unless last_event
42
+
43
+ last_event.actor_id if last_event.actor_type == 'user'
44
+ end
45
+ end
46
+
47
+ def self.type_of(record)
48
+ TYPES.key(record.class.name)
49
+ end
50
+
51
+ # People who appear as creator/editor in the given entries.
52
+ def self.editors(entries)
53
+ entries.flat_map { |e| [e.created_by, e.updated_by] }.compact.uniq(&:id)
54
+ end
55
+
56
+ # Records a person created or last edited, grouped by type, most
57
+ # recently edited first. `limit` caps each type (the admin page);
58
+ # nil lists everything (the CSV export).
59
+ # @return [Hash{String => Array<Entry>}]
60
+ def changes_by(user_id, limit: nil)
61
+ TYPES.each_with_object({}) do |(type, class_name), out|
62
+ records = touched_records(class_name.constantize, user_id, limit)
63
+ entries = entries_for(records.map { |record| [type, record] }).select { |entry| made_by?(entry, user_id) }
64
+ out[type] = entries if entries.any?
65
+ end
66
+ end
67
+
68
+ # Whether the last changes_by(limit:) read as many rows as the limit
69
+ # for some type (more may exist), whatever it kept of them.
70
+ def capped?
71
+ @capped == true
72
+ end
73
+
74
+ # Every record belonging to a Permission Set (the set, its resources,
75
+ # fields, actions, rules and assignments), filtered and sorted.
76
+ # Filters (all optional): who:, type:, days:, sort: ('asc' / 'desc', default 'desc').
77
+ # @return [Array<Entry>]
78
+ def for_permission_set(permission_set, **filters)
79
+ entries = apply_filters(permission_set_entries(permission_set), filters)
80
+ sorted = entries.sort_by { |e| e.updated_at || e.created_at || Time.at(0) }
81
+ filters[:sort].to_s == 'asc' ? sorted : sorted.reverse
82
+ end
83
+
84
+ # @param describe [#call] (entry) -> [type_label, record_label, where]
85
+ # @param person [#call] (entry) -> [created by, last edited by]
86
+ def to_csv(entries, describe:, person:)
87
+ Csv.document([CSV_HEADER] + entries.map { |entry| csv_row(entry, describe, person) })
88
+ end
89
+
90
+ private
91
+
92
+ def csv_row(entry, describe, person)
93
+ type_label, label, where = describe.call(entry)
94
+ created_by, edited_by = person.call(entry)
95
+ [type_label, label, where, created_by, entry.created_at&.iso8601, edited_by, entry.updated_at&.iso8601]
96
+ end
97
+
98
+ # The records a person created or was stamped as editing, latest edit
99
+ # first; notes when the limit was reached (capped?).
100
+ def touched_records(model, user_id, limit)
101
+ records = with_people(touched_by(model, user_id).order(updated_at: :desc).limit(limit)).to_a
102
+ @capped = true if limit && records.size >= limit
103
+ records
104
+ end
105
+
106
+ def touched_by(model, user_id)
107
+ model.where(created_by_id: user_id).or(model.where(updated_by_id: user_id))
108
+ end
109
+
110
+ # Created it, or made its last edit (by the event when there is one).
111
+ def made_by?(entry, user_id)
112
+ entry.created_by_id.to_s == user_id.to_s || entry.editor_id == user_id.to_s
113
+ end
114
+
115
+ def apply_filters(entries, filters)
116
+ type, who, days = filters.values_at(:type, :who, :days)
117
+ entries = entries.select { |e| e.type == type } if type.present? && TYPES.key?(type)
118
+ entries = filter_who(entries, who) if who.present?
119
+ entries = filter_days(entries, days.to_i) if DAY_FILTERS.include?(days.to_i)
120
+ entries
121
+ end
122
+
123
+ def with_people(scope)
124
+ scope.includes(:created_by, :updated_by)
125
+ rescue StandardError
126
+ scope
127
+ end
128
+
129
+ def permission_set_entries(permission_set)
130
+ resources = with_people(permission_set.resources).to_a
131
+ records = [['permission_set', permission_set]] + resources.map { |r| ['resource', r] } +
132
+ resource_child_records(resources.map(&:id)) + permission_set_child_records(permission_set)
133
+ entries_for(records)
134
+ end
135
+
136
+ def permission_set_child_records(permission_set)
137
+ typed('rule', permission_set.permission_set_rules.includes(:resource)) +
138
+ typed('assignment', permission_set.permission_set_users.includes(:user))
139
+ end
140
+
141
+ def resource_child_records(resource_ids)
142
+ typed('field', PermissionForge::ResourceField.where(resource_id: resource_ids).includes(:resource)) +
143
+ typed('action', PermissionForge::ResourceAction.where(resource_id: resource_ids).includes(:resource))
144
+ end
145
+
146
+ def typed(type, scope)
147
+ with_people(scope).map { |record| [type, record] }
148
+ end
149
+
150
+ # [[type, record], ...] → entries, each with its latest change event.
151
+ def entries_for(typed_records)
152
+ last = LastChanges.new(typed_records.map(&:last))
153
+ typed_records.map { |type, record| Entry.new(type: type, record: record, last_event: last.last_edit(record)) }
154
+ end
155
+
156
+ def filter_who(entries, who)
157
+ entries.select { |e| e.created_by_id.to_s == who.to_s || e.editor_id == who.to_s }
158
+ end
159
+
160
+ def filter_days(entries, days)
161
+ cutoff = days.days.ago
162
+ entries.select do |e|
163
+ time = e.updated_at || e.created_at
164
+ time && time >= cutoff
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end