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,305 @@
1
+ module PermissionForge
2
+ module Services
3
+ # Builds a merged permission hash for a user across multiple permission sets.
4
+ # Uses most-permissive-wins logic for CRUD/boolean flags and AND logic for masks.
5
+ # Muting permission sets subtract permissions after granting sets are merged.
6
+ # Field rows are merged by {FieldPermissionMerger}; the implicit entries of
7
+ # extra (virtual) fields are added by {ExtraFieldDefaults}.
8
+ #
9
+ # Every row a build reads is loaded up front, one query per table
10
+ # (#load_permission_rows): the sets' resources with their fields, actions
11
+ # and rules, active or not, and the user's assignment of each set with
12
+ # the people who granted and last changed it. Inactive rows are skipped in
13
+ # Ruby (`select(&:is_active)`) — the `active` scope would query again, per
14
+ # resource. Each holder of a set rebuilds after any edit to it, so a
15
+ # build must not cost a query per resource.
16
+ #
17
+ # @example
18
+ # data = PermissionHashBuilder.call(user: user, permission_sets: user.permission_sets.active)
19
+ # # => { resources: { Customer: { permissions: { read_access: true, ... }, fields: {...}, ... } } }
20
+ #
21
+ # @api public
22
+ class PermissionHashBuilder
23
+ RESOURCE_PERMISSION_OPTIONS = %i[read_access create_access edit_access delete_access view_all modify_all].freeze
24
+ # The rows each resource brings into the hash (#load_permission_rows).
25
+ RESOURCE_ROWS = %i[resource_fields resource_actions permission_set_rules].freeze
26
+ # The columns naming who granted and last changed an assignment (HasAuditTrail).
27
+ GRANTOR_KEYS = %w[created_by_id updated_by_id].freeze
28
+
29
+ # Resources and their rows in id order, in place. The database returns
30
+ # them in whatever order its plan gives, and order decides: the first row
31
+ # of a name wins a tie (two fields mapping to one variant — "first
32
+ # mask_strategy wins"), and rules and actions keep it in the hash. The one
33
+ # definition: Admin::EffectiveAccess sorts the rows it shows with it. An
34
+ # unsaved row (no id yet) sorts first.
35
+ def self.sort_rows(permission_set)
36
+ resources = sorted(permission_set.association(:resources))
37
+ resources.each { |resource| RESOURCE_ROWS.each { |rows| sorted(resource.association(rows)) } }
38
+ end
39
+
40
+ def self.sorted(association)
41
+ association.target.sort_by! { |row| row.id.to_i }
42
+ end
43
+ private_class_method :sorted
44
+
45
+ # Builds a user's permission hash from their permission sets.
46
+ # @param user [Object]
47
+ # @param permission_sets [Enumerable<PermissionSet>]
48
+ # @return [Hash]
49
+ def self.call(user:, permission_sets:)
50
+ new(user: user, permission_sets: permission_sets).call
51
+ end
52
+
53
+ def initialize(user:, permission_sets:)
54
+ @user = user
55
+ @permission_sets = permission_sets
56
+ end
57
+
58
+ # @return [Hash] the permission hash (the format is in the architecture guide)
59
+ def call
60
+ data = { resources: {} }
61
+ load_permission_rows
62
+
63
+ granting_sets, muting_sets = partition_permission_sets
64
+
65
+ granting_sets.each { |ps| process_permission_set(data, ps) }
66
+ add_extra_field_defaults(data)
67
+ muting_sets.each { |ps| apply_muting_permission_set(data, ps) }
68
+
69
+ data
70
+ end
71
+
72
+ private
73
+
74
+ def partition_permission_sets
75
+ @permission_sets.partition { |ps| !ps.is_muting }
76
+ end
77
+
78
+ # --- Loading (every row of the build, one query per table) ---
79
+
80
+ # The sets in the order given (created_at, id: "first mask_strategy
81
+ # wins" depends on it), each with its resources and their rows loaded.
82
+ def load_permission_rows
83
+ @permission_sets = @permission_sets.to_a
84
+ preload(@permission_sets, resources: RESOURCE_ROWS)
85
+ @permission_sets.each { |permission_set| self.class.sort_rows(permission_set) }
86
+ @assignments = assignments_by_set
87
+ end
88
+
89
+ # The user's assignment of each set (any row of the pair, as find_by
90
+ # would answer), with its grantors.
91
+ def assignments_by_set
92
+ return {} if @permission_sets.empty?
93
+
94
+ assignments = PermissionForge::PermissionSetUser.where(user_id: @user.id, permission_set_id: @permission_sets.map(&:id)).to_a
95
+ preload_grantors(assignments)
96
+ assignments.each_with_object({}) { |assignment, by_set| by_set[assignment.permission_set_id] ||= assignment }
97
+ end
98
+
99
+ # Only where an assignment names one: preloading builds the user
100
+ # model's scope — its default scopes run — even for no id at all, which
101
+ # the association readers never did.
102
+ def preload_grantors(assignments)
103
+ stamped = assignments.select { |assignment| GRANTOR_KEYS.any? { |key| assignment[key] } }
104
+ preload(stamped, %i[created_by updated_by]) if stamped.any?
105
+ end
106
+
107
+ def preload(records, associations)
108
+ ActiveRecord::Associations::Preloader.new(records: records, associations: associations).call
109
+ end
110
+
111
+ # --- Granting logic (additive, most-permissive-wins) ---
112
+
113
+ def process_permission_set(data, permission_set)
114
+ permission_set.resources.select(&:is_active).each do |resource|
115
+ process_resource(data, permission_set, resource)
116
+ end
117
+ end
118
+
119
+ def process_resource(data, permission_set, resource)
120
+ return unless resource.is_active
121
+
122
+ resource_data = init_resource_data(data, resource.name)
123
+
124
+ add_source_permission_set(resource_data[:source_permission_sets], permission_set)
125
+ merge_resource_permissions(resource_data[:permissions], resource)
126
+ FieldPermissionMerger.call(fields: resource_data[:fields], resource: resource)
127
+ merge_resource_actions(resource_data[:actions], resource)
128
+ merge_permission_set_rules(resource_data[:permission_set_rules], resource)
129
+ end
130
+
131
+ # Once per resource, after every granting set and before muting: a
132
+ # ResourceField row in any of the user's sets wins over the implicit
133
+ # grant, whichever set is older, and the grant follows the resource's
134
+ # merged read and edit.
135
+ def add_extra_field_defaults(data)
136
+ data[:resources].each do |name, resource_data|
137
+ ExtraFieldDefaults.call(entry: resource_data, resource_name: name)
138
+ end
139
+ end
140
+
141
+ def init_resource_data(data, name)
142
+ data[:resources][name] ||= {
143
+ permissions: {},
144
+ fields: {},
145
+ actions: {},
146
+ source_permission_sets: [],
147
+ permission_set_rules: []
148
+ }
149
+ end
150
+
151
+ def add_source_permission_set(source_permission_sets, permission_set)
152
+ permission_set_user = @assignments[permission_set.id]
153
+
154
+ source_permission_sets << {
155
+ uuid: permission_set.uuid,
156
+ name: permission_set.name,
157
+ is_muting: permission_set.is_muting,
158
+ permission_set_granted_by: user_reference(permission_set_user&.created_by),
159
+ permission_set_granted_updated_by: user_reference(permission_set_user&.updated_by),
160
+ permission_set_granted_at: permission_set_user&.created_at,
161
+ permission_set_granted_updated_at: permission_set_user&.updated_at
162
+ }
163
+ end
164
+
165
+ # Who granted or last changed an assignment: the person's uuid when the
166
+ # audit user model has one, else their id. Nothing asks a host user
167
+ # model for a uuid column.
168
+ def user_reference(user)
169
+ return unless user
170
+
171
+ user.respond_to?(:uuid) ? user.uuid : user.id
172
+ end
173
+
174
+ def merge_resource_permissions(permissions, resource)
175
+ RESOURCE_PERMISSION_OPTIONS.each do |action|
176
+ permissions[action] ||= resource.send(action)
177
+ end
178
+ end
179
+
180
+ def merge_resource_actions(actions, resource)
181
+ resource.resource_actions.select(&:is_active).each do |action|
182
+ key = action.name.to_sym
183
+ if actions.key?(key)
184
+ actions[key][:is_permitted] ||= action.is_permitted
185
+ else
186
+ actions[key] = { is_permitted: action.is_permitted }
187
+ end
188
+ end
189
+ end
190
+
191
+ def merge_permission_set_rules(permission_set_rules, resource)
192
+ resource.permission_set_rules.select(&:is_active).each do |rule|
193
+ permission_set_rules << {
194
+ name: rule.name,
195
+ failure_message: rule.failure_message,
196
+ join_type: rule.join_type,
197
+ field_name: rule.field_name,
198
+ field_value: rule.field_value,
199
+ field_accessor_path: rule.field_accessor_path,
200
+ operator: rule.operator,
201
+ include_nulls: rule.include_nulls
202
+ }
203
+ end
204
+ end
205
+
206
+ # --- Muting logic (subtractive, deny overrides allow) ---
207
+
208
+ # A muting resource reaches every entry whose resource chain names it
209
+ # (ResourceChain): the entry of its own name and, when it names a
210
+ # model, the entries of that model's STI subclasses. A subclass can
211
+ # only be narrower than its base, and the CRUD flags, the bypasses and
212
+ # the actions are read from one entry (can_*?, the controller guard):
213
+ # a muted base that left the subclass entry whole let the subclass
214
+ # controller destroy the row the admin believed muted, while the rows
215
+ # and fields, composed along the chain, were already closed. A muting
216
+ # row naming a subclass reaches that subclass alone.
217
+ def apply_muting_permission_set(data, permission_set)
218
+ permission_set.resources.select(&:is_active).each do |resource|
219
+ entries_under(data, resource.name).each do |resource_data|
220
+ apply_muting_resource(resource_data, permission_set, resource)
221
+ end
222
+ end
223
+ end
224
+
225
+ # The entries whose chain includes the resource name, in hash order;
226
+ # each entry's chain is worked out once per build.
227
+ def entries_under(data, name)
228
+ muted = name.to_sym
229
+ @entry_chains ||= {}
230
+ data[:resources].filter_map do |entry_name, resource_data|
231
+ chain = (@entry_chains[entry_name] ||= PermissionForge::ResourceChain.names_for(entry_name))
232
+ resource_data if chain.include?(muted)
233
+ end
234
+ end
235
+
236
+ # The set is listed once per entry: one with rows for a base and its
237
+ # subclass reaches the subclass entry twice.
238
+ def apply_muting_resource(resource_data, permission_set, resource)
239
+ sources = resource_data[:source_permission_sets]
240
+ add_source_permission_set(sources, permission_set) if sources.none? { |source| source[:uuid] == permission_set.uuid }
241
+ mute_resource_permissions(resource_data[:permissions], resource)
242
+ mute_field_permissions(resource_data[:fields], resource)
243
+ mute_fields_of_resource(resource_data[:fields], resource)
244
+ mute_resource_actions(resource_data[:actions], resource)
245
+ end
246
+
247
+ def mute_resource_permissions(permissions, resource)
248
+ RESOURCE_PERMISSION_OPTIONS.each do |action|
249
+ permissions[action] = false if resource.send(action)
250
+ end
251
+ end
252
+
253
+ # A muting set that revokes the resource's read revokes read and edit on
254
+ # every field of the resource; variants and implicit extra fields are
255
+ # entries too. Field enforcement (getters, as_json, the filters) reads
256
+ # the field entries only, so muting the resource used to protect less
257
+ # than never granting it. Read only: PermitsAttributes filters creates
258
+ # too, so the same cascade on edit would block a user who may create but
259
+ # not change ("can submit, cannot edit afterwards"). Granting sets are
260
+ # left to their field rows, as before.
261
+ def mute_fields_of_resource(fields, resource)
262
+ return unless resource.read_access
263
+
264
+ fields.each_value do |entry|
265
+ entry[:read_access] = false
266
+ entry[:edit_access] = false
267
+ end
268
+ end
269
+
270
+ def mute_field_permissions(fields, resource)
271
+ resource.resource_fields.select(&:is_active).each do |field|
272
+ next unless field.is_active
273
+
274
+ # Mute the base field AND every configured variant (_uuid, base name for
275
+ # _cents, etc.), matching the variants the granting phase writes — otherwise
276
+ # a muting set that revokes `user_id` would leave `user_uuid` readable.
277
+ mute_single_field(fields, field.name, field)
278
+ PermissionForge.configuration.field_variants_for(field.name).each do |variant|
279
+ mute_single_field(fields, variant, field)
280
+ end
281
+ end
282
+ end
283
+
284
+ # A row that mutes read mutes edit too: a field nobody may read is not
285
+ # one they may write (the admin UI's Hide writes both flags; the API
286
+ # and code can write read alone, which left the field writable through
287
+ # PermitsAttributes). Admin::EffectiveFields#mute_field says the same.
288
+ def mute_single_field(fields, field_name, field)
289
+ entry = fields[field_name]
290
+ return unless entry
291
+
292
+ entry[:read_access] = false if field.read_access
293
+ entry[:edit_access] = false if field.read_access || field.edit_access
294
+ entry[:mask_access] = true if field.mask_access
295
+ end
296
+
297
+ def mute_resource_actions(actions, resource)
298
+ resource.resource_actions.select(&:is_active).each do |action|
299
+ key = action.name.to_sym
300
+ actions[key][:is_permitted] = false if actions.key?(key) && action.is_permitted
301
+ end
302
+ end
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,192 @@
1
+ module PermissionForge
2
+ module Services
3
+ # Applies row-level permission rules to build a scoped ActiveRecord query.
4
+ # Extracted from PermissionScopedFindable for SRP and testability.
5
+ #
6
+ # Column references are quoted with the connection's real identifier quoting
7
+ # (quote_table_name / quote_column_name) rather than string interpolation, so
8
+ # a malformed field_name can never reach SQL as a literal even if a rule was
9
+ # created through a path that bypasses model validation.
10
+ #
11
+ # @api private
12
+ class PermissionRuleApplier
13
+ # join_type => the relation method that makes the join. The model
14
+ # validates join_type, but a rule row can be written past the model (the
15
+ # tables are shared with other applications): the value is looked up
16
+ # here, never sent to the relation, and anything else raises.
17
+ JOIN_METHODS = {
18
+ PermissionForge::Operators::Constants::JOINS => :joins,
19
+ PermissionForge::Operators::Constants::LEFT_JOINS => :left_joins,
20
+ PermissionForge::Operators::Constants::LEFT_OUTER_JOINS => :left_outer_joins
21
+ }.freeze
22
+
23
+ def self.call(model_class:, rules:, args:, current_user: PermissionForge.current_user,
24
+ operator_registry: PermissionForge::Operators::Registry)
25
+ new(model_class:, rules:, args:, current_user:, operator_registry:).call
26
+ end
27
+
28
+ # Builds the rule-constrained relation without evaluating a specific record
29
+ # or computing failure messages. Used by the `permission_scoped` relation
30
+ # API so callers can chain further `.where`, `.first`, `.find`, etc.
31
+ #
32
+ # @return [ActiveRecord::Relation]
33
+ def self.scope(model_class:, rules:, current_user: PermissionForge.current_user,
34
+ operator_registry: PermissionForge::Operators::Registry)
35
+ new(model_class:, rules:, args: nil, current_user:, operator_registry:).scoped_relation
36
+ end
37
+
38
+ # @return [ActiveRecord::Relation] all rules applied, no args/denial check
39
+ def scoped_relation
40
+ report_no_rules if @rules.empty?
41
+ @rules.reduce(@model_class.where(nil)) { |q, rule| build_rule_query(q, rule) }
42
+ end
43
+
44
+ def initialize(model_class:, rules:, args:, current_user:, operator_registry:)
45
+ @model_class = model_class
46
+ @rules = rules
47
+ @args = args
48
+ @current_user = current_user
49
+ @operator_registry = operator_registry
50
+ end
51
+
52
+ # @return [Array(ActiveRecord::Relation, Array<String>)] query and failure messages
53
+ def call
54
+ report_no_rules if @rules.empty?
55
+ query = @rules.reduce(@model_class.where(nil)) { |q, rule| build_rule_query(q, rule) }
56
+
57
+ # Happy path is a single existence check: rule constraints are monotonic
58
+ # (each only removes rows), so if the fully-constrained query still matches
59
+ # the target, no individual rule denied it.
60
+ return [query, []] if @rules.empty? || query.where(*@args).exists?
61
+
62
+ # Denial path only: attribute the denial to specific rules for the error
63
+ # message. The extra per-rule queries here run solely when access is denied.
64
+ [query, denial_messages]
65
+ end
66
+
67
+ private
68
+
69
+ # Called with no rules (the gem's own callers check first; a host app
70
+ # calling the applier directly may not): rule.bypassed, no_rules.
71
+ def report_no_rules
72
+ PermissionForge::Audit::Runtime.bypassed(:no_rules, model: @model_class, via: 'rule_applier', user: @current_user)
73
+ end
74
+
75
+ def rule_failure_message(rule)
76
+ "[Forbidden by Rule: #{rule[:name]}] with message: #{rule[:failure_message]}"
77
+ end
78
+
79
+ # Rebuilds the query rule-by-rule, blaming each rule whose addition removes
80
+ # the target from the result set (the first denying rule and every rule
81
+ # after it, matching the original cumulative semantics).
82
+ def denial_messages
83
+ messages = []
84
+ @rules.reduce(@model_class.where(nil)) do |q, rule|
85
+ next_query = build_rule_query(q, rule)
86
+ messages << rule_failure_message(rule) unless next_query.where(*@args).exists?
87
+ next_query
88
+ end
89
+ messages
90
+ end
91
+
92
+ def build_rule_query(query, rule)
93
+ join_type = rule[:join_type].to_s
94
+ return apply_filter(query, rule) if PermissionForge::PermissionSetRule.filter_join_type?(join_type)
95
+
96
+ apply_join(query, rule, join_method(join_type))
97
+ end
98
+
99
+ # @raise [PermissionForge::ConfigurationError] for a join_type that is not one
100
+ def join_method(join_type)
101
+ JOIN_METHODS.fetch(join_type) do
102
+ raise PermissionForge::ConfigurationError,
103
+ "Rule join_type #{join_type.inspect} is not a join type. Allowed: #{JOIN_METHODS.keys.join(', ')}"
104
+ end
105
+ end
106
+
107
+ def apply_filter(query, rule)
108
+ column = quoted_column(@model_class.table_name, rule[:field_name])
109
+ build_query(query, column, rule, [@model_class])
110
+ end
111
+
112
+ # A path that cannot be followed leaves no model to type by: the value
113
+ # goes as it is, and ActiveRecord raises for the join when the query is built.
114
+ def apply_join(query, rule, join_method)
115
+ query, models = resolve_join(query, rule, join_method)
116
+ column = quoted_column((models.last || @model_class).table_name, rule[:field_name])
117
+ build_query(query, column, rule, models)
118
+ end
119
+
120
+ # Quotes a column reference with the connection's identifier quoting.
121
+ # A dotted field_name is treated as already-qualified path segments and
122
+ # each segment is quoted independently.
123
+ def quoted_column(table, field_name)
124
+ conn = @model_class.connection
125
+ name = field_name.to_s
126
+ return name.split('.').map { |seg| conn.quote_column_name(seg) }.join('.') if name.include?('.')
127
+
128
+ "#{conn.quote_table_name(table)}.#{conn.quote_column_name(name)}"
129
+ end
130
+
131
+ def resolve_join(query, rule, join_method)
132
+ return [query, [@model_class]] if rule[:field_accessor_path].blank?
133
+
134
+ association_chain = rule[:field_accessor_path].split('.')
135
+ query = apply_join_to_query(query, join_method, association_chain)
136
+
137
+ [query, PermissionForge::Operators::RuleColumn.models_along(@model_class, association_chain)]
138
+ end
139
+
140
+ # @param join_method [Symbol] one of JOIN_METHODS — never a rule's own text
141
+ def apply_join_to_query(query, join_method, association_chain)
142
+ if association_chain.size > 1
143
+ query.public_send(join_method, build_nested_joins_structure(association_chain))
144
+ else
145
+ query.public_send(join_method, association_chain.first.to_sym)
146
+ end
147
+ end
148
+
149
+ def build_nested_joins_structure(association_chain)
150
+ structure = {}
151
+ current = structure
152
+
153
+ association_chain.each_with_index do |assoc, index|
154
+ current[assoc.to_sym] = {}
155
+ current = current[assoc.to_sym] unless index == association_chain.length - 1
156
+ end
157
+
158
+ structure
159
+ end
160
+
161
+ # A built-in operator gets its value in the type of the column it is
162
+ # compared with (see Operators::ValueTyping). A value that cannot be one
163
+ # of that column makes the rule match nothing, whatever the database
164
+ # would have made of the comparison. A custom operator's value is passed
165
+ # as it is and its strategy runs. A field_name that is not a name
166
+ # (Operators::RuleColumn.name?) matches nothing under any operator.
167
+ def build_query(query, quoted_column, rule, models)
168
+ operator = rule[:operator]
169
+ strategy = @operator_registry.fetch(operator)
170
+ target = PermissionForge::Operators::RuleColumn.find(rule[:field_name], models)
171
+ value = PermissionForge::Operators::ValueTyping.call(operator, formatted_field_value(rule), target)
172
+ return query.none if value.equal?(PermissionForge::Operators::ValueTyping::UNUSABLE)
173
+
174
+ strategy.call(query, quoted_column, rule, value)
175
+ end
176
+
177
+ # The only magic value is 'current_user', substituted with the current
178
+ # user's id where one value is compared (in a list it is an element like
179
+ # any other). Every other field_value reaches ValueTyping as it is
180
+ # written, and what comes back is quoted by the adapter — rule text is
181
+ # never interpolated into SQL.
182
+ def formatted_field_value(rule)
183
+ value = rule[:field_value]
184
+ return value unless PermissionForge::Operators::ValueTyping.current_user?(rule[:operator], value)
185
+
186
+ raise ArgumentError, 'current_user is nil but rule requires current_user substitution' unless @current_user
187
+
188
+ @current_user.id
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,89 @@
1
+ module PermissionForge
2
+ module Services
3
+ # Deep-copies a Permission Set: its resources with their fields, actions
4
+ # and rules — never its assignments. Every copied row is created by an
5
+ # audited save credited to whoever makes the copy (Clonable), and the copy
6
+ # records `permission_set.cloned` naming its source; all of it shares one
7
+ # correlation id (an enclosing Audit.correlate block's, if any).
8
+ #
9
+ # PermissionSetCloner.call(permission_set: set, attrs: { name: 'Copy' })
10
+ # PermissionSetCloner.call(permission_set: set, attrs: { name: 'Copy' }, unlock: true) # every row editable
11
+ #
12
+ # @api public
13
+ class PermissionSetCloner
14
+ include Clonable
15
+
16
+ # @param permission_set [PermissionSet] the source
17
+ # @param attrs [Hash] attributes of the copy of the set itself
18
+ # @param unlock [Boolean] create every copied row editable
19
+ # (`is_editable: true`) instead of keeping the source's locks
20
+ # @return [PermissionSet] the copy
21
+ def self.call(permission_set:, attrs: {}, unlock: false)
22
+ new(permission_set, attrs, unlock: unlock).call
23
+ end
24
+
25
+ def initialize(permission_set, attrs, unlock: false)
26
+ @permission_set = permission_set
27
+ @attrs = attrs
28
+ @unlock = unlock
29
+ end
30
+
31
+ # @return [PermissionSet] the copy
32
+ def call
33
+ PermissionForge::Audit.correlate do
34
+ @permission_set.class.transaction do
35
+ cloned = copy(@permission_set, **@attrs)
36
+ clone_resources(cloned)
37
+ record_clone(cloned)
38
+ cloned
39
+ end
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def clone_resources(target_set)
46
+ @permission_set.resources.includes(:resource_fields, :resource_actions, :permission_set_rules).find_each do |resource|
47
+ clone_resource(target_set, resource)
48
+ end
49
+ end
50
+
51
+ def clone_resource(target_set, resource)
52
+ cloned_resource = copy(resource, permission_set: target_set)
53
+
54
+ resource.resource_fields.each { |field| copy(field, resource: cloned_resource) }
55
+ clone_actions(resource, cloned_resource)
56
+ resource.permission_set_rules.each { |rule| copy(rule, permission_set: target_set, resource: cloned_resource) }
57
+ end
58
+
59
+ # One copy per action name. Rows that share a name (possible only on a
60
+ # table without the unique index) are OR-ed when permissions are built,
61
+ # so the copy is the single row they amount to.
62
+ def clone_actions(resource, cloned_resource)
63
+ resource.resource_actions.group_by(&:name).each_value do |rows|
64
+ copy(rows.first, resource: cloned_resource, **combined_action_flags(rows))
65
+ end
66
+ end
67
+
68
+ def combined_action_flags(rows)
69
+ return {} if rows.one?
70
+
71
+ active = rows.select(&:is_active)
72
+ { is_active: active.any?, is_permitted: (active.presence || rows).any?(&:is_permitted) }
73
+ end
74
+
75
+ # The lock is set before the first save, so the row's create event
76
+ # holds it: there is no second, unaudited write.
77
+ def copy(record, **attrs)
78
+ attrs = { is_editable: true }.merge(attrs) if @unlock && record.has_attribute?(:is_editable)
79
+ duplicate_record(record, **attrs)
80
+ end
81
+
82
+ def record_clone(cloned)
83
+ PermissionForge::Audit.record('permission_set.cloned', record: cloned,
84
+ data: { 'source_uuid' => @permission_set.uuid,
85
+ 'source_name' => @permission_set.name })
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,102 @@
1
+ require 'ipaddr'
2
+
3
+ module PermissionForge
4
+ module Services
5
+ # Evaluates activation conditions on a permission set against a request context.
6
+ # Returns true if the set is active for the given context (all conditions pass).
7
+ # A nil/blank condition always passes (unconditional).
8
+ #
9
+ # @example
10
+ # PermissionSetConditionEvaluator.call(
11
+ # permission_set: ps,
12
+ # context: { ip: '192.168.1.5', session_flags: ['mfa_verified'], time: Time.current }
13
+ # )
14
+ # # => true or false
15
+ #
16
+ # @api public
17
+ class PermissionSetConditionEvaluator
18
+ # @param permission_set [PermissionSet]
19
+ # @param context [Hash] `ip:`, `session_flags:` and `time:` of the request
20
+ # @return [Boolean]
21
+ def self.call(permission_set:, context:)
22
+ new(permission_set, context).call
23
+ end
24
+
25
+ # Whether a condition of the set cannot be judged in `context`: it
26
+ # restricts the IP and the context has none, or it requires a session
27
+ # flag and the context says nothing about flags (no :session_flags at
28
+ # all — an empty list is an answer). The hours are always judged (the
29
+ # time defaults to now). Under config.conditional_sets_strict_mode a
30
+ # conditional muting set applies then (ConditionalSets#passing).
31
+ #
32
+ # @return [Boolean]
33
+ def self.unjudgeable?(permission_set:, context:)
34
+ context ||= {}
35
+ (permission_set.allowed_ips.present? && context[:ip].blank?) ||
36
+ (permission_set.requires_session_flag.present? && context[:session_flags].nil?)
37
+ end
38
+
39
+ def initialize(permission_set, context)
40
+ @permission_set = permission_set
41
+ @context = context || {}
42
+ end
43
+
44
+ # @return [Boolean] whether every condition of the set passes in the context
45
+ def call
46
+ session_flag_passes? && ip_passes? && hours_pass?
47
+ end
48
+
49
+ private
50
+
51
+ # Session flag: set only active when context includes the required flag.
52
+ def session_flag_passes?
53
+ flag = @permission_set.requires_session_flag
54
+ return true if flag.blank?
55
+
56
+ Array(@context[:session_flags]).include?(flag)
57
+ end
58
+
59
+ # IP restriction: set only active when request IP matches allowed CIDRs.
60
+ # The IP may be a String or an IPAddr; anything that is not an address
61
+ # fails the condition.
62
+ def ip_passes?
63
+ allowed = @permission_set.allowed_ips
64
+ return true if allowed.blank?
65
+
66
+ request_ip = @context[:ip]
67
+ return false if request_ip.blank?
68
+
69
+ address = request_ip.is_a?(IPAddr) ? request_ip : IPAddr.new(request_ip.to_s)
70
+ parse_ip_ranges(allowed).any? { |range| range.include?(address) }
71
+ rescue IPAddr::Error
72
+ false
73
+ end
74
+
75
+ # Login hours: set only active during allowed hour window.
76
+ def hours_pass?
77
+ start_hour = @permission_set.allowed_hours_start
78
+ end_hour = @permission_set.allowed_hours_end
79
+ return true if start_hour.nil? || end_hour.nil?
80
+
81
+ hour_in_range?((@context[:time] || Time.current).hour, start_hour, end_hour)
82
+ end
83
+
84
+ def hour_in_range?(hour, start_hour, end_hour)
85
+ if start_hour <= end_hour
86
+ hour >= start_hour && hour < end_hour
87
+ else
88
+ # Wraps midnight (e.g., 22-06 means 22:00 to 05:59)
89
+ hour >= start_hour || hour < end_hour
90
+ end
91
+ end
92
+
93
+ def parse_ip_ranges(allowed_ips)
94
+ allowed_ips.split(',').filter_map do |cidr|
95
+ IPAddr.new(cidr.strip)
96
+ rescue IPAddr::Error
97
+ nil
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end