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,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # Uniform filter / search / paging over the people list, whether
6
+ # config.user_list returns an ActiveRecord relation (filtered in SQL) or an
7
+ # array (filtered in Ruby). Each operation returns a new candidates object.
8
+ module PeopleCandidates
9
+ def self.wrap(list, configuration:)
10
+ list.respond_to?(:where) ? Relation.new(list) : List.new(list, configuration)
11
+ end
12
+
13
+ # SQL-backed: searches whichever of the usual name/email columns exist.
14
+ class Relation
15
+ # Common person columns; only those the user table has are searched.
16
+ SEARCH_COLUMNS = %w[name email full_name display_name first_name last_name firstname lastname
17
+ username login].freeze
18
+ # A query of digits also matches the id — up to a bigint's length.
19
+ NUMERIC = /\A\d{1,18}\z/
20
+
21
+ def initialize(relation)
22
+ @relation = relation
23
+ end
24
+
25
+ def only(ids)
26
+ Relation.new(@relation.where(id: ids))
27
+ end
28
+
29
+ def excluding(ids)
30
+ Relation.new(@relation.where.not(id: ids))
31
+ end
32
+
33
+ def search(query)
34
+ columns = SEARCH_COLUMNS & @relation.klass.column_names
35
+ id = query.to_i if query.match?(NUMERIC)
36
+ return Relation.new(@relation.none) if columns.empty? && id.nil?
37
+
38
+ Relation.new(@relation.where(word_conditions(columns, query.downcase, id)))
39
+ end
40
+
41
+ def count
42
+ @relation.except(:order, :limit, :offset).count
43
+ end
44
+
45
+ def take(offset, limit)
46
+ @relation.offset(offset).limit(limit).to_a
47
+ end
48
+
49
+ private
50
+
51
+ # Every word must match some column, so "ana kras" finds Ana Krasniqi
52
+ # whether the name lives in one column or in first/last name columns.
53
+ # A numeric query also matches the id.
54
+ def word_conditions(columns, needle, id)
55
+ words = needle.split
56
+ matched = words.map { |word| any_column(columns, word) }.compact.reduce(:and)
57
+ return table[:id].eq(id) if matched.nil?
58
+
59
+ id ? matched.or(table[:id].eq(id)) : matched
60
+ end
61
+
62
+ def any_column(columns, word)
63
+ like = "%#{ActiveRecord::Base.sanitize_sql_like(word)}%"
64
+ columns.map { |column| table[column].lower.matches(like) }.reduce(:or)
65
+ end
66
+
67
+ def table
68
+ @relation.klass.arel_table
69
+ end
70
+ end
71
+
72
+ # Array-backed (custom resolvers): searches the person's display label.
73
+ class List
74
+ def initialize(people, configuration)
75
+ @people = people
76
+ @configuration = configuration
77
+ end
78
+
79
+ def only(ids)
80
+ List.new(@people.select { |u| ids.include?(u.id) }, @configuration)
81
+ end
82
+
83
+ def excluding(ids)
84
+ List.new(@people.reject { |u| ids.include?(u.id) }, @configuration)
85
+ end
86
+
87
+ def search(query)
88
+ needle = query.downcase
89
+ List.new(@people.select { |u| label_text(u).include?(needle) }, @configuration)
90
+ end
91
+
92
+ def count
93
+ @people.size
94
+ end
95
+
96
+ def take(offset, limit)
97
+ Array(@people)[offset, limit] || []
98
+ end
99
+
100
+ private
101
+
102
+ def label_text(user)
103
+ PersonLabel.new(user, configuration: @configuration).search_text
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # Searches and filters the people shown on the People page and in pickers.
6
+ # The candidate list comes from config.user_list_resolver (or
7
+ # user_model.order(:id)); search runs in SQL on whichever of the usual
8
+ # name/email columns exist, or in Ruby for array-returning resolvers
9
+ # (see PeopleCandidates).
10
+ class PeopleDirectory
11
+ FILTERS = %w[all ending expired muting no_access].freeze
12
+ ENDING_WINDOW_DAYS = 7
13
+ DEFAULT_PER_PAGE = 25
14
+ # Beyond this the offset overflows the database's integer; the page is
15
+ # empty long before.
16
+ MAX_PAGE = (2**31) - 1
17
+ # Filters that keep everyone *except* the matching people.
18
+ EXCLUDING_FILTERS = %w[no_access].freeze
19
+
20
+ # Assignments whose people a filter keeps ('no_access' keeps everyone else).
21
+ FILTER_ASSIGNMENTS = {
22
+ 'ending' => lambda {
23
+ PermissionForge::PermissionSetUser.where(expires_at: Time.current..ENDING_WINDOW_DAYS.days.from_now.end_of_day)
24
+ },
25
+ 'expired' => -> { PermissionForge::PermissionSetUser.expired },
26
+ 'muting' => lambda {
27
+ PermissionForge::PermissionSetUser.active_assignments.where(permission_set_id: PermissionForge::PermissionSet.muting.select(:id))
28
+ },
29
+ 'no_access' => lambda {
30
+ PermissionForge::PermissionSetUser.active_assignments.where(permission_set_id: PermissionForge::PermissionSet.active.select(:id))
31
+ }
32
+ }.freeze
33
+
34
+ Page = Struct.new(:people, :total, :page, :per_page, keyword_init: true) do
35
+ def more? = total > page * per_page
36
+ def shown = [total, page * per_page].min
37
+ end
38
+
39
+ def initialize(configuration: PermissionForge.configuration)
40
+ @configuration = configuration
41
+ end
42
+
43
+ # Typed or pasted text, as it is searched for people: without NUL bytes
44
+ # (the search builds quoted literals, and a NUL would cut the SQL
45
+ # statement short) and without surrounding white space. The one input
46
+ # boundary for people search and the bulk matcher.
47
+ #
48
+ # @return [String]
49
+ def self.search_text(text)
50
+ text.to_s.delete("\0").strip
51
+ end
52
+
53
+ # Paging options: page: (1-based, default 1), per_page: (default 25).
54
+ # @return [Page]
55
+ def search(query: nil, filter: 'all', **paging)
56
+ filter = 'all' unless FILTERS.include?(filter.to_s)
57
+ candidates = apply_query(apply_filter(base_candidates, filter.to_s), query)
58
+ paginate(candidates, paging[:page].to_s.to_i.clamp(1, MAX_PAGE), paging.fetch(:per_page, DEFAULT_PER_PAGE))
59
+ end
60
+
61
+ # Counts for the filter chips (respecting the search query).
62
+ def counts(query: nil)
63
+ base = apply_query(base_candidates, query)
64
+ FILTERS.index_with { |filter| apply_filter(base, filter).count }
65
+ end
66
+
67
+ # People matching a query, capped (for the person picker).
68
+ def pick(query:, limit: nil)
69
+ limit ||= @configuration.user_list_limit || 500
70
+ candidates = apply_query(base_candidates, query)
71
+ total = candidates.count
72
+ Page.new(people: candidates.take(0, limit), total: total, page: 1, per_page: limit)
73
+ end
74
+
75
+ # The person `id` names, looked up within config.user_list
76
+ # (user_list_resolver): the one lookup of a person by id for the admin
77
+ # and the API. Someone the resolver leaves out (another tenant's user,
78
+ # an account the host keeps out of the admin) is nobody here: nil,
79
+ # which callers answer with a 404 / "not found".
80
+ #
81
+ # @param id [String, Integer] anything else (a Hash, an Array) names nobody
82
+ # @return [Object, nil] the user
83
+ def find(id)
84
+ find_all([id]).first
85
+ end
86
+
87
+ # The people `ids` name within config.user_list; ids that name nobody
88
+ # there (or are not keys at all) are dropped.
89
+ #
90
+ # @param ids [Array, String, Integer]
91
+ # @return [Array] users
92
+ def find_all(ids)
93
+ ids = Ids.cast(ids, configuration: @configuration)
94
+ ids.empty? ? [] : base_candidates.only(ids).take(0, ids.size)
95
+ end
96
+
97
+ # Request values cast to the user model's primary-key type. Only a
98
+ # String or an Integer can name a person; for an integer key only
99
+ # digits do (the database would read '1abc' as 1), and never past a
100
+ # bigint. A NUL byte names nobody.
101
+ module Ids
102
+ INTEGER_KEY = /\A\d{1,19}\z/
103
+ MAX_INTEGER_KEY = (2**63) - 1
104
+
105
+ # @return [Array] distinct cast ids, in order
106
+ def self.cast(ids, configuration: PermissionForge.configuration)
107
+ model = configuration.user_model
108
+ type = model.type_for_attribute(model.primary_key)
109
+ (ids.is_a?(Array) ? ids : [ids]).filter_map { |id| cast_one(id, type) }.uniq
110
+ end
111
+
112
+ def self.cast_one(id, type)
113
+ return nil unless id.is_a?(String) || id.is_a?(Integer)
114
+
115
+ text = id.to_s.strip
116
+ return nil if text.empty? || text.include?("\0")
117
+ return nil if type.type == :integer && !integer_key?(text)
118
+
119
+ type.cast(text)
120
+ end
121
+
122
+ def self.integer_key?(text)
123
+ text.match?(INTEGER_KEY) && text.to_i <= MAX_INTEGER_KEY
124
+ end
125
+ private_class_method :cast_one, :integer_key?
126
+ end
127
+
128
+ private
129
+
130
+ # Memoized: an array-returning resolver is evaluated once per directory
131
+ # (the bulk matcher asks once per pasted line).
132
+ def base_candidates
133
+ @base_candidates ||= PeopleCandidates.wrap(@configuration.user_list, configuration: @configuration)
134
+ end
135
+
136
+ def apply_filter(candidates, filter)
137
+ ids = filter_ids(filter)
138
+ return candidates if ids.nil?
139
+
140
+ candidates.public_send(EXCLUDING_FILTERS.include?(filter) ? :excluding : :only, ids)
141
+ end
142
+
143
+ def filter_ids(filter)
144
+ FILTER_ASSIGNMENTS[filter]&.call&.select(:user_id)
145
+ end
146
+
147
+ def apply_query(candidates, query)
148
+ query = self.class.search_text(query)
149
+ query.empty? ? candidates : candidates.search(query)
150
+ end
151
+
152
+ def paginate(candidates, page, per_page)
153
+ Page.new(people: candidates.take((page - 1) * per_page, per_page), total: candidates.count, page: page,
154
+ per_page: per_page)
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # Creates Permission Sets from the admin UI: fresh, started from another
6
+ # set, or duplicated. Copies are deep (resources, fields, actions, rules,
7
+ # conditions) — never assignments — with every copied record unlocked.
8
+ module PermissionSetFactory
9
+ # @param attrs [Hash] name:, is_muting:, permission_set_group:
10
+ # @param source [PermissionSet, nil] set to start from
11
+ def self.create(attrs, source: nil)
12
+ return clone_unlocked(source, attrs.merge(is_active: true)) if source
13
+
14
+ PermissionForge::PermissionSet.create!(attrs.merge(is_active: true, is_editable: true))
15
+ end
16
+
17
+ # An editable copy. A source in a locked Permission Set Group is copied
18
+ # outside it: the copy would change the locked group's list of sets.
19
+ def self.duplicate(source, name:)
20
+ attrs = { name: name, is_editable: true }
21
+ attrs[:permission_set_group] = nil if EditableLock.locked?(source.permission_set_group)
22
+ clone_unlocked(source, attrs)
23
+ end
24
+
25
+ # Case-insensitive.
26
+ def self.name_taken?(name)
27
+ PermissionForge::PermissionSet.where('LOWER(name) = ?', name.downcase).exists?
28
+ end
29
+
30
+ # `base`, else "`base` 2", "`base` 3", ... — the first free name.
31
+ def self.unique_name(base)
32
+ candidate = base
33
+ suffix = 2
34
+ while name_taken?(candidate)
35
+ candidate = "#{base} #{suffix}"
36
+ suffix += 1
37
+ end
38
+ candidate
39
+ end
40
+
41
+ # Every copied row is created unlocked, so its create event holds the
42
+ # state it has (no second, unaudited write).
43
+ def self.clone_unlocked(source, attrs)
44
+ PermissionForge::Services::PermissionSetCloner.call(permission_set: source, attrs: attrs.merge(is_editable: true), unlock: true)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # Display name / email / initials for a host-app user in the admin UI.
6
+ #
7
+ # Honors config.user_label_formatter for the name. Otherwise tries :name,
8
+ # :full_name, :display_name, then composes first_name/last_name (or firstname/lastname). Attribute
9
+ # reads go raw-column-first (read_attribute_before_type_cast) so
10
+ # MaskedAttributes on the User model never garbles admin lists; virtual
11
+ # attributes and blank columns fall back to the public reader.
12
+ class PersonLabel
13
+ NAME_METHODS = %i[name full_name display_name].freeze
14
+
15
+ def initialize(user, configuration: PermissionForge.configuration)
16
+ @user = user
17
+ @configuration = configuration
18
+ end
19
+
20
+ def name
21
+ @name ||= formatted_name || default_name || email || fallback
22
+ end
23
+
24
+ def email
25
+ @email ||= raw_value(:email).to_s.strip.presence
26
+ end
27
+
28
+ # Email shown under the name — omitted when the name already is the email.
29
+ def secondary
30
+ email unless email == name
31
+ end
32
+
33
+ def initials
34
+ initial_letters(name.to_s.scan(/[[:alnum:]]+/)).join.upcase.presence || '?'
35
+ end
36
+
37
+ # Lower-cased "name email" for in-memory search.
38
+ def search_text
39
+ "#{name} #{email}".downcase
40
+ end
41
+
42
+ def first_name
43
+ name.to_s.split(/\s+/).first.presence || name
44
+ end
45
+
46
+ private
47
+
48
+ # First letters of the first and last word, or the first two letters of a single word.
49
+ def initial_letters(words)
50
+ words.size > 1 ? [words.first[0], words.last[0]] : [words.first.to_s[0, 2]]
51
+ end
52
+
53
+ def formatted_name
54
+ formatter = @configuration.user_label_formatter
55
+ return nil unless formatter
56
+
57
+ formatter.call(@user).to_s.strip.presence
58
+ end
59
+
60
+ def default_name
61
+ NAME_METHODS.each do |method|
62
+ value = raw_value(method).to_s.strip
63
+ return value unless value.empty?
64
+ end
65
+ composed(:first_name, :last_name) || composed(:firstname, :lastname)
66
+ end
67
+
68
+ def composed(first, last)
69
+ [raw_value(first), raw_value(last)].compact.map { |part| part.to_s.strip }.reject(&:empty?).join(' ').presence
70
+ end
71
+
72
+ def fallback
73
+ I18n.t('permission_forge.shared.user_fallback', id: @user.id)
74
+ end
75
+
76
+ def raw_value(attr)
77
+ attr_name = attr.to_s
78
+ if readable_column?(attr_name)
79
+ value = @user.read_attribute_before_type_cast(attr_name)
80
+ return value if value.to_s.strip.present?
81
+ end
82
+ @user.try(attr)
83
+ end
84
+
85
+ # True when attr_name is a loaded column on an AR-like user (guards
86
+ # partial SELECTs from a custom user_list_resolver).
87
+ def readable_column?(attr_name)
88
+ klass = @user.class
89
+ klass.respond_to?(:column_names) && klass.column_names.include?(attr_name) &&
90
+ @user.respond_to?(:read_attribute_before_type_cast) &&
91
+ (!@user.respond_to?(:has_attribute?) || @user.has_attribute?(attr_name))
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # The id of a PermissionForge record, from a request value: an Integer, or
6
+ # nil for anything that is not one — blank, a Hash, an Array, text, or more
7
+ # digits than a bigint holds (the database would raise, or read '1abc' as 1).
8
+ module RecordId
9
+ FORMAT = /\A\d{1,18}\z/
10
+
11
+ # @param value [Object]
12
+ # @return [Integer, nil]
13
+ def self.cast(value)
14
+ text = value.to_s.strip if value.is_a?(String) || value.is_a?(Integer)
15
+ text.to_i if text&.match?(FORMAT)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # What the rule builder page (permission_set_rules/_builder) decides beyond
6
+ # the RuleForm's own state: which notes show, which option is selected in
7
+ # each select, and how the "how to combine" choices read. The template only
8
+ # draws it.
9
+ #
10
+ # @example
11
+ # builder = RuleBuilder.new(form: form, resource: resource, model_errors: [])
12
+ # builder.ignored_warning? # => false
13
+ # builder.join_choices.map(&:join) # => ['auto', 'joins', 'left_joins']
14
+ class RuleBuilder
15
+ # join: the value; checked: the stored choice; auto: the one that is
16
+ # automatic (its title is named in the "auto" description).
17
+ JoinChoice = Struct.new(:join, :checked, :auto, keyword_init: true) do
18
+ def auto? = join == 'auto'
19
+ end
20
+
21
+ attr_reader :form, :resource, :model_errors
22
+
23
+ def initialize(form:, resource:, model_errors: [], configuration: PermissionForge.configuration)
24
+ @form = form
25
+ @resource = resource
26
+ @model_errors = Array(model_errors)
27
+ @configuration = configuration
28
+ end
29
+
30
+ def errors = form.errors
31
+ def error_codes = errors.values
32
+
33
+ def steps = form.path_steps
34
+ def target = form.target_model
35
+
36
+ # A Resource that sees everything makes every rule moot.
37
+ def ignored_warning? = resource.view_all ? true : false
38
+
39
+ # The "follow a relationship" select: only while the target has any.
40
+ def follow_options = target ? target.associations : []
41
+ def follow? = follow_options.any?
42
+
43
+ def model_missing? = target.nil?
44
+
45
+ # Open the advanced panel when the join is not the automatic one.
46
+ def advanced_open? = form.join != 'auto'
47
+
48
+ def join_choices
49
+ RuleForm::JOINS.map { |join| JoinChoice.new(join: join, checked: form.join == join, auto: form.auto_join) }
50
+ end
51
+
52
+ def scoping_on? = @configuration.auto_query_scoping.to_s == 'filter_only'
53
+
54
+ def field_selected?(column) = column.name == form.field_name
55
+ def operator_selected?(operator) = operator == form.operator
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # Form object behind the rule builder: turns the sentence-style inputs
6
+ # (relationship path, field, operator, adaptive value, "the current user",
7
+ # include empty, join choice) into PermissionSetRule attributes, and lists
8
+ # the choices offered at each step from model reflection.
9
+ class RuleForm
10
+ ARITY = {
11
+ 'equal' => :one, 'not_equal' => :one, 'less_than' => :one, 'greater_than' => :one,
12
+ 'less_than_or_equal' => :one, 'greater_than_or_equal' => :one, 'in' => :list, 'not_in' => :list,
13
+ 'contains' => :one, 'not_contains' => :one, 'starts_with' => :one, 'ends_with' => :one,
14
+ 'between' => :range, 'is_null' => :none, 'is_not_null' => :none
15
+ }.freeze
16
+ USER_OPERATORS = %w[equal not_equal in not_in is_null is_not_null].freeze
17
+ NULLABLE = %w[equal not_equal in not_in].freeze
18
+ JOINS = %w[auto joins left_joins].freeze
19
+ CURRENT_USER = 'current_user'
20
+ VALUE_ERRORS = { one: :value, list: :list, range: :range }.freeze
21
+
22
+ attr_reader :name, :path, :field_name, :operator, :value, :values, :value_from, :value_to,
23
+ :current_user, :include_nulls, :failure_message, :join, :is_active, :errors
24
+
25
+ def initialize(resource:, params: nil, rule: nil, catalog: ModelCatalog.new)
26
+ @resource = resource
27
+ @catalog = catalog
28
+ @errors = {}
29
+ if params
30
+ assign_inputs(RuleFormInput.from_params(normalize(params)))
31
+ @operator = 'equal' unless operators.include?(@operator)
32
+ else
33
+ assign_inputs(RuleFormInput.from_rule(rule))
34
+ end
35
+ end
36
+
37
+ # --- choices ----------------------------------------------------------------
38
+
39
+ def root_model
40
+ @root_model ||= @catalog.find(@resource.name)
41
+ end
42
+
43
+ # [[ModelInfo, Association selected or nil], ...] for each path step, plus
44
+ # the model the field is read from.
45
+ def path_steps
46
+ model = root_model
47
+ steps = []
48
+ @path.each do |assoc_name|
49
+ break unless model
50
+
51
+ assoc = model.association(assoc_name)
52
+ steps << [model, assoc]
53
+ model = assoc && @catalog.find(assoc.class_name)
54
+ end
55
+ @target_model = model
56
+ steps
57
+ end
58
+
59
+ def target_model
60
+ path_steps unless defined?(@target_model)
61
+ @target_model
62
+ end
63
+
64
+ def field_options
65
+ target_model ? target_model.columns.reject(&:virtual?) : []
66
+ end
67
+
68
+ def column
69
+ field_options.find { |option| option.name == @field_name }
70
+ end
71
+
72
+ def operators
73
+ column&.user_reference? ? USER_OPERATORS : ARITY.keys
74
+ end
75
+
76
+ def arity
77
+ ARITY.fetch(@operator, :one)
78
+ end
79
+
80
+ def nullable?
81
+ NULLABLE.include?(@operator)
82
+ end
83
+
84
+ def user_field?
85
+ column&.user_reference? || false
86
+ end
87
+
88
+ def enum_values
89
+ column&.enum_values
90
+ end
91
+
92
+ def input_type
93
+ case column&.type
94
+ when :integer, :decimal, :float, :bigint then 'number'
95
+ when :date then 'date'
96
+ when :datetime, :timestamp then 'datetime-local'
97
+ else 'text'
98
+ end
99
+ end
100
+
101
+ def follows_relationship?
102
+ @path.any?
103
+ end
104
+
105
+ # --- persistence --------------------------------------------------------------
106
+
107
+ def valid?
108
+ @errors = {}
109
+ @errors[:name] = :name_blank if @name.blank?
110
+ @errors[:field] = :field if column.nil?
111
+ validate_value
112
+ @errors.empty?
113
+ end
114
+
115
+ def attributes
116
+ {
117
+ name: @name, field_name: @field_name, operator: @operator, field_value: field_value,
118
+ field_accessor_path: @path.any? ? @path.join('.') : nil, join_type: join_type,
119
+ include_nulls: nullable? && @include_nulls, failure_message: @failure_message.presence,
120
+ is_active: @is_active
121
+ }
122
+ end
123
+
124
+ def field_value
125
+ case arity
126
+ when :none then nil
127
+ when :list then @values.join(',')
128
+ when :range then [@value_from, @value_to].join(',')
129
+ else @current_user ? CURRENT_USER : @value
130
+ end
131
+ end
132
+
133
+ # Rules without a relationship are filter rules (join_type nil).
134
+ def join_type
135
+ return nil if @path.empty?
136
+ return @join if %w[joins left_joins].include?(@join)
137
+
138
+ @include_nulls ? 'left_joins' : 'joins'
139
+ end
140
+
141
+ def auto_join
142
+ @include_nulls ? 'left_joins' : 'joins'
143
+ end
144
+
145
+ # Unsaved rule for previews.
146
+ def preview_rule
147
+ PermissionForge::PermissionSetRule.new(attributes.merge(resource: @resource))
148
+ end
149
+
150
+ private
151
+
152
+ def normalize(params)
153
+ (params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h).deep_stringify_keys
154
+ end
155
+
156
+ # Sets the reader ivars (@name, @path, ...) from RuleFormInput.
157
+ def assign_inputs(inputs)
158
+ inputs.each { |key, value| instance_variable_set(:"@#{key}", value) }
159
+ end
160
+
161
+ def validate_value
162
+ @errors[:value] = VALUE_ERRORS[arity] if value_missing?
163
+ end
164
+
165
+ def value_missing?
166
+ case arity
167
+ when :one then !@current_user && @value.blank?
168
+ when :list then @values.empty?
169
+ when :range then @value_from.blank? || @value_to.blank?
170
+ else false
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end