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,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ module ChangeSetParts
6
+ # Edits to one resource: label/description, then CRUD/scope flags with
7
+ # the Read/Edit cascades (skipped for muting sets — inverted semantics).
8
+ class ResourceEdit
9
+ FLAGS = %w[read_access create_access edit_access delete_access view_all modify_all is_active].freeze
10
+ TEXT = %w[label description].freeze
11
+ READ_DEPENDENT = %w[edit_access delete_access view_all modify_all].freeze
12
+
13
+ def initialize(resource, muting:)
14
+ @resource = resource
15
+ @muting = muting
16
+ end
17
+
18
+ def changes(attrs)
19
+ text_changes(attrs.slice(*TEXT)) + flag_changes(attrs.slice(*FLAGS).transform_values { |v| Values.truthy?(v) })
20
+ end
21
+
22
+ private
23
+
24
+ def text_changes(texts)
25
+ texts.filter_map { |attr, value| change(attr, value.to_s.strip.presence, kind: :resource_text).assign }
26
+ end
27
+
28
+ # A flag is "cascaded" when its new value differs from (or was not
29
+ # part of) the request.
30
+ def flag_changes(requested)
31
+ wanted = wanted_flags(requested)
32
+ FLAGS.filter_map do |flag|
33
+ to = wanted[flag]
34
+ change(flag, to, kind: :resource_flag, cascaded: requested.fetch(flag, :absent) != to).assign
35
+ end
36
+ end
37
+
38
+ def wanted_flags(requested)
39
+ wanted = FLAGS.to_h { |flag| [flag, requested.fetch(flag) { @resource.public_send(flag) }] }
40
+ cascade!(wanted) unless @muting
41
+ wanted
42
+ end
43
+
44
+ def cascade!(flags)
45
+ READ_DEPENDENT.each { |flag| flags[flag] = false } unless flags['read_access']
46
+ flags['modify_all'] = false unless flags['edit_access'] && flags['view_all']
47
+ end
48
+
49
+ def change(attribute, to, **attrs)
50
+ ChangeSet::Change.new(record: @resource, resource: @resource, attribute: attribute, to: to, **attrs)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ module ChangeSetParts
6
+ # Resource label/description and CRUD/scope flags (see ResourceEdit).
7
+ class ResourceFlags < Base
8
+ def call
9
+ section('resources').flat_map do |id, attrs|
10
+ resource = unlocked_resource(id)
11
+ next [] unless resource && attrs.is_a?(Hash)
12
+
13
+ ResourceEdit.new(resource, muting: muting?).changes(attrs)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ module ChangeSetParts
6
+ # Set header: name, description, active, group.
7
+ class SetAttrs < Base
8
+ SET_ATTRS = %w[name description is_active permission_set_group_id].freeze
9
+ # A group id that is neither blank (no group) nor digits.
10
+ NOT_A_GROUP = Object.new.freeze
11
+ TEXT_CAST = ->(value) { value.to_s.strip.presence if value.is_a?(String) || value.is_a?(Numeric) }
12
+ CASTS = {
13
+ 'is_active' => ->(value) { Values.truthy?(value) },
14
+ 'permission_set_group_id' => lambda { |value|
15
+ next nil if value.blank?
16
+
17
+ RecordId.cast(value) || NOT_A_GROUP
18
+ }
19
+ }.freeze
20
+
21
+ def call
22
+ section('set').slice(*SET_ATTRS).filter_map do |attr, value|
23
+ to = cast(attr, value)
24
+ next if to.equal?(NOT_A_GROUP)
25
+ next if attr == 'permission_set_group_id' && locked_group_move?(to)
26
+
27
+ assign_change(kind: :set_attr, record: permission_set, attribute: attr, to: to)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # A move into or out of a locked Permission Set Group is skipped
34
+ # (the controller refuses it first).
35
+ def locked_group_move?(group_id)
36
+ EditableLock.locked_group_for_move(from: permission_set.permission_set_group_id, to: group_id).present?
37
+ end
38
+
39
+ def cast(attr, value)
40
+ CASTS.fetch(attr, TEXT_CAST).call(value)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ module ChangeSetParts
6
+ # Stateless value rules shared by the ChangeSet collaborators.
7
+ module Values
8
+ def self.truthy?(value)
9
+ ActiveModel::Type::Boolean.new.cast(value) == true
10
+ end
11
+
12
+ # Edit on a resource without Edit is downgraded to Read (muting sets
13
+ # have inverted semantics, so Edit is always available there).
14
+ def self.effective_level(level, resource, muting:)
15
+ level == 'edit' && !(muting || resource.edit_access) ? 'read' : level
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # Creates ResourceFields for model columns and virtual fields at one level,
6
+ # applying privacy defaults: sensitive names start Masked when gdpr_defaults
7
+ # is on, and the muting 'hide_sensitive' level hides only sensitive names.
8
+ class ColumnFields
9
+ def initialize(resource, muting:, sensitive: SensitiveColumns.new, configuration: PermissionForge.configuration)
10
+ @resource = resource
11
+ @muting = muting
12
+ @sensitive = sensitive
13
+ @configuration = configuration
14
+ end
15
+
16
+ # @return [Integer] number of fields created (columns whose level resolves to nil are skipped)
17
+ def create(columns, level)
18
+ columns.filter_map { |column| create_field(column, level) }.size
19
+ end
20
+
21
+ private
22
+
23
+ def create_field(column, level)
24
+ match = @sensitive.match(column)
25
+ column_level = @muting ? muting_level(level, match) : granting_level(level, match)
26
+ return unless column_level
27
+
28
+ attrs = FieldLevel.flags_for(column_level, muting: @muting)
29
+ @resource.resource_fields.create!(attrs.merge(name: column, label: column.humanize, is_active: true, is_editable: true,
30
+ mask_strategy: match&.strategy || 'full'))
31
+ end
32
+
33
+ def muting_level(level, match)
34
+ return 'hide' if level == 'hide_sensitive' && match
35
+ return nil if %w[hide_sensitive leave].include?(level) || level.blank?
36
+
37
+ FieldLevel.valid?(level, muting: true) ? level : nil
38
+ end
39
+
40
+ def granting_level(level, match)
41
+ return nil unless FieldLevel.valid?(level, muting: false)
42
+ return 'masked' if match && %w[read edit].include?(level) && @configuration.gdpr_defaults
43
+
44
+ level
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+
5
+ module PermissionForge
6
+ module Admin
7
+ # Activation conditions form (Conditions tab): turns the posted IP list,
8
+ # hour window and session flag into PermissionSet attributes, collecting
9
+ # per-input errors (:ips => [messages], :flag => message).
10
+ class ConditionsForm
11
+ FLAG_FORMAT = /\A[\w.:-]{1,100}\z/
12
+
13
+ attr_reader :errors
14
+
15
+ # @param params [ActionController::Parameters, Hash]
16
+ def initialize(params)
17
+ @params = params
18
+ @errors = {}
19
+ end
20
+
21
+ # Evaluated in order, so errors are keyed :ips before :flag.
22
+ def attributes
23
+ @attributes ||= {
24
+ allowed_ips: ips.join(',').presence,
25
+ allowed_hours_start: hour(:allowed_hours_start),
26
+ allowed_hours_end: hour(:allowed_hours_end),
27
+ requires_session_flag: session_flag
28
+ }
29
+ end
30
+
31
+ private
32
+
33
+ def ips
34
+ list = @params[:allowed_ips].to_s.split(/[\n,]/).map(&:strip).reject(&:empty?)
35
+ bad = list.each_with_index.reject { |ip, _| valid_ip?(ip) }
36
+ @errors[:ips] = bad.map { |ip, i| I18n.t('permission_forge.errors.admin.ip', line: i + 1, ip: ip) } if bad.any?
37
+ list
38
+ end
39
+
40
+ public
41
+
42
+ # Whether the hour window was switched on in the posted form.
43
+ def hours_enabled?
44
+ @params[:hours_enabled] == '1'
45
+ end
46
+
47
+ # The hour posted for `key` as an Integer, or nil when it is not a
48
+ # number (blank, a Hash, an Array) — what the form shows again.
49
+ def submitted_hour(key)
50
+ RecordId.cast(@params[key])
51
+ end
52
+
53
+ private
54
+
55
+ # nil unless the hour window is enabled.
56
+ def hour(key)
57
+ submitted_hour(key) if hours_enabled?
58
+ end
59
+
60
+ def session_flag
61
+ custom = @params[:requires_session_flag_custom].to_s.strip
62
+ flag = custom.presence || @params[:requires_session_flag].to_s.strip
63
+ return flag.presence if flag.blank? || flag.match?(FLAG_FORMAT)
64
+
65
+ @errors[:flag] = I18n.t('permission_forge.errors.admin.flag')
66
+ nil
67
+ end
68
+
69
+ # IPAddr::Error is an ArgumentError.
70
+ def valid_ip?(value)
71
+ IPAddr.new(value)
72
+ true
73
+ rescue ArgumentError
74
+ false
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # The host app's controller actions, resolved the way the runtime guard
6
+ # resolves them (ControllerActions::Mapping) — so the admin lists exactly
7
+ # what the guard checks.
8
+ #
9
+ # Reads the route table once per instance. Routes without a static
10
+ # controller and action (mounted engines and Rack apps, redirects, dynamic
11
+ # segments), internal routes, excluded controllers and actions whose name
12
+ # cannot be a ResourceAction key are skipped. A
13
+ # controller that fails to load, or a host resolver that raises, drops
14
+ # that one entry instead of the whole catalog.
15
+ #
16
+ # @example
17
+ # catalog = ControllerCatalog.new
18
+ # catalog.keys_for('Customer') # => ['archive', 'export']
19
+ # catalog.for_resource('Customer') # => [#<struct Entry controller_path="customers", action_name="archive", ...>]
20
+ # catalog.any_guarded? # => true once a listed controller runs the guard
21
+ class ControllerCatalog
22
+ # Shown for a route that answers every HTTP verb.
23
+ ANY_VERB = 'ANY'
24
+ FORMAT_SUFFIX = '(.:format)'
25
+
26
+ # One mapped controller action. `key` is the action key the guard asks
27
+ # for; `crud` is :read / :create / :edit / :delete when the action means
28
+ # a CRUD permission, nil for a custom action. `guarded` is whether the
29
+ # controller runs the guard (ControllerActions.guarded?).
30
+ Entry = Struct.new(:controller_path, :action_name, :verbs, :paths, :resource, :key, :crud, :guarded,
31
+ keyword_init: true) do
32
+ def crud? = !crud.nil?
33
+ def guarded? = guarded == true
34
+
35
+ # 'customers#export'
36
+ def endpoint = "#{controller_path}##{action_name}"
37
+
38
+ # A custom (non-CRUD) action of the named resource.
39
+ def custom_for?(resource_name) = resource == resource_name && !crud?
40
+
41
+ def sort_key = [resource, key, controller_path, action_name]
42
+ end
43
+
44
+ # Reads one route: its static controller and action, verbs and path.
45
+ class RoutedAction
46
+ def initialize(route)
47
+ @route = route
48
+ end
49
+
50
+ # @return [Array(String, String), nil] [controller_path, action_name];
51
+ # nil for an internal route or one without a static controller and
52
+ # action (mounted apps, redirects, dynamic segments)
53
+ def target
54
+ return nil if @route.respond_to?(:internal) && @route.internal
55
+
56
+ parts = @route.defaults.values_at(:controller, :action)
57
+ parts.map(&:to_s) if parts.all? { |part| part.is_a?(String) || part.is_a?(Symbol) }
58
+ end
59
+
60
+ def verbs
61
+ verbs = @route.verb.to_s.split('|').reject(&:empty?)
62
+ verbs.empty? ? [ANY_VERB] : verbs
63
+ end
64
+
65
+ def path
66
+ @route.path.spec.to_s.delete_suffix(FORMAT_SUFFIX)
67
+ end
68
+
69
+ # Adds this route's verbs and path to its target's entry in `found`.
70
+ def merge_into(found)
71
+ merged = (found[target] ||= { verbs: [], paths: [] })
72
+ merged[:verbs] |= verbs
73
+ merged[:paths] |= [path]
74
+ end
75
+ end
76
+ private_constant :RoutedAction
77
+
78
+ def initialize(configuration: PermissionForge.configuration, routes: Rails.application.routes)
79
+ @configuration = configuration
80
+ @routes = routes
81
+ @controller_classes = {}
82
+ # controller_path => conventional resource, filled by Mapping: one
83
+ # lookup per controller instead of one per route.
84
+ @conventions = {}
85
+ end
86
+
87
+ # @return [Array<Entry>] one per mapped (controller_path, action_name),
88
+ # sorted by resource, key, controller and action
89
+ def entries
90
+ @entries ||= routed_actions.filter_map { |(path, action), route| build_entry(path, action, route) }
91
+ .sort_by(&:sort_key).freeze
92
+ end
93
+
94
+ # @param resource_name [String, Symbol] e.g. 'Customer'
95
+ # @return [Array<Entry>] the resource's custom (non-CRUD) entries, sorted by key
96
+ def for_resource(resource_name)
97
+ name = resource_name.to_s
98
+ entries.select { |entry| entry.custom_for?(name) }
99
+ end
100
+
101
+ # @param resource_name [String, Symbol]
102
+ # @return [Array<String>] unique custom action keys, sorted
103
+ def keys_for(resource_name)
104
+ for_resource(resource_name).map(&:key).uniq
105
+ end
106
+
107
+ # @return [Boolean] whether any listed controller runs the guard (see
108
+ # ControllerActions.guarded?). When false, permitting or blocking an
109
+ # action changes nothing at runtime.
110
+ def any_guarded?
111
+ entries.any?(&:guarded)
112
+ end
113
+
114
+ private
115
+
116
+ # { ['customers', 'export'] => { verbs: ['GET'], paths: ['/customers/export'] } }
117
+ def routed_actions
118
+ @routes.routes.each_with_object({}) do |route, found|
119
+ routed = RoutedAction.new(route)
120
+ routed.merge_into(found) if listed?(routed.target)
121
+ end
122
+ end
123
+
124
+ # Has a static target, and its controller is not excluded from discovery.
125
+ def listed?(target)
126
+ target && !@configuration.controller_excluded?(target.first)
127
+ end
128
+
129
+ def build_entry(path, action, route)
130
+ klass = controller_class(path)
131
+ target = ControllerActions::Mapping.call(controller_path: path, action_name: action, controller_class: klass,
132
+ configuration: @configuration, conventions: @conventions)
133
+ return nil unless target&.manageable?
134
+
135
+ Entry.new(controller_path: path, action_name: action, verbs: route[:verbs].sort, paths: route[:paths].sort,
136
+ resource: target.resource, key: target.action, crud: target.crud,
137
+ guarded: ControllerActions.guarded?(klass))
138
+ rescue StandardError => e
139
+ log_skipped("#{path}##{action}", e)
140
+ nil
141
+ end
142
+
143
+ # The controller's class, or nil when it does not exist or cannot be loaded.
144
+ def controller_class(path)
145
+ @controller_classes.fetch(path) { @controller_classes[path] = load_controller(path) }
146
+ end
147
+
148
+ # A controller file that fails to load must not take the catalog down.
149
+ def load_controller(path)
150
+ ControllerActions.controller_class_for(path)
151
+ rescue StandardError, ScriptError => e
152
+ log_skipped(path, e)
153
+ nil
154
+ end
155
+
156
+ # Logged once per catalog: a raising resolver would otherwise repeat the
157
+ # same line for every route.
158
+ def log_skipped(what, error)
159
+ return if @logged
160
+
161
+ @logged = true
162
+ Rails.logger&.warn("[PermissionForge] controller catalog skipped #{what}: #{error.class}: #{error.message}")
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # @api private
5
+ module Admin
6
+ # The one CSV writer of the admin exports (audit trail, audit log, who
7
+ # holds what): RFC 4180 quoting, CRLF line ends, and a leading apostrophe
8
+ # on any value a spreadsheet would run as a formula (CSV injection).
9
+ module Csv
10
+ # Characters that make Excel, LibreOffice or Google Sheets treat a cell
11
+ # as a formula (or a DDE call) when they open the file — also after
12
+ # leading spaces or line breaks, which some of them skip (and which the
13
+ # audit columns get when a leading tab is cleaned into a space).
14
+ FORMULA_START = /\A(?:[\t\r]|\s*[=+\-@])/
15
+ NEEDS_QUOTES = /[",\r\n]/
16
+ LINE_END = "\r\n"
17
+
18
+ module_function
19
+
20
+ # @param value [Object] anything; nil is an empty cell
21
+ # @return [String] the cell as written to the file
22
+ def cell(value)
23
+ text = value.to_s
24
+ text = "'#{text}" if text.match?(FORMULA_START)
25
+ text.match?(NEEDS_QUOTES) || text != text.strip ? %("#{text.gsub('"', '""')}") : text
26
+ end
27
+
28
+ # @param values [Array] the cells of one row
29
+ # @return [String] the row, with its line end
30
+ def line(values)
31
+ values.map { |value| cell(value) }.join(',') << LINE_END
32
+ end
33
+
34
+ # @param rows [Array<Array>]
35
+ # @return [String]
36
+ def document(rows)
37
+ rows.map { |row| line(row) }.join
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # The is_editable lock, walked up what a record belongs to: a record is
6
+ # locked when it, its Resource, its Permission Set, or (for a membership)
7
+ # its Permission Set Group has is_editable: false. The admin screens and
8
+ # the JSON API ask this one walk before every change, so a locked
9
+ # Permission Set gains and loses no resources, fields, rules, actions or
10
+ # holders through either surface, and a locked Resource keeps its fields,
11
+ # rules and actions.
12
+ #
13
+ # A set's group is not part of its chain: a locked group freezes its own
14
+ # name, its members and its list of sets (see .locked_group_for_move),
15
+ # not the content of the sets in it — as on the group page.
16
+ #
17
+ # The lock never blocks narrowing access, and always blocks widening it
18
+ # (.widening_lock). Which way a change to the holders goes depends on
19
+ # the set: a granting set gives its holders access, so removing a holder
20
+ # or ending their assignment sooner narrows, and adding a holder,
21
+ # restoring a removal, clearing or extending an end date widens. A
22
+ # Muting Permission Set takes access away from its holders, so each of
23
+ # those changes goes the other way: adding a holder or keeping them
24
+ # longer narrows, removing one or ending their assignment sooner widens.
25
+ #
26
+ # @example
27
+ # EditableLock.locked_record(field) # => the locked Resource or Permission Set, or nil
28
+ # EditableLock.chain(rule) # => [rule, resource, permission_set]
29
+ # EditableLock.widening_lock(set, :revoke) # => the set when it is locked and muting, else nil
30
+ module EditableLock
31
+ # What each record belongs to, for the lock: the first that is present.
32
+ PARENTS = {
33
+ 'PermissionForge::Resource' => %i[permission_set],
34
+ 'PermissionForge::ResourceField' => %i[resource],
35
+ 'PermissionForge::ResourceAction' => %i[resource],
36
+ 'PermissionForge::PermissionSetRule' => %i[resource permission_set],
37
+ 'PermissionForge::PermissionSetUser' => %i[permission_set],
38
+ 'PermissionForge::PermissionSetGroupUser' => %i[permission_set_group]
39
+ }.freeze
40
+
41
+ # What a record is listed in: deleting it changes that list, so a
42
+ # locked container refuses the deletion (a set in a locked group).
43
+ CONTAINERS = { 'PermissionForge::PermissionSet' => :permission_set_group }.freeze
44
+
45
+ module_function
46
+
47
+ # @param record [ActiveRecord::Base, nil]
48
+ # @return [Array<ActiveRecord::Base>] the record, then what it belongs to
49
+ def chain(record)
50
+ records = []
51
+ while record && records.none? { |seen| seen.equal?(record) }
52
+ records << record
53
+ record = parent_of(record)
54
+ end
55
+ records
56
+ end
57
+
58
+ # @param records [Array<ActiveRecord::Base, nil>]
59
+ # @return [ActiveRecord::Base, nil] the first locked record in their chains
60
+ def locked_record(*records)
61
+ records.flatten.compact.each do |record|
62
+ locked = chain(record).find { |link| locked?(link) }
63
+ return locked if locked
64
+ end
65
+ nil
66
+ end
67
+
68
+ # @return [Boolean] whether the record itself is flagged is_editable: false
69
+ def locked?(record)
70
+ record.respond_to?(:is_editable) && record.is_editable == false
71
+ end
72
+
73
+ # Moving a set from one Permission Set Group to another changes both
74
+ # groups' lists of sets: refused when either is locked (the group
75
+ # page's add/remove set refuses the same).
76
+ #
77
+ # @param from [Integer, nil] the group id the set has
78
+ # @param to [Integer, String, nil] the group id it would get
79
+ # @return [PermissionForge::PermissionSetGroup, nil] the locked group, if any
80
+ def locked_group_for_move(from:, to:)
81
+ to = RecordId.cast(to)
82
+ return nil if to == from
83
+
84
+ PermissionForge::PermissionSetGroup.where(id: [from, to].compact).detect { |group| locked?(group) }
85
+ end
86
+
87
+ # The container whose list deleting `record` would change, or nil.
88
+ #
89
+ # @return [ActiveRecord::Base, nil] e.g. a set's Permission Set Group
90
+ def container_of(record)
91
+ association = CONTAINERS[record.class.name]
92
+ association && record.public_send(association)
93
+ end
94
+
95
+ # How a change moves a person's holding of a set: +1 they hold it more
96
+ # (added, or kept longer), -1 less (removed, or ended sooner), 0 not at
97
+ # all (the same end date again).
98
+ HOLDING = {
99
+ grant: ->(**) { 1 },
100
+ revoke: ->(**) { -1 },
101
+ change_expiry: ->(from:, to:) { expiry_direction(from: from, to: to) }
102
+ }.freeze
103
+
104
+ # Whether a change to the holders of `permission_set` gives anybody
105
+ # more access: holding a granting set more, or a Muting Permission Set
106
+ # less.
107
+ #
108
+ # @param permission_set [PermissionForge::PermissionSet]
109
+ # @param change [Symbol] :grant, :revoke, or :change_expiry (with from:/to:)
110
+ # @param from [Time, nil] the end date the assignment has (:change_expiry)
111
+ # @param to [Time, nil] the one it would get (:change_expiry)
112
+ # @return [Boolean]
113
+ def widens_access?(permission_set, change, **expiry)
114
+ direction = HOLDING.fetch(change).call(**expiry)
115
+ permission_set.is_muting ? direction.negative? : direction.positive?
116
+ end
117
+
118
+ # The locked record that refuses a change to the holders of
119
+ # `permission_set`: the set itself when the change widens access
120
+ # (.widens_access?) and the set is locked; nil when the change may go
121
+ # ahead — a narrowing one always may.
122
+ #
123
+ # @return [PermissionForge::PermissionSet, nil]
124
+ def widening_lock(permission_set, change, **expiry)
125
+ return nil unless widens_access?(permission_set, change, **expiry)
126
+
127
+ locked_record(permission_set)
128
+ end
129
+
130
+ # +1 when the end date moves later or is cleared, -1 when it moves
131
+ # earlier or is set where there was none, 0 when it stays. Compared at
132
+ # the column's precision (microseconds): a preset computes its end with
133
+ # nanoseconds (end_of_day), the stored value has none, and choosing the
134
+ # same preset again is no change.
135
+ #
136
+ # @param from [Time, nil] the end date the assignment has
137
+ # @param to [Time, nil] the one it would get
138
+ # @return [Integer]
139
+ def expiry_direction(from:, to:)
140
+ return 0 if from.nil? && to.nil?
141
+ return 1 if to.nil?
142
+ return -1 if from.nil?
143
+
144
+ to.floor(6) <=> from.floor(6)
145
+ end
146
+
147
+ def parent_of(record)
148
+ PARENTS.fetch(record.class.name, []).each do |association|
149
+ parent = record.public_send(association)
150
+ return parent if parent
151
+ end
152
+ nil
153
+ end
154
+ private_class_method :parent_of
155
+ end
156
+ end
157
+ end