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,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # Resolves pasted emails/names (one per line or comma-separated) to people
6
+ # for bulk assignment. Exact, case-insensitive matches only — a near miss
7
+ # is reported as "not found" rather than guessed. Only people within
8
+ # config.user_list_resolver match (PeopleDirectory#find_all): anyone else
9
+ # is "not found" too.
10
+ class BulkPeopleMatcher
11
+ FIRST_LAST_COLUMNS = [%w[first_name last_name], %w[firstname lastname]].freeze
12
+ # Same-named people considered per pasted line before the resolver
13
+ # narrows them.
14
+ CANDIDATES_PER_TOKEN = 20
15
+
16
+ Result = Struct.new(:to_assign, :already, :not_found, keyword_init: true)
17
+
18
+ # @param directory [PeopleDirectory, nil] share the request's directory,
19
+ # so an array-returning resolver is evaluated once per request
20
+ def initialize(configuration: PermissionForge.configuration, directory: nil)
21
+ @configuration = configuration
22
+ @directory = directory
23
+ end
24
+
25
+ # @param text [String] pasted identifiers
26
+ # @param user_ids [Array] people picked with checkboxes
27
+ # @param permission_set [PermissionSet]
28
+ def call(text:, user_ids:, permission_set:)
29
+ found, not_found = resolve(tokens(text))
30
+ picked(user_ids).each { |user| found[user.id] = user }
31
+
32
+ already, to_assign = partition_assigned(found.values, permission_set)
33
+ Result.new(to_assign: to_assign, already: already, not_found: not_found)
34
+ end
35
+
36
+ private
37
+
38
+ # [already assigned, not yet assigned]
39
+ def partition_assigned(users, permission_set)
40
+ assigned = permission_set.permission_set_users.where(user_id: users.map(&:id)).pluck(:user_id)
41
+ users.partition { |user| assigned.include?(user.id) }
42
+ end
43
+
44
+ def tokens(text)
45
+ PeopleDirectory.search_text(text).split(/[\n,;]+/).map(&:strip).reject(&:empty?).uniq
46
+ end
47
+
48
+ # [{ id => user } for matched tokens, unmatched tokens]
49
+ def resolve(tokens)
50
+ found = {}
51
+ not_found = tokens.reject do |token|
52
+ user = lookup(token)
53
+ found[user.id] = user if user
54
+ end
55
+ [found, not_found]
56
+ end
57
+
58
+ def model
59
+ @configuration.user_model
60
+ end
61
+
62
+ def lookup(token)
63
+ needle = token.downcase
64
+ return exact(column_if_present('email'), needle) if token.include?('@')
65
+
66
+ exact(name_column, needle) || first_last_match(needle)
67
+ end
68
+
69
+ def exact(column, needle)
70
+ column && first_listed(model.where(model.arel_table[column].lower.eq(needle)))
71
+ end
72
+
73
+ # The first match that config.user_list holds.
74
+ def first_listed(matches)
75
+ directory.find_all(matches.limit(CANDIDATES_PER_TOKEN).ids).first
76
+ end
77
+
78
+ def directory
79
+ @directory ||= PeopleDirectory.new(configuration: @configuration)
80
+ end
81
+
82
+ def column_if_present(column)
83
+ column if model.column_names.include?(column)
84
+ end
85
+
86
+ # "Ana Krasniqi" against separate first/last name columns.
87
+ def first_last_match(needle)
88
+ first, last = needle.split(/\s+/, 2)
89
+ pair = first_last_columns
90
+ return nil unless pair && last
91
+
92
+ table = model.arel_table
93
+ first_listed(model.where(table[pair.first].lower.eq(first).and(table[pair.last].lower.eq(last))))
94
+ end
95
+
96
+ def first_last_columns
97
+ FIRST_LAST_COLUMNS.find { |cols| (cols - model.column_names).empty? }
98
+ end
99
+
100
+ def name_column
101
+ (%w[name full_name display_name] & model.column_names).first
102
+ end
103
+
104
+ def picked(ids)
105
+ directory.find_all(ids)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # Batched edits to one Permission Set (set header, resource CRUD/scope
6
+ # flags, field levels, new fields, action toggles, new actions) as
7
+ # submitted by the editor forms.
8
+ #
9
+ # Computes the list of real changes — after the same cascades the UI
10
+ # shows (Read off => Edit/Delete/View All/Modify All off and Editable
11
+ # fields downgraded; Edit off => Modify All off and Editable fields
12
+ # downgraded) — and applies them in one transaction. Muting sets have
13
+ # inverted semantics, so no cascades or dependencies apply to them.
14
+ #
15
+ # Params shape (all optional):
16
+ # set: { name:, description:, is_active:, permission_set_group_id: }
17
+ # resources: { "<id>" => { read_access: "1", ..., label:, description: } }
18
+ # fields: { "<field id>" => { level: "masked", mask_strategy:, label:, description:, is_active: } }
19
+ # new_fields: { "<resource id>" => { "<column>" => { level: } } }
20
+ # actions: { "<action id>" => { is_permitted: "1" } }
21
+ # bulk: { resource_id:, keys: [...], level: }
22
+ # new_actions: { "<resource id>" => { "<key>" => { is_permitted: "1" } } }
23
+ # bulk_actions: { resource_id:, is_permitted: "1" } — every discovered action without a row
24
+ class ChangeSet
25
+ # Collaborators, in evaluation order: later parts see earlier parts'
26
+ # in-memory edits (field levels depend on the resource's new Edit flag).
27
+ PARTS = [
28
+ ChangeSetParts::SetAttrs,
29
+ ChangeSetParts::ResourceFlags,
30
+ ChangeSetParts::FieldLevels,
31
+ ChangeSetParts::NewFields,
32
+ ChangeSetParts::Actions,
33
+ ChangeSetParts::NewActions
34
+ ].freeze
35
+
36
+ # kind: :set_attr | :resource_flag | :resource_text | :field_level | :field_attr | :new_field | :action |
37
+ # :new_action
38
+ Change = Struct.new(:kind, :record, :attribute, :from, :to, :cascaded, :resource, keyword_init: true) do
39
+ # Blank-ish values (nil, "") are equal; false is a real value.
40
+ def unchanged?
41
+ from == to || (from.blank? && to.blank? && from != false && to != false)
42
+ end
43
+
44
+ # Captures the record's current value as `from` and assigns `to`.
45
+ # Returns self, or nil (nothing assigned) when effectively unchanged.
46
+ def assign
47
+ self.from = record.public_send(attribute)
48
+ return if unchanged?
49
+
50
+ record.public_send("#{attribute}=", to)
51
+ self
52
+ end
53
+ end
54
+
55
+ class Invalid < StandardError
56
+ attr_reader :record
57
+
58
+ def initialize(record)
59
+ @record = record
60
+ super(record.errors.full_messages.to_sentence)
61
+ end
62
+ end
63
+
64
+ attr_reader :permission_set
65
+
66
+ # @param catalog [ControllerCatalog, nil] the app's controller actions,
67
+ # for new_actions; built on first use when nil
68
+ def initialize(permission_set:, params:, sensitive: SensitiveColumns.new, catalog: nil)
69
+ @permission_set = permission_set
70
+ @context = ChangeSetParts::Context.new(permission_set: permission_set, params: normalize(params),
71
+ sensitive: sensitive, catalog: catalog)
72
+ end
73
+
74
+ def changes
75
+ @changes ||= PARTS.flat_map { |part| part.new(@context).call }
76
+ end
77
+
78
+ def empty?
79
+ changes.empty?
80
+ end
81
+
82
+ # Saves every pending record, all or nothing, under one audit
83
+ # correlation id. Its own savepoint (requires_new) rolls back the saves
84
+ # before a failing one — and their audit events — also when the caller's
85
+ # transaction rescues Invalid and goes on to commit.
86
+ #
87
+ # @return [Array<Change>]
88
+ # @raise [Invalid] a record failed validation; nothing was saved
89
+ def apply!
90
+ PermissionForge::Audit.correlate do
91
+ ActiveRecord::Base.transaction(requires_new: true) do
92
+ pending_records.each { |record| save!(record) }
93
+ end
94
+ end
95
+ changes
96
+ end
97
+
98
+ private
99
+
100
+ # Anything but a Hash of sections (a String, an Array) changes nothing.
101
+ def normalize(params)
102
+ hash = if params.respond_to?(:to_unsafe_h) then params.to_unsafe_h
103
+ elsif params.is_a?(Hash) then params
104
+ else {}
105
+ end
106
+ hash.deep_stringify_keys
107
+ end
108
+
109
+ def save!(record)
110
+ raise Invalid, record unless record.save
111
+ end
112
+
113
+ # Records with assigned-but-unsaved attributes, in dependency order.
114
+ def pending_records
115
+ changes.map(&:record).uniq.select { |record| pending?(record) }
116
+ end
117
+
118
+ def pending?(record)
119
+ record.new_record? || record.changed?
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ module ChangeSetParts
6
+ # Custom action is_permitted toggles (actions of locked resources are ignored).
7
+ class Actions < Base
8
+ def call
9
+ section('actions').filter_map do |id, attrs|
10
+ action = action_index[id.to_s]
11
+ next unless action && attrs.is_a?(Hash) && attrs.key?('is_permitted')
12
+
13
+ assign_change(kind: :action, record: action, attribute: 'is_permitted', to: truthy?(attrs['is_permitted']),
14
+ resource: resource_of(action))
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def action_index
21
+ @action_index ||= resources.values.reject { |r| locked?(r) }.flat_map(&:resource_actions)
22
+ .index_by { |action| action.id.to_s }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ module ChangeSetParts
6
+ # Base for the ChangeSet collaborators. Each part turns one params
7
+ # section into a list of ChangeSet::Change and assigns (never saves) the
8
+ # new values on the in-memory records; ChangeSet#apply! persists them.
9
+ class Base
10
+ def initialize(context)
11
+ @context = context
12
+ end
13
+
14
+ private
15
+
16
+ delegate :permission_set, :params, :sensitive, :muting?, :section, :resources, :resource_of,
17
+ to: :@context
18
+
19
+ def change(**attrs)
20
+ ChangeSet::Change.new(**attrs)
21
+ end
22
+
23
+ # Assigns the change on its record; nil when effectively unchanged.
24
+ def assign_change(**attrs)
25
+ change(**attrs).assign
26
+ end
27
+
28
+ def valid_level?(level)
29
+ FieldLevel.valid?(level, muting: muting?)
30
+ end
31
+
32
+ def flags_for(level)
33
+ FieldLevel.flags_for(level, muting: muting?)
34
+ end
35
+
36
+ # The resource for `id`, unless missing or locked.
37
+ def unlocked_resource(id)
38
+ resource = resources[id.to_s]
39
+ resource unless resource.nil? || locked?(resource)
40
+ end
41
+
42
+ def bulk
43
+ section('bulk')
44
+ end
45
+
46
+ # Records flagged is_editable: false are managed by the system/API and
47
+ # are never changed from the admin UI (their disabled inputs still post).
48
+ def locked?(record)
49
+ record.respond_to?(:is_editable) && record.is_editable == false
50
+ end
51
+
52
+ def truthy?(value)
53
+ Values.truthy?(value)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ module ChangeSetParts
6
+ # State shared by every ChangeSet collaborator: the permission set, the
7
+ # normalized params and the in-memory resources. Each part mutates these
8
+ # same resource/field objects, so later parts see earlier parts' edits
9
+ # (e.g. field levels see the resource's new Edit flag).
10
+ class Context
11
+ attr_reader :permission_set, :params, :sensitive
12
+
13
+ def initialize(permission_set:, params:, sensitive:, catalog: nil)
14
+ @permission_set = permission_set
15
+ @params = params
16
+ @sensitive = sensitive
17
+ @controller_catalog = catalog
18
+ @muting = permission_set.is_muting
19
+ end
20
+
21
+ # The app's controller actions (see NewActions). Built on first use:
22
+ # most change sets never read the route table.
23
+ def controller_catalog
24
+ @controller_catalog ||= ControllerCatalog.new
25
+ end
26
+
27
+ def muting?
28
+ @muting
29
+ end
30
+
31
+ # One params section (e.g. 'fields', 'bulk'), or an empty hash — also
32
+ # when the request sent something else than a Hash there.
33
+ def section(key)
34
+ value = @params[key]
35
+ value.is_a?(Hash) ? value : {}
36
+ end
37
+
38
+ def resources
39
+ @resources ||= @permission_set.resources.includes(:resource_fields).index_by { |r| r.id.to_s }
40
+ end
41
+
42
+ # The in-memory resource (with this change set's flag edits applied) — not
43
+ # record.resource, which may be a separately loaded copy.
44
+ def resource_of(record)
45
+ resources[record.resource_id.to_s]
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ module ChangeSetParts
6
+ # Edits to one existing field: level (+ mask strategy), then
7
+ # label/description/active. `resource` is the in-memory resource with
8
+ # this change set's flag edits already applied.
9
+ class FieldEdit
10
+ ATTRS = %w[label description is_active].freeze
11
+ MASK_LEVELS = %w[masked mask].freeze
12
+ CASTS = {
13
+ 'label' => ->(value, field) { value.to_s.strip.presence || field.name.humanize },
14
+ 'is_active' => ->(value, _field) { Values.truthy?(value) }
15
+ }.freeze
16
+ TEXT_CAST = ->(value, _field) { value.to_s.strip.presence }
17
+
18
+ def initialize(field, resource, muting:)
19
+ @field = field
20
+ @resource = resource
21
+ @muting = muting
22
+ end
23
+
24
+ def changes(attrs)
25
+ level_change(attrs) + attr_changes(attrs.slice(*ATTRS))
26
+ end
27
+
28
+ # Cascade: Editable => Read-only. Returns the (cascaded) change or nil.
29
+ def downgrade_to_read
30
+ assign_level('read').first&.tap { |change| change.cascaded = true }
31
+ end
32
+
33
+ private
34
+
35
+ def level_change(attrs)
36
+ level = attrs['level'].to_s
37
+ return [] unless FieldLevel.valid?(level, muting: @muting)
38
+
39
+ assign_level(Values.effective_level(level, @resource, muting: @muting), attrs['mask_strategy'])
40
+ end
41
+
42
+ def assign_level(level, strategy = nil)
43
+ from = [FieldLevel.level_of(@field, muting: @muting), @field.mask_strategy]
44
+ write_level(level, strategy)
45
+ return [] if from.first == level && !@field.mask_strategy_changed?
46
+
47
+ [change('level', [level, @field.mask_strategy], kind: :field_level, from: from)]
48
+ end
49
+
50
+ def write_level(level, strategy)
51
+ @field.assign_attributes(FieldLevel.flags_for(level, muting: @muting))
52
+ @field.mask_strategy = strategy if strategy.present? && MASK_LEVELS.include?(level)
53
+ end
54
+
55
+ def attr_changes(attrs)
56
+ attrs.filter_map do |attr, value|
57
+ change(attr, CASTS.fetch(attr, TEXT_CAST).call(value, @field), kind: :field_attr).assign
58
+ end
59
+ end
60
+
61
+ def change(attribute, to, **attrs)
62
+ ChangeSet::Change.new(record: @field, resource: @resource, attribute: attribute, to: to, **attrs)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ module ChangeSetParts
6
+ # Existing fields: explicit edits and bulk level selections (see
7
+ # FieldEdit), plus the Edit-off cascade to Read-only.
8
+ class FieldLevels < Base
9
+ def call
10
+ requested = requested_field_attrs
11
+ changes = requested.flat_map { |id, attrs| changes_for_field(field_index[id], attrs) }
12
+ changes + cascaded_field_changes(requested.keys)
13
+ end
14
+
15
+ private
16
+
17
+ def field_index
18
+ @field_index ||= resources.values.flat_map(&:resource_fields).index_by { |field| field.id.to_s }
19
+ end
20
+
21
+ # Merges explicit field params with bulk selections.
22
+ def requested_field_attrs
23
+ out = explicit_field_attrs
24
+ level = bulk['level'].to_s
25
+ return out unless valid_level?(level)
26
+
27
+ bulk_fields.each { |field| (out[field.id.to_s] ||= {})['level'] = level }
28
+ out
29
+ end
30
+
31
+ def explicit_field_attrs
32
+ section('fields').select { |id, attrs| field_index.key?(id.to_s) && attrs.is_a?(Hash) }.transform_keys(&:to_s)
33
+ end
34
+
35
+ def bulk_fields
36
+ resource = resources[bulk['resource_id'].to_s]
37
+ return [] unless resource
38
+
39
+ fields = Array(bulk['keys']).filter_map { |key| resource.resource_fields.find { |field| field.name == key.to_s } }
40
+ fields.reject { |field| field.is_editable == false }
41
+ end
42
+
43
+ def changes_for_field(field, attrs)
44
+ return [] if field.nil? || locked?(field) || locked?(resource_of(field))
45
+
46
+ field_edit(field).changes(attrs)
47
+ end
48
+
49
+ # Editable fields on resources whose Edit (or Read) was just turned off
50
+ # are downgraded to Read-only so field and resource stay consistent.
51
+ def cascaded_field_changes(already)
52
+ return [] if muting?
53
+
54
+ resources.values.reject(&:edit_access).flat_map(&:resource_fields).filter_map do |field|
55
+ field_edit(field).downgrade_to_read if cascadable?(field, already)
56
+ end
57
+ end
58
+
59
+ # A locked field, or a field of a locked Resource, keeps its level.
60
+ def cascadable?(field, already)
61
+ !already.include?(field.id.to_s) && !locked?(field) && !locked?(resource_of(field)) &&
62
+ FieldLevel.level_of(field, muting: false) == 'edit'
63
+ end
64
+
65
+ def field_edit(field)
66
+ FieldEdit.new(field, resource_of(field), muting: muting?)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ module ChangeSetParts
6
+ # New actions: controller actions that discovery lists for a resource and
7
+ # that have no ResourceAction row in this set yet — picked one by one
8
+ # (`new_actions`), or all at once (`bulk_actions`).
9
+ #
10
+ # Only keys the controller catalog lists for the resource are created;
11
+ # free-text keys go through ResourceActionsController. Nothing is created
12
+ # while controller_action_discovery is off, in a locked set, in a locked
13
+ # resource, or for a key that already has a row. A blank is_permitted
14
+ # means "don't add". In a muting set is_permitted means "block".
15
+ class NewActions < Base
16
+ def call
17
+ return [] unless creatable?
18
+
19
+ explicit = hash_section('new_actions').flat_map { |resource_id, keys| explicit_new_actions(resource_id, keys) }
20
+ explicit + bulk_new_actions
21
+ end
22
+
23
+ private
24
+
25
+ delegate :controller_catalog, to: :@context
26
+
27
+ def creatable?
28
+ PermissionForge.configuration.controller_action_discovery? && !locked?(permission_set)
29
+ end
30
+
31
+ # A params section, or {} when the request sent something else there.
32
+ def hash_section(key)
33
+ value = section(key)
34
+ value.is_a?(Hash) ? value : {}
35
+ end
36
+
37
+ # The route table is only read once a row actually asks to be added.
38
+ def explicit_new_actions(resource_id, keys)
39
+ resource = unlocked_resource(resource_id)
40
+ chosen = chosen_keys(keys)
41
+ return [] unless resource && chosen.any?
42
+
43
+ addable = addable_keys(resource)
44
+ chosen.filter_map { |key, permitted| build_new_action(resource, key, permitted) if addable.include?(key) }
45
+ end
46
+
47
+ # { 'export' => '1' } for the keys with a non-blank choice.
48
+ def chosen_keys(keys)
49
+ return {} unless keys.is_a?(Hash)
50
+
51
+ keys.each_with_object({}) do |(key, attrs), chosen|
52
+ permitted = attrs.is_a?(Hash) ? attrs['is_permitted'].to_s.strip : ''
53
+ chosen[key.to_s] = permitted unless permitted.empty?
54
+ end
55
+ end
56
+
57
+ # Every discovered key that neither has a row nor an explicit pick.
58
+ def bulk_new_actions
59
+ bulk = hash_section('bulk_actions')
60
+ permitted = bulk['is_permitted'].to_s.strip
61
+ resource = unlocked_resource(bulk['resource_id']) unless permitted.empty?
62
+ return [] unless resource
63
+
64
+ addable_keys(resource).map { |key| build_new_action(resource, key, permitted) }
65
+ end
66
+
67
+ # Discovered keys for the resource without a row — including the rows
68
+ # this change set has already built.
69
+ def addable_keys(resource)
70
+ discovered_keys(resource.name) - existing_names(resource)
71
+ end
72
+
73
+ # The catalog only lists valid keys; the grep is defence in depth.
74
+ def discovered_keys(resource_name)
75
+ controller_catalog.keys_for(resource_name).grep(PermissionForge::ControllerActions::KEY_FORMAT)
76
+ end
77
+
78
+ def existing_names(resource)
79
+ resource.resource_actions.map(&:name)
80
+ end
81
+
82
+ def build_new_action(resource, key, permitted)
83
+ action = resource.resource_actions.build(name: key, label: key.humanize, is_active: true,
84
+ is_permitted: truthy?(permitted))
85
+ change(kind: :new_action, record: action, attribute: 'is_permitted', from: nil, to: action.is_permitted,
86
+ resource: resource)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ module ChangeSetParts
6
+ # New fields: columns without a ResourceField row, picked one by one or
7
+ # through a bulk selection.
8
+ class NewFields < Base
9
+ def call
10
+ explicit = section('new_fields').flat_map { |resource_id, columns| explicit_new_fields(resource_id, columns) }
11
+ explicit + bulk_new_fields
12
+ end
13
+
14
+ private
15
+
16
+ def explicit_new_fields(resource_id, columns)
17
+ resource = unlocked_resource(resource_id)
18
+ return [] unless resource && columns.is_a?(Hash)
19
+
20
+ existing = existing_names(resource)
21
+ columns.filter_map do |column, attrs|
22
+ name = column.to_s
23
+ next if existing.include?(name) || !attrs.is_a?(Hash)
24
+
25
+ build_new_field(resource, name, attrs['level'].to_s)
26
+ end
27
+ end
28
+
29
+ def bulk_new_fields
30
+ level = bulk['level'].to_s
31
+ resource = unlocked_resource(bulk['resource_id'])
32
+ return [] unless resource && valid_level?(level)
33
+
34
+ bulk_columns(resource).filter_map { |column| build_new_field(resource, column, level) }
35
+ end
36
+
37
+ # Bulk-selected columns that have neither a field nor an explicit pick.
38
+ def bulk_columns(resource)
39
+ Array(bulk['keys']).map(&:to_s).uniq - existing_names(resource) - explicit_columns(resource)
40
+ end
41
+
42
+ def existing_names(resource)
43
+ resource.resource_fields.map(&:name)
44
+ end
45
+
46
+ def explicit_columns(resource)
47
+ (params.dig('new_fields', resource.id.to_s) || {}).keys.map(&:to_s)
48
+ end
49
+
50
+ def build_new_field(resource, column, level)
51
+ return nil unless valid_level?(level) && level != 'leave'
52
+
53
+ level = Values.effective_level(level, resource, muting: muting?)
54
+ field = resource.resource_fields.build(name: column, label: column.humanize, is_active: true, is_editable: true)
55
+ field.assign_attributes(flags_for(level))
56
+ field.mask_strategy = sensitive.match(column)&.strategy || 'full'
57
+ change(kind: :new_field, record: field, attribute: 'level', from: nil, to: [level, field.mask_strategy],
58
+ resource: resource)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end