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,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Api
5
+ class ResourcesController < BaseController
6
+ include CrudActions
7
+
8
+ before_action :set_permission_set
9
+ before_action :set_resource, only: %i[show update destroy generate_fields]
10
+
11
+ # Grants read (and, with grant_all, edit) on every unconfigured column,
12
+ # so it passes the same gates as the admin UI's "Sync fields": the
13
+ # authorization hook, then the lock on the resource and its set.
14
+ def generate_fields
15
+ guard_mutation!(:generate_fields, @resource)
16
+ return if performed?
17
+
18
+ fields = PermissionForge::Services::ResourceFieldGenerator.call(
19
+ resource: @resource,
20
+ grant_all: %w[true 1].include?(params[:grant_all])
21
+ )
22
+ data = PermissionForge::Api::Serializers::ResourceFieldSerializer.collection(fields)
23
+ render json: { data: data }, status: :created
24
+ rescue StandardError => e
25
+ # Log the internal detail; return a generic message so model/column names
26
+ # and adapter errors are not leaked to API clients.
27
+ Rails.logger.error("[PermissionForge] generate_fields failed for #{@resource&.name}: #{e.class}: #{e.message}")
28
+ render json: {
29
+ error: { status: 422, messages: I18n.t('permission_forge.api.errors.field_generation_failed') }
30
+ }, status: :unprocessable_entity
31
+ end
32
+
33
+ private
34
+
35
+ def collection_scope = @permission_set.resources
36
+ def current_record = @resource
37
+ def serializer_type = :resource
38
+
39
+ def set_permission_set
40
+ @permission_set = find_record(PermissionForge::PermissionSet, :permission_set_identifier)
41
+ end
42
+
43
+ def set_resource
44
+ @resource = find_record(@permission_set.resources)
45
+ end
46
+
47
+ def record_params
48
+ pf_require_hash(:resource).permit(
49
+ :name, :description, :is_active, :is_editable,
50
+ :read_access, :create_access, :edit_access, :delete_access,
51
+ :view_all, :modify_all
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,198 @@
1
+ module PermissionForge
2
+ # Base class of the admin screens. Inherits from `config.base_controller_class`, so the host's
3
+ # authentication and layout apply.
4
+ #
5
+ # @api public
6
+ class ApplicationController < PermissionForge.configuration.base_controller_class.constantize
7
+ # Include standard ActionController modules when inheriting from a base
8
+ # that may not have them (e.g. ActionController::Base vs ApplicationController)
9
+ ActionController::Base::MODULES.each do |mod|
10
+ include mod unless self < mod
11
+ end
12
+
13
+ include PermissionForge::ForgeryProtection
14
+
15
+ protect_from_forgery with: :exception, **pf_forgery_strategy
16
+ # Several admin forms submit to more than one action via `formaction`
17
+ # buttons (rule builder refresh, bulk "Check names", "Apply
18
+ # recommendation"). Per-form tokens are bound to the form's own action, so
19
+ # those submissions would fail verification; the session-wide (masked)
20
+ # token still protects every request.
21
+ self.per_form_csrf_tokens = false
22
+
23
+ layout :pf_layout
24
+
25
+ include PermissionForge::BasicAuthentication
26
+ include PermissionForge::TenantRequired
27
+ include PermissionForge::ParamShapes
28
+ include PermissionForge::HtmlFormat
29
+
30
+ THEMES = %w[auto light dark].freeze
31
+ FRAGMENT_HEADER = 'X-PF-Fragment'.freeze
32
+ AUDIT_SOURCE = 'admin_ui'.freeze
33
+
34
+ before_action :set_locale
35
+ before_action :pf_require_tenant
36
+ around_action :pf_without_permission_scope
37
+ # After authentication (a refused sign-in records its own event) and
38
+ # inside the scope suspension (an actor resolver may query).
39
+ around_action :pf_audit_request
40
+
41
+ helper PermissionForge::ApplicationHelper unless self < PermissionForge::ApplicationHelper
42
+ helper_method :pf_fragment?, :pf_theme, :pf_help_visible?, :pf_current_admin,
43
+ :pf_attention, :pf_settings_catalog, :pf_back_or
44
+
45
+ private
46
+
47
+ # Memoized per request: drives the Home badge and the home page.
48
+ def pf_attention
49
+ @pf_attention ||= PermissionForge::Admin::AttentionItems.new
50
+ end
51
+
52
+ def pf_settings_catalog
53
+ @pf_settings_catalog ||= PermissionForge::Admin::SettingsCatalog.new
54
+ end
55
+
56
+ def set_locale
57
+ session[:permission_forge_locale] = params[:locale] if params[:locale].present? && available_locale?(params[:locale])
58
+
59
+ I18n.locale = session[:permission_forge_locale] || I18n.default_locale
60
+ end
61
+
62
+ def available_locale?(locale)
63
+ allowed = PermissionForge.configuration.available_locales || PermissionForge::BUNDLED_LOCALES
64
+ Array(allowed).map(&:to_s).include?(locale.to_s)
65
+ end
66
+
67
+ def default_url_options
68
+ {}
69
+ end
70
+
71
+ # The admin manages permissions for everyone, so it must see every record:
72
+ # suspend Level 1 auto-scoping / Level 2 detection for the request.
73
+ def pf_without_permission_scope(&)
74
+ PermissionForge.without_permission_scope(&)
75
+ end
76
+
77
+ # Who acts, from where, which request and why — for every audit event of
78
+ # the request; one correlation id for a request that changes something;
79
+ # the read-only pages of Audit::AuditedViews recorded after success.
80
+ # Nothing of it outlives the request (Audit::RequestContext).
81
+ def pf_audit_request(&)
82
+ PermissionForge::Audit::RequestContext.wrap(self, source: AUDIT_SOURCE, &)
83
+ end
84
+
85
+ # Records a refused request (admin.auth_failed, admin.lock_refused …) so
86
+ # that no rollback takes it along. `data` gets method, path and endpoint.
87
+ #
88
+ # pf_audit_refused('admin.auth_failed', data: { 'reason' => 'assignment_not_authorized' }, permission_set: set)
89
+ def pf_audit_refused(event, **payload)
90
+ PermissionForge::Audit::RequestContext.refused(self, event, source: AUDIT_SOURCE, **payload)
91
+ end
92
+
93
+ # Requests from the progressive-enhancement script that load a page into
94
+ # a drawer/dialog get the bare content, without the sidebar layout.
95
+ def pf_fragment?
96
+ request.headers[FRAGMENT_HEADER].present?
97
+ end
98
+
99
+ def pf_layout
100
+ pf_fragment? ? 'permission_forge/fragment' : 'permission_forge/application'
101
+ end
102
+
103
+ def pf_theme
104
+ theme = cookies[:pf_theme].to_s
105
+ THEMES.include?(theme) ? theme : 'auto'
106
+ end
107
+
108
+ def pf_dismissed_help
109
+ cookies[:pf_help].to_s.split(',').grep(/\A[a-z_]+\z/)
110
+ end
111
+
112
+ def pf_help_visible?(key)
113
+ !pf_dismissed_help.include?(key.to_s)
114
+ end
115
+
116
+ # Cookies are scoped to the engine's mount path so they never collide with
117
+ # (or leak into) the host app's own cookies, and are Secure whenever the
118
+ # admin is served over HTTPS.
119
+ def pf_cookie(name, value)
120
+ cookies[name] = { value: value, path: pf_mount_path, httponly: true, same_site: :lax,
121
+ secure: request.ssl?, expires: 1.year.from_now }
122
+ end
123
+
124
+ def pf_mount_path
125
+ request.script_name.presence || '/'
126
+ end
127
+
128
+ def pf_current_admin
129
+ return @pf_current_admin if defined?(@pf_current_admin)
130
+
131
+ @pf_current_admin = begin
132
+ PermissionForge.current_user
133
+ rescue StandardError
134
+ nil
135
+ end
136
+ end
137
+
138
+ # Redirect with a toast. `undo:` is { path:, method:, params: } for a
139
+ # one-click reversal rendered inside the toast.
140
+ # Messages: notice:, alert:.
141
+ def pf_redirect(path, undo: nil, **messages)
142
+ flash[:pf_undo] = undo if undo
143
+ redirect_to path, **messages.slice(:notice, :alert)
144
+ end
145
+
146
+ def pf_back_or(default)
147
+ target = params[:return_to].to_s
148
+ pf_local_path?(target) ? target : default
149
+ end
150
+
151
+ # Same-origin path ("/x"), never protocol-relative ("//host"), and
152
+ # nothing a browser could read as another host: no backslash ("/\\host")
153
+ # and no control character (a tab or newline inside "//").
154
+ def pf_local_path?(path)
155
+ path.start_with?('/') && !path.start_with?('//') && !path.match?(/[\\[:cntrl:]]/)
156
+ end
157
+
158
+ # Expiry preset + custom date posted by the assign forms.
159
+ def pf_load_expiry
160
+ @expiry = PermissionForge::Admin::ExpiryPresets::KEYS.include?(params[:expiry]) ? params[:expiry] : 'never'
161
+ @custom_date = params[:custom_date].to_s
162
+ end
163
+
164
+ # Sets @expires_at from the chosen preset; returns the translated error
165
+ # for an invalid custom date, else nil.
166
+ def pf_resolve_expiry
167
+ @expires_at = PermissionForge::Admin::ExpiryPresets.expires_at_for(@expiry, custom: @custom_date)
168
+ nil
169
+ rescue PermissionForge::Admin::ExpiryPresets::InvalidDate => e
170
+ t("permission_forge.errors.admin.expiry_#{e.message}")
171
+ end
172
+
173
+ def pf_people_count(permission_set)
174
+ permission_set.permission_set_users.active_assignments.count
175
+ end
176
+
177
+ def pf_not_found
178
+ raise ActionController::RoutingError, 'Not Found'
179
+ end
180
+
181
+ def pf_locked?(record)
182
+ PermissionForge::Admin::EditableLock.locked?(record)
183
+ end
184
+
185
+ # Redirects with an alert when the record (or any of `records`), or what
186
+ # it belongs to (Admin::EditableLock: its Resource, Permission Set, the
187
+ # Group of a membership), is locked, and records the refusal
188
+ # (admin.lock_refused, naming the locked record); returns whether it was.
189
+ def pf_reject_locked!(records, path, alert: t('permission_forge.flash.not_editable')) # rubocop:disable Naming/PredicateMethod -- redirects; the answer says whether it did
190
+ locked = PermissionForge::Admin::EditableLock.locked_record(records)
191
+ if locked
192
+ pf_audit_refused('admin.lock_refused', record: locked)
193
+ redirect_to path, alert: alert
194
+ end
195
+ locked ? true : false
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,97 @@
1
+ module PermissionForge
2
+ # Serves the admin UI's own stylesheet, script and brand files straight from
3
+ # the gem, so the engine works without Sprockets, Propshaft, importmap or any
4
+ # other host asset pipeline.
5
+ #
6
+ # Inherits from ActionController::Base on purpose — never from the host's
7
+ # base_controller_class — so host before_actions (sign-in redirects, tenant
8
+ # lookups, layouts) can't break or leak into static files. Nothing served
9
+ # here is sensitive: it is the same CSS/JS/logo for every visitor.
10
+ #
11
+ # @api private
12
+ class AssetsController < ActionController::Base
13
+ ROOT = Engine.root.join('app', 'frontend', 'permission_forge').freeze
14
+
15
+ # Allow-list: published name => [relative path, content type].
16
+ FILES = {
17
+ 'pf_admin.css' => ['pf_admin.css', 'text/css'],
18
+ 'pf_admin.js' => ['pf_admin.js', 'text/javascript'],
19
+ 'logo-light.svg' => ['brand/logo-horizontal-light.svg', 'image/svg+xml'],
20
+ 'logo-dark.svg' => ['brand/logo-horizontal-dark.svg', 'image/svg+xml'],
21
+ 'mark.svg' => ['brand/mark.svg', 'image/svg+xml'],
22
+ 'mark-small.svg' => ['brand/mark-small.svg', 'image/svg+xml'],
23
+ 'favicon.svg' => ['brand/favicon.svg', 'image/svg+xml'],
24
+ 'favicon.ico' => ['brand/favicon.ico', 'image/x-icon'],
25
+ 'apple-touch-icon.png' => ['brand/apple-touch-icon.png', 'image/png'],
26
+ 'chevron.svg' => ['brand/chevron.svg', 'image/svg+xml']
27
+ }.freeze
28
+
29
+ # GET-only; CSRF protection stays on (no-op for GET) so no request path
30
+ # ever runs unprotected. The cross-origin <script> check is skipped: this
31
+ # controller exists to serve pf_admin.js to <script src> tags, and the
32
+ # file is the same public, static script for every visitor.
33
+ include PermissionForge::ForgeryProtection
34
+
35
+ protect_from_forgery with: :exception, **pf_forgery_strategy
36
+ skip_after_action :verify_same_origin_request
37
+ # Rails 8.2 adds Vary: Sec-Fetch-Site to every protected response; the
38
+ # same public file for every visitor needs no per-header cache copies.
39
+ skip_after_action :append_sec_fetch_site_to_vary_header, raise: false
40
+
41
+ def show
42
+ name = params[:file].to_s
43
+ path, type = asset_entry(name)
44
+ return head(:not_found) unless path
45
+
46
+ cache_headers(name)
47
+ return unless stale?(etag: self.class.digest(name), public: true)
48
+
49
+ response.headers['X-Content-Type-Options'] = 'nosniff'
50
+ send_file path, type: type, disposition: 'inline'
51
+ end
52
+
53
+ private
54
+
55
+ # [absolute path, content type] for an allow-listed name whose file exists, else nil.
56
+ def asset_entry(name)
57
+ path, type = self.class.entries[name]
58
+ [path, type] if path && File.file?(path)
59
+ end
60
+
61
+ # Fingerprinted URLs (?v=digest) never change content, so they can be
62
+ # cached forever; unversioned requests revalidate via ETag.
63
+ def cache_headers(name)
64
+ version = params[:v]
65
+ if version.present? && version == self.class.digest(name)
66
+ response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
67
+ else
68
+ expires_in 0, public: true, must_revalidate: true
69
+ end
70
+ end
71
+
72
+ class << self
73
+ # Allow-list resolved to absolute paths: name => [path, content type].
74
+ # Paths come only from FILES, never from the request.
75
+ def entries
76
+ @entries ||= FILES.to_h { |name, (relative, type)| [name, [ROOT.join(relative).to_s, type]] }.freeze
77
+ end
78
+
79
+ # Short content digest used as the cache-busting `v` query param.
80
+ def digest(name)
81
+ @digests ||= {}
82
+ return @digests[name] if @digests.key?(name) && !reload_assets?
83
+
84
+ entry = FILES[name.to_s]
85
+ @digests[name] = entry ? Digest::SHA256.file(ROOT.join(entry.first)).hexdigest[0, 12] : nil
86
+ end
87
+
88
+ private
89
+
90
+ # Recompute digests on every request in development so CSS/JS edits in
91
+ # the gem show up without a server restart.
92
+ def reload_assets?
93
+ defined?(Rails) && Rails.env.development?
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,266 @@
1
+ module PermissionForge
2
+ # Giving and taking access: PermissionSetUser rows. Every change applies
3
+ # immediately (the model invalidates the person's cached permissions) and
4
+ # goes through the model's callbacks, so each grant, change, review and
5
+ # revocation writes its audit event. config.admin_assignment_authorization
6
+ # is asked first (AssignmentAuthorization).
7
+ #
8
+ # @api private
9
+ class AssignmentsController < ApplicationController
10
+ include PermissionForge::AssignmentAuthorization
11
+ include PermissionForge::ActingAdmin
12
+
13
+ # What config.admin_assignment_authorization is asked for each member action.
14
+ CHANGE_ACTIONS = { update: :change_expiry, destroy: :revoke, keep: :review }.freeze
15
+
16
+ before_action :set_assignment, only: %i[update destroy confirm_destroy keep]
17
+ before_action :change_refused?, only: CHANGE_ACTIONS.keys # redirects when refused
18
+ # Removing a holder of a locked Muting Permission Set gives access back.
19
+ before_action :removal_refused?, only: %i[confirm_destroy destroy] # redirects when refused
20
+
21
+ def new
22
+ load_form
23
+ end
24
+
25
+ def create
26
+ load_form
27
+ @errors = validate_form
28
+ return render(:new, status: :unprocessable_entity) if @errors.any?
29
+
30
+ back = pf_back_or(permission_forge.person_path(@person))
31
+ return if pf_assignment_refused?(@chosen_sets, @person.id, :grant, back)
32
+
33
+ created = assign_chosen_sets
34
+ pf_redirect back, notice: assigned_notice(created), undo: assign_undo(created, back)
35
+ end
36
+
37
+ # Change the end date (presets) or extend by 30 days. On a locked
38
+ # Permission Set only a change that narrows access passes
39
+ # (EditableLock.widening_lock): for a granting set an end date set or
40
+ # moved earlier, for a Muting Permission Set one cleared or moved later.
41
+ def update
42
+ expires_at = new_expiry(params[:expiry].to_s)
43
+ return redirect_to(return_path) if expires_at == :invalid
44
+ return if widening_refused?(expires_at)
45
+
46
+ @assignment.update!(expires_at: expires_at)
47
+ redirect_to return_path, notice: end_changed_notice(expires_at)
48
+ end
49
+
50
+ def confirm_destroy
51
+ @lost = lost_labels
52
+ end
53
+
54
+ # Removing a holder of a granting set narrows access, also on a locked
55
+ # set; the toast's Undo (which grants again) is offered only where that
56
+ # is allowed (EditableLock.widening_lock).
57
+ def destroy
58
+ name = person_name(@assignment.user)
59
+ set_name = @assignment.permission_set.name
60
+ undo = restore_undo
61
+ @assignment.destroy!
62
+ pf_redirect return_path, notice: t('permission_forge.flash.assignments.removed', name: name, set: set_name), undo: undo
63
+ end
64
+
65
+ # Access review "Keep": records the reviewer and the date
66
+ # (PermissionSetUser#mark_reviewed!, one assignment.reviewed event);
67
+ # staleness is measured from the last review.
68
+ def keep
69
+ @assignment.mark_reviewed!(reviewer: pf_acting_admin_record)
70
+ redirect_to return_path, notice: t('permission_forge.flash.assignments.kept',
71
+ name: person_name(@assignment.user), set: @assignment.permission_set.name)
72
+ end
73
+
74
+ # Undo for "Remove": recreates the assignment.
75
+ def restore
76
+ expires_at = restored_expiry
77
+ set, user = restore_target
78
+ return if pf_widening_refused?(pf_widening_lock(set, :grant), return_path) || pf_assignment_refused?(set, user.id, :grant, return_path)
79
+
80
+ restore_assignment(set, user, expires_at)
81
+ redirect_to return_path, notice: t('permission_forge.flash.assignments.restored', name: person_name(user), set: set.name)
82
+ end
83
+
84
+ # Undo for "Assign": removes the assignments just created (narrows
85
+ # access, so also from a granting set locked since; a locked Muting
86
+ # Permission Set keeps its holders).
87
+ def undo
88
+ assignments = undo_assignments
89
+ return if undo_refused?(assignments)
90
+
91
+ PermissionForge::Audit.correlate { assignments.each(&:destroy!) }
92
+ redirect_to return_path, notice: t('permission_forge.flash.assignments.undone')
93
+ end
94
+
95
+ private
96
+
97
+ # An assignment of somebody config.user_list_resolver leaves out is not
98
+ # found: the admin cannot manage them through an assignment id either.
99
+ def set_assignment
100
+ @assignment = PermissionForge::PermissionSetUser.includes(:permission_set, :user).find(params[:id])
101
+ PermissionForge::Admin::PeopleDirectory.new.find(@assignment.user_id) || pf_not_found
102
+ end
103
+
104
+ def restore_undo
105
+ return if pf_widening_lock(@assignment.permission_set, :grant)
106
+
107
+ restore = { 'permission_set_id' => @assignment.permission_set_id, 'user_id' => @assignment.user_id,
108
+ 'expires_at' => @assignment.expires_at&.iso8601, 'return_to' => return_path }
109
+ { path: permission_forge.restore_assignments_path, method: 'post', params: restore }
110
+ end
111
+
112
+ # A locked set's end dates only move in the narrowing direction.
113
+ def widening_refused?(expires_at)
114
+ locked = pf_widening_lock(@assignment.permission_set, :change_expiry, from: @assignment.expires_at, to: expires_at)
115
+ pf_widening_refused?(locked, return_path)
116
+ end
117
+
118
+ def removal_refused?
119
+ pf_widening_refused?(pf_widening_lock(@assignment.permission_set, :revoke), return_path)
120
+ end
121
+
122
+ def change_refused?
123
+ pf_assignment_refused?(@assignment.permission_set, @assignment.user_id, CHANGE_ACTIONS.fetch(action_name.to_sym), return_path)
124
+ end
125
+
126
+ def restore_target
127
+ [PermissionForge::PermissionSet.find(params[:permission_set_id]),
128
+ PermissionForge::Admin::PeopleDirectory.new.find(params[:user_id]) || pf_not_found]
129
+ end
130
+
131
+ def restore_assignment(set, user, expires_at)
132
+ PermissionForge::PermissionSetUser.find_or_create_by!(permission_set: set, user: user) do |assignment|
133
+ assignment.expires_at = expires_at
134
+ end
135
+ end
136
+
137
+ def return_path
138
+ pf_back_or(@assignment ? permission_forge.person_path(@assignment.user_id) : permission_forge.people_path)
139
+ end
140
+
141
+ def person_name(user)
142
+ PermissionForge::Admin::PersonLabel.new(user).name
143
+ end
144
+
145
+ # All or nothing: one transaction (and one correlation id) for every chosen set.
146
+ def assign_chosen_sets
147
+ PermissionForge::PermissionSetUser.transaction do
148
+ @chosen_sets.map do |set|
149
+ PermissionForge::PermissionSetUser.create!(permission_set: set, user: @person, expires_at: @expires_at)
150
+ end
151
+ end
152
+ end
153
+
154
+ # Only assignments of people within config.user_list_resolver are
155
+ # undone (an id naming anyone else is not found).
156
+ def undo_assignments
157
+ assignments = PermissionForge::PermissionSetUser.where(id: pf_id_list(:ids)).includes(:permission_set).to_a
158
+ people = PermissionForge::Admin::PeopleDirectory.new.find_all(assignments.map(&:user_id)).map(&:id)
159
+ pf_not_found unless assignments.all? { |assignment| people.include?(assignment.user_id) }
160
+ assignments
161
+ end
162
+
163
+ def undo_refused?(assignments)
164
+ assignments.group_by(&:permission_set).any? do |set, rows|
165
+ pf_widening_refused?(pf_widening_lock(set, :revoke), return_path) || pf_assignment_refused?(set, rows.map(&:user_id), :revoke, return_path)
166
+ end
167
+ end
168
+
169
+ # --- assign form state ------------------------------------------------------
170
+
171
+ def load_form
172
+ load_person
173
+ load_sets
174
+ @people_counts = PermissionForge::PermissionSetUser.active_assignments.group(:permission_set_id).count
175
+ pf_load_expiry
176
+ @chosen_sets = @sets.select { |set| @selected_ids.include?(set.id) && !@existing_ids.include?(set.id) }
177
+ end
178
+
179
+ def load_person
180
+ @person = person_chosen? ? PermissionForge::Admin::PeopleDirectory.new.find(params[:user_id]) : nil
181
+ @query = params[:q].to_s.strip
182
+ @people = @person ? nil : PermissionForge::Admin::PeopleDirectory.new.pick(query: @query, limit: picker_limit)
183
+ @existing_ids = @person ? PermissionForge::PermissionSetUser.where(user_id: @person.id).pluck(:permission_set_id) : []
184
+ end
185
+
186
+ # A person was picked (and the form is not switching or searching people).
187
+ def person_chosen?
188
+ params[:user_id].present? && params[:change_person].blank? && params[:search].blank?
189
+ end
190
+
191
+ def load_sets
192
+ @set_query = pf_text_param(:set_q)
193
+ @preset_set = PermissionForge::PermissionSet.find_by(id: params[:permission_set_id])
194
+ @selected_ids = (pf_id_list(:set_ids) + [@preset_set&.id]).compact.uniq
195
+ @sets = assignable_sets
196
+ end
197
+
198
+ def picker_limit
199
+ @person ? 0 : 8
200
+ end
201
+
202
+ # The sets a holder can be added to: not those where that widens access
203
+ # on a locked set (a locked Muting Permission Set takes holders).
204
+ def assignable_sets
205
+ scope = PermissionForge::PermissionSet.order(:is_muting, :name)
206
+ unless @set_query.empty?
207
+ needle = "%#{ActiveRecord::Base.sanitize_sql_like(@set_query.downcase)}%"
208
+ scope = scope.where('LOWER(name) LIKE ?', needle).or(scope.where(id: @selected_ids))
209
+ end
210
+ scope.reject { |set| pf_widening_lock(set, :grant) }
211
+ end
212
+
213
+ def validate_form
214
+ errors = {}
215
+ errors[:person] = t('permission_forge.errors.admin.person') unless @person
216
+ errors[:sets] = t('permission_forge.errors.admin.sets') if @chosen_sets.empty?
217
+ expiry_error = pf_resolve_expiry
218
+ errors[:expiry] = expiry_error if expiry_error
219
+ errors
220
+ end
221
+
222
+ def assign_undo(created, back)
223
+ { path: permission_forge.undo_assignments_path, method: 'post',
224
+ params: { 'ids' => created.map(&:id), 'return_to' => back } }
225
+ end
226
+
227
+ def end_changed_notice(expires_at)
228
+ t('permission_forge.flash.assignments.end_changed', name: person_name(@assignment.user),
229
+ set: @assignment.permission_set.name,
230
+ when: helpers.pf_expiry(expires_at).first)
231
+ end
232
+
233
+ # A date that cannot be read is a 400: it must never restore the
234
+ # assignment without an end date.
235
+ def restored_expiry = pf_time_param(:expires_at)
236
+
237
+ def assigned_notice(created)
238
+ sets = created.map { |assignment| assignment.permission_set.name }.to_sentence
239
+ return t('permission_forge.flash.assignments.assigned', name: person_name(@person), sets: sets) if @expires_at.nil?
240
+
241
+ t('permission_forge.flash.assignments.assigned_until', name: person_name(@person), sets: sets,
242
+ date: helpers.pf_date(@expires_at, year: false))
243
+ end
244
+
245
+ # :invalid for a blank or unknown key (CHANGE_KEYS has no blank entry).
246
+ def new_expiry(key)
247
+ return extended_expiry if key == 'extend30'
248
+ return :invalid unless PermissionForge::Admin::ExpiryPresets::CHANGE_KEYS.include?(key)
249
+
250
+ PermissionForge::Admin::ExpiryPresets.expires_at_for(key)
251
+ end
252
+
253
+ def extended_expiry
254
+ base = [@assignment.expires_at&.to_date, Time.zone.today].compact.max
255
+ (base + 30).in_time_zone.end_of_day
256
+ end
257
+
258
+ # What the person stops seeing when this (granting) assignment is removed.
259
+ def lost_labels
260
+ permission_set = @assignment.permission_set
261
+ return [] if permission_set.is_muting
262
+
263
+ permission_set.resources.select(&:is_active).map { |r| helpers.pf_resource_plural(r.name) }
264
+ end
265
+ end
266
+ end