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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # Every Permission Set containing a resource, with one field's row there
6
+ # (granting sets first, then by name) and the set's people count. Backs
7
+ # the field drawer's "In other sets" list and "Who can see …?".
8
+ class FieldAcrossSets
9
+ # @return [Array<Hash>] { resource:, set:, field: (ResourceField or nil), people:, in_effect: }
10
+ # in_effect is whether the row (or, without a row, the resource) counts
11
+ # right now, as in ActionAcrossSets#rows.
12
+ def self.call(resource_name, key) = new(resource_name, key).call
13
+
14
+ def initialize(resource_name, key)
15
+ @resource_name = resource_name
16
+ @key = key
17
+ end
18
+
19
+ def call
20
+ resources.map do |resource|
21
+ field = field_in(resource)
22
+ { resource: resource, set: resource.permission_set, field: field, people: people_count(resource),
23
+ in_effect: resource.in_effect? && (field.nil? || field.is_active) }
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def resources
30
+ PermissionForge::Resource.where(name: @resource_name).includes(:permission_set, :resource_fields)
31
+ .sort_by { |resource| sort_key(resource.permission_set) }
32
+ end
33
+
34
+ def sort_key(permission_set)
35
+ [permission_set.is_muting ? 1 : 0, permission_set.name]
36
+ end
37
+
38
+ def field_in(resource)
39
+ resource.resource_fields.find { |field| field.name == @key }
40
+ end
41
+
42
+ def people_count(resource)
43
+ people_counts.fetch(resource.permission_set_id, 0)
44
+ end
45
+
46
+ def people_counts
47
+ @people_counts ||= PermissionForge::PermissionSetUser.active_assignments.group(:permission_set_id).count
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # What the field drawer (resource_fields/show) shows for one column of a
6
+ # Resource, decided here so the template only lays it out: the level and
7
+ # mask strategy in force, which notes apply, whether the recommendation
8
+ # can be applied, and which person is being explained.
9
+ #
10
+ # Words stay in the template (I18n); this answers which note, not what it
11
+ # says.
12
+ #
13
+ # @example
14
+ # detail = FieldDetail.new(resource: resource, row: rows.row('email'), rows: rows)
15
+ # detail.locked? # => false
16
+ # detail.unconfigured_note # => :not_configured
17
+ # detail.recommendation # => :apply
18
+ class FieldDetail
19
+ DEFAULT_STRATEGY = 'full'
20
+ CONTEXT_KEYS = %i[rules across people_count explain_people explain explain_user_id].freeze
21
+
22
+ attr_reader :resource, :row, :rows, :rules, :across, :people_count, :explain, :explain_people
23
+
24
+ # rows: the FieldRows the row came from (it knows the model).
25
+ # explain: { user:, field: } for the person picked in "Explain for a person", else nil.
26
+ def initialize(resource:, row:, rows:, configuration: PermissionForge.configuration, **context)
27
+ @resource = resource
28
+ @row = row
29
+ @rows = rows
30
+ @configuration = configuration
31
+ unknown = context.keys - CONTEXT_KEYS
32
+ raise ArgumentError, "unknown context: #{unknown.join(', ')}" if unknown.any?
33
+
34
+ @rules = context.fetch(:rules, [])
35
+ @across = context[:across]
36
+ @people_count = context[:people_count]
37
+ @explain_people = context.fetch(:explain_people, [])
38
+ @explain = context[:explain]
39
+ @explain_user_id = context[:explain_user_id].to_s
40
+ end
41
+
42
+ def permission_set = resource.permission_set
43
+ def key = row.name
44
+ def field = row.field
45
+ def label = row.label
46
+ def muting? = permission_set.is_muting
47
+ def level = row.level
48
+
49
+ # Locked when the field, its Resource or its Permission Set is (is_editable: false).
50
+ def locked? = !EditableLock.locked_record(field, resource).nil?
51
+
52
+ # The mask strategy the select starts on: the saved one, else the
53
+ # recommended one for a sensitive column, else 'full'.
54
+ def strategy
55
+ field&.mask_strategy || row.match&.strategy || DEFAULT_STRATEGY
56
+ end
57
+
58
+ # Other columns that follow this one (`_id` → `_uuid`, `_cents` → base name).
59
+ def variants
60
+ @variants ||= @configuration.field_variants_for(key)
61
+ end
62
+
63
+ # The edit level needs edit access on the Resource, except in a muting set.
64
+ def edit_disabled? = !muting? && !resource.edit_access
65
+
66
+ def in_force? = row.in_force?
67
+
68
+ # The note under the level control while no setting is in force, as the
69
+ # suffix of a locale key under `resource_fields.show` — or nil.
70
+ def unconfigured_note
71
+ return nil if row.in_force?
72
+
73
+ implicit = row.implicit_grant
74
+ if row.configured?
75
+ implicit ? :turned_off_implicit : :turned_off
76
+ else
77
+ implicit ? :not_configured_implicit : :not_configured
78
+ end
79
+ end
80
+
81
+ # Setting the note links to.
82
+ def unconfigured_setting
83
+ row.implicit_grant ? 'extra_fields_default_access' : 'unconfigured_field_strategy'
84
+ end
85
+
86
+ # What happens to a field nobody set up (a key under `ui.unconfigured`).
87
+ def unconfigured_behaviour = @configuration.unconfigured_field_strategy
88
+
89
+ def sensitive? = row.sensitive?
90
+
91
+ # The privacy recommendation banner: only for a sensitive field in a granting set.
92
+ def recommendation_banner? = sensitive? && !muting?
93
+
94
+ # :applied (already masked with the recommended strategy), :apply (offer
95
+ # the button) or nil (locked: nothing to offer).
96
+ def recommendation
97
+ return :applied if level == 'masked' && strategy == row.match.strategy
98
+ return nil if locked?
99
+
100
+ :apply
101
+ end
102
+
103
+ def virtual? = row.virtual
104
+ def declared? = row.declared
105
+ def extra_fields_method = @configuration.extra_fields_method
106
+ def model_masks_virtual? = rows.model&.masked_on_model?(key) || false
107
+
108
+ # The person the explain select starts on.
109
+ def explain_selected?(user) = @explain_user_id == user.id.to_s
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # Maps the admin UI's single "access level" choice to ResourceField flags
6
+ # and back. Granting sets offer Hidden / Masked / Read-only / Editable;
7
+ # muting sets offer Unchanged / Force mask / Hide, where the same flags mean
8
+ # "remove" (see PermissionHashBuilder#mute_single_field).
9
+ module FieldLevel
10
+ GRANTING = {
11
+ 'hidden' => { read_access: false, edit_access: false, mask_access: false },
12
+ 'masked' => { read_access: true, edit_access: false, mask_access: true },
13
+ 'read' => { read_access: true, edit_access: false, mask_access: false },
14
+ 'edit' => { read_access: true, edit_access: true, mask_access: false }
15
+ }.freeze
16
+
17
+ MUTING = {
18
+ 'leave' => { read_access: false, edit_access: false, mask_access: false },
19
+ 'mask' => { read_access: false, edit_access: false, mask_access: true },
20
+ 'hide' => { read_access: true, edit_access: true, mask_access: false }
21
+ }.freeze
22
+
23
+ # Ordered from most closed to most open (granting) — used to rank levels.
24
+ GRANTING_ORDER = %w[hidden masked read edit].freeze
25
+
26
+ module_function
27
+
28
+ def levels_for(muting:)
29
+ muting ? MUTING.keys : GRANTING.keys
30
+ end
31
+
32
+ def flags_for(level, muting:)
33
+ (muting ? MUTING : GRANTING).fetch(level.to_s) { raise ArgumentError, "Unknown field level: #{level.inspect}" }
34
+ end
35
+
36
+ def valid?(level, muting:)
37
+ (muting ? MUTING : GRANTING).key?(level.to_s)
38
+ end
39
+
40
+ # Derives the level from a field's flags. Flag combinations the UI never
41
+ # writes (e.g. edit + mask) collapse to the closest level.
42
+ def level_of(field, muting:)
43
+ read = field.read_access
44
+ edit = field.edit_access
45
+ mask = field.mask_access
46
+ return muting_level(read, edit, mask) if muting
47
+
48
+ return 'hidden' unless read
49
+ return 'masked' if mask
50
+
51
+ edit ? 'edit' : 'read'
52
+ end
53
+
54
+ def muting_level(read, edit, mask)
55
+ return 'hide' if read || edit
56
+
57
+ mask ? 'mask' : 'leave'
58
+ end
59
+
60
+ # Level an engine field-permission entry resolves to
61
+ # ({ read_access:, edit_access:, mask_access: } from the permissions hash).
62
+ def effective_level(entry)
63
+ return 'hidden' if entry.nil? || !entry[:read_access]
64
+ return 'masked' if entry[:mask_access]
65
+
66
+ entry[:edit_access] ? 'edit' : 'read'
67
+ end
68
+
69
+ def rank(level)
70
+ GRANTING_ORDER.index(level.to_s) || -1
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # One row per field on a Resource page: configured ResourceFields, model
6
+ # columns nobody configured yet, and virtual fields — with the flags the
7
+ # UI needs (sensitive, virtual, column missing, level).
8
+ class FieldRows
9
+ # `declared` — a virtual field the model lists in extra_fields_method
10
+ # (a virtual row that is not declared was discovered). `implicit_grant` —
11
+ # no setting in force here (none, or one that is turned off), and
12
+ # ExtraFieldDefaults makes the field readable and editable right now
13
+ # (extra_fields_default_access = :grant).
14
+ Row = Struct.new(:name, :label, :field, :match, :virtual, :missing, :level, :declared, :implicit_grant,
15
+ keyword_init: true) do
16
+ def configured? = !field.nil?
17
+ def sensitive? = !match.nil?
18
+ def locked? = field && field.is_editable == false
19
+ def active? = field.nil? || field.is_active
20
+
21
+ # A setting the engine applies: it exists and is turned on. A row that
22
+ # is configured and not in force is turned off (is_active false); the
23
+ # engine skips it, so the field is treated like one with no setting.
24
+ def in_force? = configured? && active?
25
+ end
26
+
27
+ GRANTING_FILTERS = %w[all hidden masked read edit sensitive unconfigured].freeze
28
+ MUTING_FILTERS = %w[all leave mask hide sensitive unconfigured].freeze
29
+
30
+ attr_reader :resource, :model
31
+
32
+ def initialize(resource, catalog: ModelCatalog.new, sensitive: SensitiveColumns.new)
33
+ @resource = resource
34
+ @muting = resource.permission_set.is_muting
35
+ @model = catalog.find(resource.name)
36
+ @sensitive = sensitive
37
+ end
38
+
39
+ def filters
40
+ @muting ? MUTING_FILTERS : GRANTING_FILTERS
41
+ end
42
+
43
+ def rows
44
+ @rows ||= (configured_rows + unconfigured_rows).sort_by { |r| [r.configured? ? 0 : 1, r.name] }
45
+ end
46
+
47
+ def filtered(filter: 'all', query: nil)
48
+ needle = query.to_s.strip.downcase
49
+ rows.select { |r| match_filter?(r, filter) && (needle.empty? || "#{r.name} #{r.label}".downcase.include?(needle)) }
50
+ end
51
+
52
+ def counts
53
+ filters.index_with { |filter| rows.count { |r| match_filter?(r, filter) } }
54
+ end
55
+
56
+ def row(name)
57
+ rows.find { |r| r.name == name.to_s }
58
+ end
59
+
60
+ # --- sync with model -----------------------------------------------------
61
+
62
+ # Managed columns and virtual fields nobody configured in this resource.
63
+ def new_columns
64
+ return [] unless @model
65
+
66
+ @model.managed_field_names - configured_names
67
+ end
68
+
69
+ def missing_fields
70
+ rows.select(&:missing)
71
+ end
72
+
73
+ def ignored_columns
74
+ Array(@model&.ignored_column_names)
75
+ end
76
+
77
+ def sync_needed?
78
+ new_columns.any? || missing_fields.any?
79
+ end
80
+
81
+ private
82
+
83
+ # Every row counts, switched off or not: a field with a row cannot get a
84
+ # second one.
85
+ def configured_names
86
+ @configured_names ||= @resource.resource_fields.map(&:name)
87
+ end
88
+
89
+ def configured_rows
90
+ @resource.resource_fields.map { |field| configured_row(field) }
91
+ end
92
+
93
+ def configured_row(field)
94
+ name = field.name
95
+ virtual = virtual?(name)
96
+ Row.new(name: name, label: field.label.presence || human(name), field: field, match: @sensitive.match(name),
97
+ virtual: virtual, missing: @model.present? && !virtual && @model.column_names.exclude?(name),
98
+ level: FieldLevel.level_of(field, muting: @muting), declared: declared?(name),
99
+ implicit_grant: !field.is_active && implicit_grant?(name))
100
+ end
101
+
102
+ def unconfigured_rows
103
+ new_columns.map do |name|
104
+ Row.new(name: name, label: human(name), field: nil, match: @sensitive.match(name), virtual: virtual?(name),
105
+ missing: false, level: nil, declared: declared?(name), implicit_grant: implicit_grant?(name))
106
+ end
107
+ end
108
+
109
+ def implicit_grant?(name)
110
+ @model.present? && @model.implicit_grant?(name)
111
+ end
112
+
113
+ # A field configured while discovery was on stays virtual (not "column
114
+ # missing") after discovery is turned off.
115
+ def virtual?(name)
116
+ @model.present? && @model.virtual_field?(name)
117
+ end
118
+
119
+ def declared?(name)
120
+ @model.present? && @model.declared_virtual_field_names.include?(name)
121
+ end
122
+
123
+ def human(name)
124
+ @model ? @model.klass.human_attribute_name(name) : name.to_s.humanize
125
+ end
126
+
127
+ def match_filter?(row, filter)
128
+ case filter
129
+ when 'all' then true
130
+ when 'sensitive' then row.sensitive?
131
+ when 'unconfigured' then !row.configured?
132
+ else row.level == filter
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/numeric/time' # UNSEEN_AFTER, read as this file loads
4
+
5
+ module PermissionForge
6
+ module Admin
7
+ # The latest recorded change event of each of a list of records: who
8
+ # really made the last edit, and when. `updated_by` alone cannot say —
9
+ # an edit by nobody, by an HTTP Basic login or by an API client keeps the
10
+ # previous editor — so wherever the admin shows "last edited by", an
11
+ # event wins over the columns (see AuditTrail::Entry and the footer).
12
+ #
13
+ # Two queries per record type and slice: the newest event id per record
14
+ # (the ids grow with every insert), then those events.
15
+ class LastChanges
16
+ SLICE = 500
17
+
18
+ # @param records [Array<ActiveRecord::Base>] permission records
19
+ # @param events [Array<String>, nil] only these change events (e.g.
20
+ # %w[assignment.granted] — who granted it); nil: any change event
21
+ def initialize(records, events: nil)
22
+ @records = Array(records).compact
23
+ @events = events
24
+ end
25
+
26
+ # Edits this much later than the last event are edits the log did not see.
27
+ UNSEEN_AFTER = 1.second
28
+
29
+ # @return [AuditEvent, nil] the latest (matching) change event of the record
30
+ def [](record)
31
+ index[key(record.class.base_class.name, record.id)]
32
+ end
33
+
34
+ # The latest change event when it is the record's last edit; nil when
35
+ # the record was edited after it (updated_at more than UNSEEN_AFTER
36
+ # later — an edit made while auditing was off): the columns are newer.
37
+ #
38
+ # @return [AuditEvent, nil]
39
+ def last_edit(record)
40
+ event = self[record]
41
+ return nil unless event
42
+
43
+ updated = record.try(:updated_at)
44
+ updated && updated > event.occurred_at + UNSEEN_AFTER ? nil : event
45
+ end
46
+
47
+ # Whether the record is one of those this lookup was built for.
48
+ def covers?(record)
49
+ @keys ||= @records.to_set { |candidate| key(candidate.class.base_class.name, candidate.id) }
50
+ @keys.include?(key(record.class.base_class.name, record.id))
51
+ end
52
+
53
+ private
54
+
55
+ def key(type, id)
56
+ "#{type}##{id}"
57
+ end
58
+
59
+ def index
60
+ @index ||= available? ? load_index : {}
61
+ end
62
+
63
+ def available?
64
+ @records.any? && PermissionForge::Audit.enabled?
65
+ rescue StandardError
66
+ false
67
+ end
68
+
69
+ def load_index
70
+ ids_by_type.each_with_object({}) do |(type, ids), out|
71
+ ids.each_slice(SLICE) { |slice| index_latest(out, type, slice) }
72
+ end
73
+ end
74
+
75
+ def index_latest(index, type, ids)
76
+ latest(type, ids).each { |event| index[key(type, event.record_id)] = event }
77
+ end
78
+
79
+ # { record type => [ids as Strings] }
80
+ def ids_by_type
81
+ @records.group_by { |record| record.class.base_class.name }
82
+ .transform_values { |records| records.map { |record| record.id.to_s }.uniq }
83
+ end
84
+
85
+ def latest(type, ids)
86
+ events = PermissionForge::AuditEvent
87
+ matching = events.where(category: 'change', record_type: type, record_id: ids)
88
+ matching = matching.where(event: @events) if @events
89
+ newest = matching.group(:record_id).select(events.arel_table[:id].maximum)
90
+ events.where(id: newest).to_a
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # Read-only reflection over the host app's ActiveRecord models, so the
6
+ # admin UI only ever offers real models, columns and associations (no
7
+ # free-text names that silently do nothing when misspelled).
8
+ class ModelCatalog
9
+ Column = Struct.new(:name, :label, :type, :enum_values, :user_reference, :virtual, keyword_init: true) do
10
+ def user_reference? = user_reference
11
+ def virtual? = virtual
12
+ end
13
+
14
+ Association = Struct.new(:name, :label, :class_name, :foreign_key, :macro, keyword_init: true) do
15
+ # @param reflection [ActiveRecord::Reflection::AssociationReflection] (non-polymorphic)
16
+ # @param target [Class] the reflection's resolved class
17
+ # @param owner [Class] the model declaring the association
18
+ def self.from(reflection, target, owner)
19
+ name = reflection.name
20
+ new(name: name.to_s, label: owner.human_attribute_name(name), class_name: target.name,
21
+ foreign_key: reflection.foreign_key.to_s, macro: reflection.macro)
22
+ end
23
+ end
24
+
25
+ # A model the admin can protect. Its virtual fields come from
26
+ # PermissionForge::VirtualFields.
27
+ class ModelInfo
28
+ attr_reader :klass
29
+
30
+ # Whether a field with no active ResourceField row gets the implicit
31
+ # read + edit entry from ExtraFieldDefaults right now.
32
+ delegate :implicit_grant?, to: :virtual_fields
33
+
34
+ def initialize(klass, configuration)
35
+ @klass = klass
36
+ @configuration = configuration
37
+ end
38
+
39
+ def name = @klass.name
40
+ def label = @klass.model_name.human
41
+ def plural_label = @klass.model_name.human(count: 2)
42
+
43
+ def column_names
44
+ @column_names ||= @klass.column_names
45
+ end
46
+
47
+ # Columns PermissionForge manages (ignored_fields removed).
48
+ def managed_column_names
49
+ column_names - ignored_column_names
50
+ end
51
+
52
+ def ignored_column_names
53
+ column_names & Array(@configuration.ignored_fields).map(&:to_s)
54
+ end
55
+
56
+ # Virtual fields the model declares through extra_fields_method.
57
+ def declared_virtual_field_names
58
+ virtual_fields.declared
59
+ end
60
+
61
+ # Declared + discovered (config.virtual_field_discovery), never a
62
+ # column or an ignored field.
63
+ def virtual_field_names
64
+ virtual_fields.names
65
+ end
66
+
67
+ # Every field the admin configures: managed columns, then virtual fields.
68
+ def managed_field_names
69
+ managed_column_names + virtual_field_names
70
+ end
71
+
72
+ def virtual_field?(name)
73
+ virtual_fields.include?(name)
74
+ end
75
+
76
+ # Whether code that reads a virtual field straight from the model gets
77
+ # the field's level: its reader is wrapped on the class and
78
+ # config.mask_virtual_attributes selects the model.
79
+ def masked_on_model?(name)
80
+ PermissionForge::Concerns::MaskedAttributes::VirtualReaders.masked?(@klass, name)
81
+ end
82
+
83
+ def columns
84
+ @columns ||= managed_column_names.map { |name| column(name) } +
85
+ virtual_field_names.map { |name| virtual_column(name) }
86
+ end
87
+
88
+ def column(name)
89
+ name = name.to_s
90
+ return virtual_column(name) unless column_names.include?(name)
91
+
92
+ Column.new(name: name, label: @klass.human_attribute_name(name), type: column_type(name),
93
+ enum_values: enum_values(name), user_reference: user_reference?(name), virtual: false)
94
+ end
95
+
96
+ def associations
97
+ @associations ||= @klass.reflect_on_all_associations.filter_map { |ref| build_association(ref) }
98
+ end
99
+
100
+ def association(name)
101
+ associations.find { |association| association.name == name.to_s }
102
+ end
103
+
104
+ private
105
+
106
+ def virtual_fields
107
+ @virtual_fields ||= PermissionForge::VirtualFields.new(@klass, @configuration)
108
+ end
109
+
110
+ # nil for polymorphic or unresolvable associations.
111
+ def build_association(ref)
112
+ target = safe_klass(ref) unless ref.polymorphic?
113
+ Association.from(ref, target, @klass) if target
114
+ end
115
+
116
+ def virtual_column(name)
117
+ Column.new(name: name, label: @klass.human_attribute_name(name), type: :string,
118
+ enum_values: nil, user_reference: false, virtual: true)
119
+ end
120
+
121
+ def column_type(name)
122
+ @klass.type_for_attribute(name).type || :string
123
+ rescue StandardError
124
+ :string
125
+ end
126
+
127
+ def enum_values(name)
128
+ return nil unless @klass.respond_to?(:defined_enums)
129
+
130
+ @klass.defined_enums[name]&.keys
131
+ end
132
+
133
+ def user_reference?(name)
134
+ user_class = @configuration.user_class.to_s
135
+ @klass.reflect_on_all_associations(:belongs_to).any? do |ref|
136
+ !ref.polymorphic? && ref.foreign_key.to_s == name && safe_klass(ref)&.name == user_class
137
+ end
138
+ end
139
+
140
+ def safe_klass(ref)
141
+ ref.klass
142
+ rescue StandardError
143
+ nil
144
+ end
145
+ end
146
+
147
+ def initialize(configuration: PermissionForge.configuration)
148
+ @configuration = configuration
149
+ end
150
+
151
+ # @return [Array<ModelInfo>] sorted by label
152
+ def models
153
+ @models ||= model_classes.map { |k| ModelInfo.new(k, @configuration) }.sort_by(&:label)
154
+ end
155
+
156
+ # @return [ModelInfo, nil] for a model name, whether or not it is listed.
157
+ # One ModelInfo per name for this catalog, so its reflection runs once
158
+ # however many Permission Sets contain the model.
159
+ def find(name)
160
+ key = name.to_s
161
+ @found ||= {}
162
+ @found.fetch(key) { @found[key] = build_info(key) }
163
+ end
164
+
165
+ private
166
+
167
+ def build_info(name)
168
+ klass = name.safe_constantize
169
+ ModelInfo.new(klass, @configuration) if usable?(klass)
170
+ end
171
+
172
+ def model_classes
173
+ classes = if @configuration.admin_model_resolver
174
+ Array(@configuration.admin_model_resolver.call)
175
+ else
176
+ default_model_classes
177
+ end
178
+ classes.select { |k| usable?(k) }.uniq
179
+ end
180
+
181
+ def default_model_classes
182
+ eager_load_app_models
183
+ ActiveRecord::Base.descendants.select { |k| k.name.present? && app_model?(k) }
184
+ end
185
+
186
+ def usable?(klass)
187
+ klass.is_a?(Class) && klass < ActiveRecord::Base && !klass.abstract_class? &&
188
+ !klass.name.to_s.start_with?('PermissionForge::') && klass.table_exists?
189
+ rescue StandardError
190
+ false
191
+ end
192
+
193
+ # Only models defined inside the host app (not gems/frameworks).
194
+ def app_model?(klass)
195
+ return true unless defined?(Rails) && Rails.root
196
+
197
+ file = Object.const_source_location(klass.name)&.first
198
+ file.nil? || file.start_with?(Rails.root.to_s)
199
+ rescue StandardError
200
+ true
201
+ end
202
+
203
+ def eager_load_app_models
204
+ return unless defined?(Rails) && Rails.root && Rails.respond_to?(:autoloaders)
205
+
206
+ eager_load_dir(Rails.autoloaders.main, Rails.root.join('app', 'models').to_s)
207
+ rescue StandardError => e
208
+ Rails.logger&.debug("[PermissionForge] model eager load skipped: #{e.class}")
209
+ end
210
+
211
+ def eager_load_dir(loader, dir)
212
+ loader.eager_load_dir(dir) if File.directory?(dir) && loader.respond_to?(:eager_load_dir)
213
+ end
214
+ end
215
+ end
216
+ end