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,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # "What can this person do right now, and why?"
6
+ #
7
+ # Mirrors PermissionHashBuilder + FieldPermissionMerger exactly (same set
8
+ # selection order, same ||= / AND / muting semantics, same variants and
9
+ # extra fields) but keeps provenance: which Permission Set granted or muted
10
+ # every flag, field and action. Parity with the engine is covered by tests.
11
+ #
12
+ # Differences from a live request, by design:
13
+ # - Activation conditions are not evaluated (the admin's IP/time says
14
+ # nothing about the person's). Conditional sets count, and are reported in
15
+ # #conditional_sets so the UI can say "only while conditions are true".
16
+ class EffectiveAccess
17
+ CRUD = %i[read_access create_access edit_access delete_access].freeze
18
+ RESOURCE_FLAGS = (CRUD + %i[view_all modify_all]).freeze
19
+
20
+ # A permission outcome with the sets that caused it.
21
+ Outcome = Struct.new(:state, :granted_by, :blocked_by, keyword_init: true) do
22
+ def granted? = state == :granted
23
+ def blocked? = state == :blocked
24
+ end
25
+
26
+ # `implicit` — no set configures the field for this outcome: it is a
27
+ # declared virtual field on the implicit read + edit grant
28
+ # (extra_fields_default_access = :grant).
29
+ FieldOutcome = Struct.new(:name, :level, :mask_strategy, :granted_by, :blocked_by, :blocked_level,
30
+ :capped, :unconfigured, :virtual, :variant_of, :implicit, keyword_init: true) do
31
+ def visible? = level != 'hidden'
32
+ end
33
+
34
+ RuleSource = Struct.new(:rule, :permission_set, keyword_init: true)
35
+
36
+ ResourceOutcome = Struct.new(:name, :flags, :records, :rules, :fields, :actions, keyword_init: true) do
37
+ def op(flag) = flags.fetch(flag)
38
+ def visible_field_count = fields.count(&:visible?)
39
+ end
40
+
41
+ # One resource name with its [permission_set, resource] pairs from the
42
+ # granting and muting sets, and the resulting resource flag outcomes.
43
+ ResourceSources = Struct.new(:name, :grants, :mutes, :flags, keyword_init: true)
44
+
45
+ attr_reader :live_sets, :conditional_sets
46
+
47
+ def self.call(...) = new(...).call
48
+
49
+ def initialize(user:, configuration: PermissionForge.configuration, catalog: ModelCatalog.new)
50
+ @user = user
51
+ @configuration = configuration
52
+ @catalog = catalog
53
+ end
54
+
55
+ # @return [Array<ResourceOutcome>]
56
+ def call
57
+ @live_sets = select_live_sets
58
+ @conditional_sets = @live_sets.select(&:conditional?)
59
+ granting, muting = @live_sets.partition(&:granting?)
60
+
61
+ names = granting.flat_map { |ps| active_resources(ps).map(&:name) }.uniq
62
+ names.map { |name| build_resource(sources_for(name, granting, muting)) }
63
+ end
64
+
65
+ private
66
+
67
+ # Same selection as PermissionCacheAccessor#active_non_expired_permission_sets
68
+ # minus the request-context filter.
69
+ def select_live_sets
70
+ ids = PermissionForge::PermissionSetUser.where(user_id: @user.id).active_assignments.select(:permission_set_id)
71
+ sets = PermissionForge::PermissionSet.active.where(id: ids).order(:created_at, :id)
72
+ .includes(resources: %i[resource_fields resource_actions permission_set_rules]).to_a
73
+ # Same row order as the hash builder (a variant tie goes to the older row).
74
+ sets.each { |permission_set| PermissionForge::Services::PermissionHashBuilder.sort_rows(permission_set) }
75
+ end
76
+
77
+ def active_resources(permission_set)
78
+ permission_set.resources.select(&:is_active)
79
+ end
80
+
81
+ # [permission_set, resource] for every active row of the sets named by
82
+ # one of `names`, in set order then row order.
83
+ def pairs_for(permission_sets, names)
84
+ permission_sets.flat_map do |ps|
85
+ active_resources(ps).select { |r| names.include?(r.name) }.map { |resource| [ps, resource] }
86
+ end
87
+ end
88
+
89
+ # A granting set's row for the resource; a muting set's rows for the
90
+ # resource and for the STI bases of the model it names — the engine's
91
+ # cascade (PermissionHashBuilder#apply_muting_permission_set): a muted
92
+ # base reaches the subclass entry, a muted subclass only itself.
93
+ def sources_for(name, granting, muting)
94
+ grants = pairs_for(granting, [name])
95
+ mutes = pairs_for(muting, PermissionForge::ResourceChain.names_for(name).map(&:to_s))
96
+ flags = RESOURCE_FLAGS.index_with { |flag| outcome_for(grants, mutes) { |r| r.public_send(flag) } }
97
+ ResourceSources.new(name: name, grants: grants, mutes: mutes, flags: flags)
98
+ end
99
+
100
+ def build_resource(sources)
101
+ grants = sources.grants
102
+ ResourceOutcome.new(
103
+ name: sources.name, flags: sources.flags,
104
+ records: records_state(sources.flags),
105
+ rules: rules_for(grants),
106
+ fields: EffectiveFields.new(sources, configuration: @configuration, catalog: @catalog).call,
107
+ actions: action_outcomes(grants, sources.mutes)
108
+ )
109
+ end
110
+
111
+ # Granted by every granting set whose resource matches; muting sets only
112
+ # block what some set granted.
113
+ def outcome_for(grants, mutes, &)
114
+ granted_by = sets_matching(grants, &)
115
+ blocked_by = granted_by.any? ? sets_matching(mutes, &) : []
116
+ Outcome.new(state: outcome_state(granted_by, blocked_by), granted_by: granted_by, blocked_by: blocked_by)
117
+ end
118
+
119
+ # Each set once: a muting set may reach an entry through two rows.
120
+ def sets_matching(pairs)
121
+ pairs.select { |_, resource| yield resource }.map(&:first).uniq
122
+ end
123
+
124
+ def outcome_state(granted_by, blocked_by)
125
+ return :none if granted_by.empty?
126
+
127
+ blocked_by.any? ? :blocked : :granted
128
+ end
129
+
130
+ def records_state(flags)
131
+ return :none unless flags[:read_access].granted?
132
+
133
+ flags[:view_all].granted? ? :all : :rules
134
+ end
135
+
136
+ def rules_for(grants)
137
+ grants.flat_map do |ps, resource|
138
+ resource.permission_set_rules.select(&:is_active).map { |rule| RuleSource.new(rule: rule, permission_set: ps) }
139
+ end
140
+ end
141
+
142
+ # --- Actions ----------------------------------------------------------
143
+
144
+ def action_outcomes(grants, mutes)
145
+ names = grants.flat_map { |_, r| active_actions(r).map(&:name) }.uniq
146
+ names.map { |action| [action, outcome_for(grants, mutes) { |r| permitted?(r, action) }] }
147
+ end
148
+
149
+ def active_actions(resource)
150
+ resource.resource_actions.select(&:is_active)
151
+ end
152
+
153
+ def permitted?(resource, action)
154
+ active_actions(resource).select(&:is_permitted).map(&:name).include?(action)
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # Field half of EffectiveAccess for one resource: mirrors what
6
+ # PermissionHashBuilder does with fields (FieldPermissionMerger's ||=
7
+ # read/edit, AND-merged mask and variants, then extra fields, then muting)
8
+ # while keeping which sets granted or muted each field.
9
+ #
10
+ # Extra fields follow the engine: after every granting set is merged, each
11
+ # declared name that nothing configured gets the implicit entry
12
+ # (ExtraFieldDefaults). A row in any set wins, whatever the set order.
13
+ class EffectiveFields
14
+ FieldOutcome = EffectiveAccess::FieldOutcome
15
+
16
+ # What FiltersResponseAttributes does with a column no set mentions
17
+ # (any other strategy hides it).
18
+ UNCONFIGURED_LEVELS = { 'pass_through' => 'read', 'warn' => 'read', 'mask_all' => 'masked' }.freeze
19
+
20
+ # @param sources [EffectiveAccess::ResourceSources]
21
+ def initialize(sources, configuration:, catalog:)
22
+ @sources = sources
23
+ @configuration = configuration
24
+ @catalog = catalog
25
+ # entry: { read_access:, edit_access:, mask_access:, mask_strategy:, sources: [[set, level]], variant_of: }
26
+ @entries = {}
27
+ end
28
+
29
+ # @return [Array<EffectiveAccess::FieldOutcome>]
30
+ def call
31
+ merge_granting_fields
32
+ apply_muting_fields
33
+
34
+ model = @catalog.find(@sources.name)
35
+ (@entries.keys + Array(model&.managed_field_names)).uniq.map do |field|
36
+ field_outcome(field, model.present? && model.virtual_field?(field))
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def active_fields(resource)
43
+ resource.resource_fields.select(&:is_active)
44
+ end
45
+
46
+ def variants_of(name)
47
+ @configuration.field_variants_for(name)
48
+ end
49
+
50
+ def merge_granting_fields
51
+ @sources.grants.each do |ps, resource|
52
+ active_fields(resource).each { |field| merge_granting_field(ps, field) }
53
+ end
54
+ implicit_names.each { |name| @entries[name] ||= implicit_entry }
55
+ end
56
+
57
+ def merge_granting_field(permission_set, field)
58
+ name = field.name
59
+ variants_of(name).each { |variant| merge_into(entry_for(variant, name), field, permission_set) }
60
+ merge_into(entry_for(name, nil), field, permission_set)
61
+ end
62
+
63
+ def entry_for(key, variant_of)
64
+ @entries[key] ||= { sources: [], variant_of: variant_of }
65
+ end
66
+
67
+ # The engine's own merge (FieldPermissionMerger.merge_row), plus provenance.
68
+ def merge_into(entry, field, permission_set)
69
+ Services::FieldPermissionMerger.merge_row(entry, field)
70
+ entry[:sources] << [permission_set, FieldLevel.level_of(field, muting: false), field.mask_strategy]
71
+ end
72
+
73
+ # Declared names that get the implicit read + edit entry; none under
74
+ # extra_fields_default_access = :unconfigured (they are then listed as
75
+ # unconfigured virtual fields instead). Read for any constant, like the
76
+ # engine does, not only for the models the catalog lists.
77
+ def implicit_names
78
+ PermissionForge::VirtualFields.new(@sources.name.to_s.safe_constantize, @configuration).implicit
79
+ end
80
+
81
+ # The engine's entry for the resource flags as the granting sets merged
82
+ # them (what some granting set grants, blocked later or not).
83
+ def implicit_entry
84
+ granted = @sources.flags.transform_values { |outcome| !outcome.granted_by.empty? }
85
+ Services::ExtraFieldDefaults.implicit_entry(granted).merge(sources: [], extra: true)
86
+ end
87
+
88
+ def apply_muting_fields
89
+ @sources.mutes.each do |ps, resource|
90
+ active_fields(resource).each { |field| apply_muting_field(ps, field) }
91
+ hide_every_field(ps) if resource.read_access
92
+ end
93
+ end
94
+
95
+ # A muting set that revokes the resource's read hides every field, as
96
+ # the engine does (PermissionHashBuilder#mute_fields_of_resource). One
97
+ # that revokes its edit caps every field to read: level_attrs does that
98
+ # from the resource's edit outcome.
99
+ def hide_every_field(permission_set)
100
+ @entries.each_value { |entry| hide_entry(entry, permission_set) }
101
+ end
102
+
103
+ def hide_entry(entry, permission_set)
104
+ entry[:read_access] = false
105
+ entry[:edit_access] = false
106
+ (entry[:mutes] ||= []) << [permission_set, 'hide']
107
+ end
108
+
109
+ def apply_muting_field(permission_set, field)
110
+ name = field.name
111
+ ([name] + variants_of(name)).each do |key|
112
+ entry = @entries[key]
113
+ mute_field(entry, field, permission_set) if entry
114
+ end
115
+ end
116
+
117
+ # The engine's own muting of a row (PermissionHashBuilder
118
+ # #mute_single_field: a row that mutes read mutes edit too), plus
119
+ # provenance.
120
+ def mute_field(entry, field, permission_set)
121
+ level = FieldLevel.level_of(field, muting: true)
122
+ return if level == 'leave'
123
+
124
+ entry[:read_access] = false if field.read_access
125
+ entry[:edit_access] = false if field.read_access || field.edit_access
126
+ entry[:mask_access] = true if field.mask_access
127
+ (entry[:mutes] ||= []) << [permission_set, level]
128
+ end
129
+
130
+ def field_outcome(name, virtual)
131
+ entry = @entries[name]
132
+ return unconfigured_outcome(name, virtual) if entry.nil?
133
+
134
+ implicit = entry[:extra].present?
135
+ FieldOutcome.new(
136
+ name: name, mask_strategy: entry[:mask_strategy], granted_by: granting_sources(entry),
137
+ unconfigured: false, virtual: virtual || implicit, implicit: implicit, variant_of: entry[:variant_of],
138
+ **level_attrs(entry), **muting_attrs(entry)
139
+ )
140
+ end
141
+
142
+ # Editable fields are capped to Read when the resource's Edit is not granted.
143
+ def level_attrs(entry)
144
+ level = FieldLevel.effective_level(entry)
145
+ capped = level == 'edit' && !@sources.flags[:edit_access].granted?
146
+ { level: capped ? 'read' : level, capped: capped }
147
+ end
148
+
149
+ def muting_attrs(entry)
150
+ mutes = entry[:mutes] || []
151
+ { blocked_by: mutes.map(&:first).uniq, blocked_level: mutes.last&.last }
152
+ end
153
+
154
+ # Sets that make the field readable, most open first. A set that lists
155
+ # the field as Hidden grants nothing and, like the engine, has no say in
156
+ # the mask. An implicit entry exists only when no set configured the
157
+ # field, so no set is credited for it: the level comes from the
158
+ # virtual-field default.
159
+ def granting_sources(entry)
160
+ return [] if entry[:extra]
161
+
162
+ entry[:sources].select { |_, lvl, _| FieldLevel.rank(lvl) >= 1 }
163
+ .sort_by { |_, lvl, _| -FieldLevel.rank(lvl) }.map(&:first).uniq
164
+ end
165
+
166
+ def unconfigured_outcome(name, virtual)
167
+ FieldOutcome.new(name: name, level: unconfigured_level, mask_strategy: 'full', granted_by: [],
168
+ blocked_by: [], capped: false, unconfigured: true, virtual: virtual, implicit: false)
169
+ end
170
+
171
+ def unconfigured_level
172
+ UNCONFIGURED_LEVELS.fetch(@configuration.unconfigured_field_strategy.to_s, 'hidden')
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # Assignment end-date presets. Calendar-day maths (date + n days), never
6
+ # seconds, so DST changes don't shift labels. An assignment ending on a
7
+ # date stays valid through the end of that day.
8
+ module ExpiryPresets
9
+ DAYS = { '1d' => 1, '1w' => 7, '30d' => 30 }.freeze
10
+ KEYS = %w[1d 1w 30d eom custom never].freeze
11
+ CHANGE_KEYS = %w[1w 30d eom never].freeze
12
+
13
+ class InvalidDate < StandardError; end
14
+
15
+ module_function
16
+
17
+ # @return [Date, nil] nil for "never"/"custom" without a date
18
+ def date_for(key, today: Time.zone.today, custom: nil)
19
+ case key.to_s
20
+ when *DAYS.keys then today + DAYS.fetch(key.to_s)
21
+ when 'eom' then today.end_of_month
22
+ when 'custom' then parse_custom(custom, today)
23
+ end
24
+ end
25
+
26
+ # The expires_at timestamp for a preset (end of the chosen day), or nil.
27
+ def expires_at_for(key, today: Time.zone.today, custom: nil)
28
+ date_for(key, today: today, custom: custom)&.in_time_zone&.end_of_day
29
+ end
30
+
31
+ def parse_custom(value, today)
32
+ raise InvalidDate, :missing if value.blank?
33
+
34
+ date = Date.iso8601(value.to_s)
35
+ raise InvalidDate, :not_future unless date > today
36
+
37
+ date
38
+ rescue Date::Error
39
+ raise InvalidDate, :missing
40
+ end
41
+
42
+ # Days until the assignment ends (0 = today, negative = ended), nil when
43
+ # it never ends.
44
+ def days_left(expires_at, today: Time.zone.today)
45
+ return nil if expires_at.nil?
46
+
47
+ (expires_at.to_date - today).to_i
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ module Exports
6
+ # A file the admin hands over (CSV or JSON): a metadata block — what was
7
+ # exported, when, by whom, with which filters, how many rows — then
8
+ # every row. Rows are produced lazily, one batch at a time, so a large
9
+ # export is written as it is read and nothing is cut off.
10
+ #
11
+ # CSV layout:
12
+ # export,<kind>
13
+ # exported_at,<ISO 8601>
14
+ # exported_by,<label> (and exported_by_type / exported_by_id)
15
+ # filters,<JSON>
16
+ # row_count,<n>
17
+ # <empty line>
18
+ # <column names>
19
+ # <rows>
20
+ #
21
+ # JSON layout: { "meta": {...}, "columns": [...], "data": [ {...}, ... ] }
22
+ class Document
23
+ attr_reader :columns, :meta
24
+
25
+ # @param columns [Array<String>] the row keys, in order
26
+ # @param meta [Hash{String => Object}] export, exported_at, exported_by, filters, row_count …
27
+ # @param rows [#call] callable taking a block; yields each row as a Hash{String => Object}
28
+ def initialize(columns:, meta:, rows:)
29
+ @columns = columns
30
+ @meta = meta
31
+ @rows = rows
32
+ end
33
+
34
+ # @return [Enumerator<String>] the CSV, chunk by chunk
35
+ def csv
36
+ Enumerator.new do |out|
37
+ out << csv_head
38
+ @rows.call { |row| out << csv_row(row) }
39
+ end
40
+ end
41
+
42
+ # The metadata block, an empty line, the column names.
43
+ def csv_head
44
+ Csv.document(meta.map { |key, value| [key, flat(value)] }) + Csv::LINE_END + Csv.line(columns)
45
+ end
46
+
47
+ # @return [Enumerator<String>] the JSON, chunk by chunk
48
+ def json
49
+ Enumerator.new do |out|
50
+ out << %({"meta":#{ActiveSupport::JSON.encode(meta)},"columns":#{ActiveSupport::JSON.encode(columns)},"data":[)
51
+ first = true
52
+ @rows.call do |row|
53
+ out << (first ? '' : ',') << ActiveSupport::JSON.encode(row.slice(*columns))
54
+ first = false
55
+ end
56
+ out << "]}\n"
57
+ end
58
+ end
59
+
60
+ # @param format [Symbol, String] :csv or :json
61
+ # @return [Enumerator<String>]
62
+ def chunks(format)
63
+ format.to_s == 'json' ? json : csv
64
+ end
65
+
66
+ # The whole file as one String (tests, small exports).
67
+ def render(format)
68
+ chunks(format).to_a.join
69
+ end
70
+
71
+ private
72
+
73
+ # One row as a CSV line, in column order.
74
+ def csv_row(row)
75
+ Csv.line(columns.map { |column| flat(row[column]) })
76
+ end
77
+
78
+ # A cell: Hashes and Arrays as JSON, times in ISO 8601, the rest as text.
79
+ def flat(value)
80
+ case value
81
+ when Hash, Array then ActiveSupport::JSON.encode(value)
82
+ when Time, DateTime, ActiveSupport::TimeWithZone then value.utc.iso8601(6)
83
+ else value
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ module Exports
6
+ # The audit log export: every event the filter matches (views of the
7
+ # log included, whatever the page hid), oldest first,
8
+ # with every column (the JSON API's, from AuditEventSerializer) and
9
+ # `data` as JSON. The rows are fixed when the export starts — the
10
+ # events up to the newest one then — so the row count in the metadata
11
+ # is the number of rows that follow, even while new events (this
12
+ # export's own audit.exported, for one) are being written.
13
+ class Events
14
+ BATCH_SIZE = 1000
15
+ KIND = 'audit_events'
16
+
17
+ def self.columns
18
+ PermissionForge::Api::Serializers::AuditEventSerializer.attribute_names.map(&:to_s)
19
+ end
20
+
21
+ # @param relation [ActiveRecord::Relation] the filtered events, in any order
22
+ # @param filters [Hash] the filters, as written in the metadata
23
+ # @param exported_by [Audit::Actor]
24
+ # @param batch_size [Integer] events read per query
25
+ # @param time_zone [String] the zone date-only filters were read in
26
+ # @return [Document]
27
+ def self.call(relation, filters:, exported_by: PermissionForge::Audit::Actor.current, now: Time.current, # rubocop:disable Metrics/ParameterLists
28
+ batch_size: BATCH_SIZE, time_zone: AuditLogDays.zone_name)
29
+ events = snapshot(relation.unscope(:order))
30
+ # An export is evidence: views of the log are always in it, and it says so.
31
+ meta = Exports.meta(KIND, exported_by: exported_by, filters: filters.to_h.merge('include_views' => true),
32
+ row_count: events.count, now: now, time_zone: time_zone)
33
+ Document.new(columns: columns, meta: meta, rows: lambda { |&row|
34
+ events.find_each(batch_size: batch_size) { |event| row.call(serialize(event)) }
35
+ })
36
+ end
37
+
38
+ def self.snapshot(events)
39
+ newest = events.maximum(:id)
40
+ newest ? events.where(id: ..newest) : events.none
41
+ end
42
+
43
+ def self.serialize(event)
44
+ PermissionForge::Api::Serializers::AuditEventSerializer.new(event).as_json.transform_keys(&:to_s)
45
+ end
46
+
47
+ private_class_method :snapshot, :serialize
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ module Exports
6
+ # "Who holds what": every assignment as it stands — the person, the
7
+ # Permission Set, when and by whom it was granted, when it ends, when
8
+ # and by whom it was last reviewed, and the reason of its last change.
9
+ # Granted by is the actor of the assignment.granted event when the
10
+ # audit log has one, else the created_by column.
11
+ #
12
+ # The rows are read while the file is written (batches of BATCH_SIZE,
13
+ # the way the events export does), never all at once. `row_count` in the
14
+ # metadata is a COUNT taken when the export starts, on the assignments up
15
+ # to the newest id then. Assignments are changed and removed, not only
16
+ # added: one removed while the file is written is missing from it
17
+ # (`row_count` can then exceed the rows), and each row shows its
18
+ # assignment as it was when its batch was read. The person lookups run
19
+ # per batch, in the tenant and with the record rules suspended as in the
20
+ # action (AuditEventsController#pf_stream).
21
+ class Holdings
22
+ BATCH_SIZE = 500
23
+ KIND = 'holdings'
24
+ COLUMNS = %w[assignment_id assignment_uuid person_id person permission_set_id permission_set_uuid permission_set
25
+ muting set_active granted_at granted_by_type granted_by_id granted_by expires_at expired
26
+ reviewed_at reviewed_by_id reviewed_by reason].freeze
27
+
28
+ # @param exported_by [Audit::Actor]
29
+ # @param batch_size [Integer] assignments read per query
30
+ # @return [Document]
31
+ def self.call(exported_by: PermissionForge::Audit::Actor.current, now: Time.current, batch_size: BATCH_SIZE)
32
+ new(now, batch_size).document(exported_by)
33
+ end
34
+
35
+ def initialize(now, batch_size = BATCH_SIZE)
36
+ @now = now
37
+ @batch_size = batch_size
38
+ end
39
+
40
+ def document(exported_by)
41
+ assignments = snapshot
42
+ meta = Exports.meta(KIND, exported_by: exported_by, filters: {}, row_count: assignments.count, now: @now)
43
+ Document.new(columns: COLUMNS, meta: meta, rows: ->(&row) { each_row(assignments, &row) })
44
+ end
45
+
46
+ private
47
+
48
+ # The assignments as they stand now: up to the newest id.
49
+ def snapshot
50
+ assignments = PermissionForge::PermissionSetUser.includes(:permission_set, :user)
51
+ newest = assignments.maximum(:id)
52
+ newest ? assignments.where(id: ..newest) : assignments.none
53
+ end
54
+
55
+ # Outside the query cache: it would keep every batch's results for the request.
56
+ def each_row(assignments, &)
57
+ ActiveRecord::Base.uncached { each_batch(assignments, &) }
58
+ end
59
+
60
+ def each_batch(assignments)
61
+ assignments.find_in_batches(batch_size: @batch_size) do |batch|
62
+ granted = LastChanges.new(batch, events: %w[assignment.granted])
63
+ people = people_by_id(batch)
64
+ batch.each { |assignment| yield row(assignment, granted[assignment], people) }
65
+ end
66
+ end
67
+
68
+ def row(assignment, grant_event, people)
69
+ set = assignment.permission_set
70
+ person(assignment).merge(permission_set_columns(set)).merge(
71
+ 'granted_at' => assignment.created_at, **granted_by(assignment, grant_event, people),
72
+ 'expires_at' => assignment.expires_at, 'expired' => assignment.expired?,
73
+ **reviewed(assignment, people), 'reason' => assignment.try(:reason)
74
+ )
75
+ end
76
+
77
+ def person(assignment)
78
+ { 'assignment_id' => assignment.id, 'assignment_uuid' => assignment.try(:uuid), 'person_id' => assignment.user_id,
79
+ 'person' => label(assignment.user) }
80
+ end
81
+
82
+ def permission_set_columns(set)
83
+ { 'permission_set_id' => set&.id, 'permission_set_uuid' => set&.uuid, 'permission_set' => set&.name,
84
+ 'muting' => set&.is_muting, 'set_active' => set&.is_active }
85
+ end
86
+
87
+ def granted_by(assignment, event, people)
88
+ if event
89
+ return { 'granted_by_type' => event.actor_type, 'granted_by_id' => event.actor_id,
90
+ 'granted_by' => event.actor_label }
91
+ end
92
+
93
+ id = assignment.try(:created_by_id)
94
+ { 'granted_by_type' => (id ? 'user' : nil), 'granted_by_id' => id, 'granted_by' => label(people[id.to_s]) }
95
+ end
96
+
97
+ def reviewed(assignment, people)
98
+ id = assignment.try(:reviewed_by_id)
99
+ { 'reviewed_at' => assignment.try(:reviewed_at), 'reviewed_by_id' => id, 'reviewed_by' => label(people[id.to_s]) }
100
+ end
101
+
102
+ # The creators and reviewers of a batch, by id (String).
103
+ def people_by_id(batch)
104
+ ids = batch.flat_map { |assignment| [assignment.try(:created_by_id), assignment.try(:reviewed_by_id)] }.compact.uniq
105
+ return {} if ids.empty?
106
+
107
+ PermissionForge.configuration.user_model.where(id: ids).index_by { |user| user.id.to_s }
108
+ rescue StandardError
109
+ {}
110
+ end
111
+
112
+ def label(user)
113
+ PersonLabel.new(user).name if user
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # Files the admin hands over: the audit log (Exports::Events) and who
6
+ # holds what (Exports::Holdings), written by Exports::Document.
7
+ module Exports
8
+ module_function
9
+
10
+ # The metadata block every export starts with.
11
+ #
12
+ # @param kind [String] what was exported
13
+ # @param exported_by [Audit::Actor]
14
+ # @param filters [Hash]
15
+ # @param row_count [Integer]
16
+ # @param time_zone [String] the zone date-only filters were read in
17
+ # @return [Hash{String => Object}]
18
+ def meta(kind, exported_by:, filters:, row_count:, now: Time.current, time_zone: AuditLogDays.zone_name) # rubocop:disable Metrics/ParameterLists
19
+ {
20
+ 'export' => kind,
21
+ 'exported_at' => now.utc.iso8601,
22
+ 'time_zone' => time_zone,
23
+ 'exported_by' => exported_by.label || exported_by.type,
24
+ 'exported_by_type' => exported_by.type,
25
+ 'exported_by_id' => exported_by.id,
26
+ 'filters' => filters.to_h.transform_keys(&:to_s),
27
+ 'row_count' => row_count
28
+ }
29
+ end
30
+
31
+ # A file name: permission-forge-<kind>-<UTC timestamp>.<format>
32
+ def filename(kind, format, now: Time.current)
33
+ "permission-forge-#{kind.to_s.tr('_', '-')}-#{now.utc.strftime('%Y%m%dT%H%M%SZ')}.#{format}"
34
+ end
35
+ end
36
+ end
37
+ end