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,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module ControllerActions
5
+ # Decides whether a user may run a controller action. The runtime guard
6
+ # ({PermissionForge::Concerns::ControllerActionGuard}) raises on a denied
7
+ # decision; host apps call {.allowed?} to hide buttons with the same logic.
8
+ #
9
+ # Decision order — the first row that applies wins:
10
+ # 1. the action maps to no Resource (Mapping returns nil) → allow
11
+ # 2. no user → action_guard_nil_user_strategy, else nil_user_strategy → :bypass allows, anything else denies
12
+ # 3. config.action_guard_bypass returns true → allow
13
+ # 4. no Permission Set manages the Resource → unconfigured_action_strategy
14
+ # 5. the action means a CRUD permission → user.can?(crud, resource)
15
+ # 6. custom action the user is granted → allow
16
+ # 7. custom action some Permission Set manages → deny
17
+ # 8. custom action no Permission Set manages → unconfigured_action_strategy
18
+ #
19
+ # A grant (rows 5 and 6) is checked before the index, so a granted request
20
+ # costs no extra cache read; rows 4, 7 and 8 share one. Everything
21
+ # unexpected fails closed: a user object without `can?`, a bypass lambda
22
+ # that raises or returns something other than true, an unknown strategy.
23
+ #
24
+ # @example
25
+ # Authorizer.call(controller_path: 'customers', action_name: 'export')
26
+ # # => #<struct allowed=false, reason=:action_denied, target=#<struct resource="Customer", ...>>
27
+ # Authorizer.allowed?(controller_path: 'customers', action_name: 'export', user: current_user)
28
+ # # => false
29
+ #
30
+ # @api public
31
+ class Authorizer
32
+ # Every outcome and whether it lets the request through.
33
+ REASONS = {
34
+ unmapped: true, # row 1
35
+ nil_user_bypassed: true, # row 2, the nil-user strategy is :bypass
36
+ nil_user_denied: false, # row 2, any other nil-user strategy
37
+ guard_bypassed: true, # row 3
38
+ unsupported_user: false, # the user object has no can?
39
+ resource_not_managed: true, # row 4, unconfigured_action_strategy = :allow
40
+ resource_not_managed_blocked: false, # row 4, any other strategy
41
+ crud_granted: true, # row 5
42
+ crud_denied: false, # row 5
43
+ action_granted: true, # row 6
44
+ action_denied: false, # row 7
45
+ action_not_managed: true, # row 8, unconfigured_action_strategy = :allow
46
+ action_not_managed_blocked: false # row 8, any other strategy
47
+ }.freeze
48
+
49
+ # The allowed outcomes that let a request past the Permission Sets, and
50
+ # the rule.bypassed reason each one is published with (the guard does
51
+ # it: Authorizer.allowed? also answers button checks).
52
+ BYPASS_REASONS = {
53
+ nil_user_bypassed: 'nil_user',
54
+ guard_bypassed: 'guard_bypass',
55
+ resource_not_managed: 'no_rules',
56
+ action_not_managed: 'no_rules'
57
+ }.freeze
58
+
59
+ # target is the Mapping::Target that was checked, or nil when the action
60
+ # maps to no Resource. Every denied decision carries a target.
61
+ #
62
+ # @api private
63
+ Decision = Struct.new(:allowed, :reason, :target, keyword_init: true) do
64
+ def allowed? = allowed == true
65
+ def denied? = !allowed?
66
+
67
+ # @return [String, nil] the rule.bypassed reason, for an allowed
68
+ # decision that skipped the Permission Sets
69
+ def bypass_reason = BYPASS_REASONS[reason]
70
+ end
71
+
72
+ # @param controller_path [String] e.g. 'customers/merges'
73
+ # @param action_name [String, Symbol] e.g. 'create'
74
+ # @param controller_class [Class, nil] the controller, for its
75
+ # `permission_forge_actions` declaration. Defaults to the controller's
76
+ # class, then to the class the path names (`Customers::MergesController`).
77
+ # @param controller [Object, nil] the controller instance, handed to
78
+ # config.action_guard_bypass
79
+ # @param user [Object, nil] defaults to PermissionForge.current_user
80
+ # @param configuration [PermissionForge::Configuration]
81
+ # @param index [#managed_actions] defaults to ManagedIndex
82
+ # @return [Decision]
83
+ def self.call(...) = new(...).call
84
+
85
+ # Same arguments as {.call}.
86
+ #
87
+ # @return [Boolean] whether the user may run the action
88
+ def self.allowed?(...) = call(...).allowed?
89
+
90
+ # What to check, plus the injectable dependencies — all keywords.
91
+ def initialize(controller_path:, action_name:, controller_class: nil, controller: nil, # rubocop:disable Metrics/ParameterLists
92
+ user: PermissionForge.current_user, configuration: PermissionForge.configuration,
93
+ index: ManagedIndex)
94
+ @controller_path = controller_path.to_s
95
+ @action_name = action_name.to_s
96
+ @controller = controller
97
+ # No controller given (a button check): the class the path names.
98
+ @controller_class = controller_class || controller&.class || ControllerActions.controller_class_for(@controller_path)
99
+ @user = user
100
+ @configuration = configuration
101
+ @index = index
102
+ end
103
+
104
+ # @return [Decision]
105
+ def call
106
+ @target = Mapping.call(controller_path: @controller_path, action_name: @action_name,
107
+ controller_class: @controller_class, configuration: @configuration)
108
+ return decide(:unmapped) unless @target
109
+ return decide(nil_user_bypass? ? :nil_user_bypassed : :nil_user_denied) unless @user
110
+ return decide(:guard_bypassed) if guard_bypassed?
111
+ return decide(:unsupported_user) unless @user.respond_to?(:can?)
112
+
113
+ authorize
114
+ end
115
+
116
+ private
117
+
118
+ # Rows 4–8: what the Permission Sets say. A grant is checked first — a
119
+ # user can only be granted something on a managed resource, so the answer
120
+ # is the same and a granted request never reads the index. Everything
121
+ # else costs one index read.
122
+ def authorize
123
+ return decide(@target.crud? ? :crud_granted : :action_granted) if can?(@target.permission)
124
+
125
+ managed_actions = @index.managed_actions(@target.resource)
126
+ return unconfigured(:resource_not_managed) if managed_actions.nil?
127
+ return decide(:crud_denied) if @target.crud?
128
+ return decide(:action_denied) if managed_actions.include?(@target.action)
129
+
130
+ unconfigured(:action_not_managed)
131
+ end
132
+
133
+ def decide(reason)
134
+ Decision.new(allowed: REASONS.fetch(reason), reason: reason, target: @target)
135
+ end
136
+
137
+ # Nobody configured this resource or action: the strategy decides.
138
+ def unconfigured(reason)
139
+ decide(@configuration.allow_unconfigured_actions? ? reason : :"#{reason}_blocked")
140
+ end
141
+
142
+ # Only an explicit true from can? grants.
143
+ def can?(permission)
144
+ @user.can?(permission, @target.resource) == true
145
+ end
146
+
147
+ # Only the exact :bypass value lets a request with no user through.
148
+ def nil_user_bypass?
149
+ @configuration.action_guard_nil_user_bypass?
150
+ end
151
+
152
+ # Only an explicit true skips the guard. A raising (or non-callable)
153
+ # bypass counts as "no bypass": the Permission Sets still decide.
154
+ def guard_bypassed?
155
+ bypass = @configuration.action_guard_bypass
156
+ return false if bypass.nil?
157
+
158
+ bypass.call(@user, @controller) == true
159
+ rescue StandardError => e
160
+ log_bypass_error(e)
161
+ false
162
+ end
163
+
164
+ def log_bypass_error(error)
165
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
166
+
167
+ Rails.logger&.error("[PermissionForge] action_guard_bypass error: #{error.class}: #{error.message}")
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/current_attributes'
4
+
5
+ module PermissionForge
6
+ module ControllerActions
7
+ # Which resources and custom actions any Permission Set manages.
8
+ #
9
+ # "Managed" means a row is in effect — an active row in an active resource
10
+ # of an active Permission Set, granting or muting — in the current tenant
11
+ # (Resource.in_effect / ResourceAction.in_effect). The guard uses it to tell
12
+ # "nobody has configured this" (unconfigured_action_strategy applies) from
13
+ # "configured, and this user was not granted it" (denied).
14
+ #
15
+ # The index is one small hash ({ 'Customer' => ['export', 'merge'] }) kept
16
+ # in PermissionForge.cache, so every process sees an invalidation
17
+ # (InvalidatesManagedActions drops it when a defining record changes).
18
+ #
19
+ # A stale "not managed" lets a request through under the default
20
+ # strategy, so the index is invalidated and rebuilt the way a user's
21
+ # permission hash is — the same code, one definition:
22
+ # - every invalidation leaves a fresh stamp (STAMP_KEY) before it removes
23
+ # the keys, and a build only leaves the index in the store when the
24
+ # stamp it read before reading the rows is still there after storing it
25
+ # (Cache::Invalidation, Cache::Rebuild). A build that raced a commit is
26
+ # removed and made once more; it reads the rows past ActiveRecord's
27
+ # query cache;
28
+ # - a removal the store does not confirm is tried again, reported
29
+ # (Cache::Failure) and owed by this process (Cache::Backlog): until the
30
+ # store confirms it, this process builds the index from the database
31
+ # instead of reading it from the store;
32
+ # - the cached copy lives at most MAX_TTL seconds, whatever cache_ttl is.
33
+ # That bounds what nothing above can see: rows changed behind the
34
+ # callbacks (update_all), and — in the other processes, which do not
35
+ # know what this one owes — an index whose removal the store never
36
+ # confirmed;
37
+ # - a request keeps its own copy (RequestMemo), so many checks in one
38
+ # request cost one cache read. Rails resets it between requests and
39
+ # jobs; it is dropped on this thread by invalidate! and re-read after
40
+ # MAX_TTL in a long-lived process.
41
+ class ManagedIndex
42
+ CACHE_KEY = 'managed_actions'
43
+
44
+ # The invalidation stamp: one for every tenant's index, as a user has one
45
+ # whatever the tenant (an invalidation in another tenant makes a build
46
+ # repeat itself, which costs time, not correctness).
47
+ STAMP_KEY = 'managed_actions_invalidation'
48
+
49
+ # Rebuilding costs two small queries, so the index can afford to be fresh.
50
+ MAX_TTL = 60
51
+
52
+ # The current request's copies: one per cache key, holding the cache
53
+ # version it was read under (a bump replaces it — a job that
54
+ # invalidates many users must not keep one copy per bump).
55
+ class RequestMemo < ActiveSupport::CurrentAttributes
56
+ attribute :indexes
57
+ end
58
+
59
+ class << self
60
+ # The managed action keys of a resource, in one lookup. Callers that
61
+ # need both answers (the guard) use this instead of the two predicates.
62
+ #
63
+ # @param resource [String, Symbol] resource name (e.g. 'Customer')
64
+ # @return [Array<String>, nil] nil when no Permission Set manages the resource
65
+ def managed_actions(resource)
66
+ entries[resource.to_s]
67
+ end
68
+
69
+ # @param resource [String, Symbol] resource name
70
+ # @return [Boolean]
71
+ def managed_resource?(resource)
72
+ !managed_actions(resource).nil?
73
+ end
74
+
75
+ # @param resource [String, Symbol] resource name
76
+ # @param action [String, Symbol] action key (e.g. 'export')
77
+ # @return [Boolean]
78
+ def managed_action?(resource, action)
79
+ Array(managed_actions(resource)).include?(action.to_s)
80
+ end
81
+
82
+ # Drops the index for the ambient tenant, the tenant-less key and —
83
+ # when the caller knows it (a callback on a tenant-scoped record) —
84
+ # that record's tenant, along with this request's copies.
85
+ #
86
+ # A Cache::Invalidation: a fresh stamp first, then every key, each
87
+ # removal confirmed by the store (Cache::Base#expunge). One the store
88
+ # does not confirm — or that raises — is tried again; when it still
89
+ # fails it is reported through Cache::Failure, not raised, and owed
90
+ # (Cache::Backlog): this process does not read the index from the store
91
+ # until the store has confirmed it. Other processes may serve the old
92
+ # index until it expires: MAX_TTL seconds at most.
93
+ #
94
+ # Inside an open transaction the rows this is about are not visible to
95
+ # anybody else yet, and a request that builds the index right now
96
+ # caches it without them. So the drop is repeated once the outermost
97
+ # transaction has committed (Cache::Settlement).
98
+ #
99
+ # @param tenant_id [Object, nil]
100
+ # @return [Boolean] whether the store confirmed every removal
101
+ def invalidate!(tenant_id: nil)
102
+ keys = [cache_key, CACHE_KEY, (tenant_key(tenant_id) if tenant_id)].compact.uniq
103
+ confirmed = PermissionForge::Cache::Invalidation.call(user_id: nil, stamp_key: STAMP_KEY, keys: keys)
104
+ repeat_after_commit(tenant_id)
105
+ confirmed
106
+ ensure
107
+ RequestMemo.indexes = nil
108
+ end
109
+
110
+ private
111
+
112
+ def repeat_after_commit(tenant_id)
113
+ transaction = PermissionForge::OpenTransaction.current
114
+ return unless transaction
115
+
116
+ # the tenant that is ambient now: the commit may run after the block that made it so
117
+ tenant_id ||= PermissionForge.ambient_tenant_id
118
+ PermissionForge::Cache::Settlement.for(transaction).add([CACHE_KEY, tenant_id]) { invalidate!(tenant_id: tenant_id) }
119
+ end
120
+
121
+ # @return [Hash{String => Array<String>}] frozen
122
+ def entries
123
+ key = cache_key
124
+ memo = (RequestMemo.indexes ||= {})
125
+ version = PermissionForge.permissions_cache_version
126
+ held = memo[key]
127
+ return held[:entries] if held && current?(held, version)
128
+
129
+ index, transaction = load(key)
130
+ memo[key] = { entries: index, version: version, at: Time.now, transaction: transaction }
131
+ index
132
+ end
133
+
134
+ # A request's copy is good under the cache version it was read with,
135
+ # for MAX_TTL — and, when it was built inside a transaction, for as
136
+ # long as that transaction is open.
137
+ def current?(held, version)
138
+ held[:version] == version && Time.now - held[:at] < MAX_TTL && !held[:transaction]&.finished?
139
+ end
140
+
141
+ # An index built inside an open transaction holds rows nobody else can
142
+ # see yet, and that a rollback takes back — at every isolation level.
143
+ # It is not written to the store, and the request's copy of it is
144
+ # dropped when that transaction ends.
145
+ #
146
+ # @return [Array(Hash, PermissionForge::OpenTransaction)] the index, and
147
+ # the open transaction it was built in (nil when it came from the
148
+ # store or was built outside one)
149
+ def load(key)
150
+ cached = read_shared(key)
151
+ return [normalize(cached), nil] unless cached.nil?
152
+
153
+ transaction = PermissionForge::OpenTransaction.current
154
+ [transaction ? build : store_built(key), transaction]
155
+ end
156
+
157
+ # The cached index — unless this process still owes the store its
158
+ # removal (Cache::Backlog): the entry may be the very one that should
159
+ # be gone.
160
+ def read_shared(key)
161
+ PermissionForge.cache.read(key) unless PermissionForge::Cache::Invalidation.owed?(STAMP_KEY)
162
+ end
163
+
164
+ # Builds the index and leaves it in the store only when no
165
+ # invalidation overtook the build (Cache::Rebuild).
166
+ #
167
+ # @return [Hash] the index as built, whether or not the store kept it
168
+ def store_built(key)
169
+ PermissionForge::Cache::Rebuild.call(key: key, stamp_key: STAMP_KEY, user_id: nil, build: -> { [build, ttl] }).first
170
+ end
171
+
172
+ def ttl
173
+ [PermissionForge.configuration.cache_ttl, MAX_TTL].compact.min
174
+ end
175
+
176
+ # String keys (the cache symbolizes hash keys on read), frozen so a
177
+ # request's shared copy cannot be changed by a caller.
178
+ def normalize(index)
179
+ index.to_h { |resource, actions| [resource.to_s, Array(actions).map(&:to_s).freeze] }.freeze
180
+ end
181
+
182
+ # Reads the rows outside ActiveRecord's query cache: inside a request
183
+ # it would answer a repeated build with the rows the first one read.
184
+ #
185
+ # @return [Hash{String => Array<String>}] frozen
186
+ def build
187
+ PermissionForge::Resource.uncached do
188
+ index = PermissionForge::Resource.in_effect.distinct.pluck(:name).index_with { [] }
189
+ action_pairs.each { |resource, action| index[resource] << action }
190
+ normalize(index)
191
+ end
192
+ end
193
+
194
+ def action_pairs
195
+ PermissionForge::ResourceAction.in_effect.distinct
196
+ .pluck(PermissionForge::Resource.arel_table[:name],
197
+ PermissionForge::ResourceAction.arel_table[:name])
198
+ end
199
+
200
+ # Mirrors PermissionCacheAccessor#permission_cache_key.
201
+ def cache_key
202
+ config = PermissionForge.configuration
203
+ tenant = ActsAsTenant.current_tenant if config.tenant_enabled? && defined?(ActsAsTenant)
204
+ tenant ? tenant_key(tenant.id) : CACHE_KEY
205
+ end
206
+
207
+ def tenant_key(tenant_id)
208
+ "tenant_#{tenant_id}_#{CACHE_KEY}"
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module ControllerActions
5
+ # Resolves a controller action to the Resource and action key that govern
6
+ # it. The runtime guard and the admin's controller catalog both go through
7
+ # here, so the admin lists exactly what the guard checks.
8
+ #
9
+ # Resolution order, for the resource and for the action key independently:
10
+ # 1. the controller's own `permission_forge_actions` declaration
11
+ # 2. config.controller_action_resolver
12
+ # 3. convention — controller name → model, action name → key
13
+ #
14
+ # @example
15
+ # Mapping.call(controller_path: 'api/v1/customers', action_name: 'export')
16
+ # # => #<struct resource="Customer", action="export", crud=nil>
17
+ # Mapping.call(controller_path: 'customers', action_name: 'index')
18
+ # # => #<struct resource="Customer", action="index", crud=:read>
19
+ class Mapping
20
+ # crud is the CRUD permission the action means (:read, :create, :edit,
21
+ # :delete), or nil for a custom action checked by its key.
22
+ Target = Struct.new(:resource, :action, :crud, keyword_init: true) do
23
+ def crud? = !crud.nil?
24
+
25
+ # What to ask PermissionedUser#can? for.
26
+ def permission = crud || action
27
+
28
+ # Whether the admin and the API can give this action a row: a CRUD
29
+ # flag, or a key that is a valid ResourceAction name. An action whose
30
+ # name is not a valid key (`exportCSV`) is still mapped, so the guard
31
+ # checks it like any other — with no row for it, that means
32
+ # unconfigured_action_strategy — instead of waving it through.
33
+ def manageable? = crud? || action.match?(KEY_FORMAT)
34
+ end
35
+
36
+ def self.call(...) = new(...).call
37
+
38
+ # @param conventions [Hash, nil] a caller-owned memo of controller_path =>
39
+ # conventional resource, for callers that map many actions at once (the
40
+ # admin's controller catalog). The guard passes none.
41
+ def initialize(controller_path:, action_name:, controller_class: nil, configuration: PermissionForge.configuration,
42
+ conventions: nil)
43
+ @controller_path = controller_path.to_s
44
+ @action_name = action_name.to_s
45
+ @controller_class = controller_class
46
+ @configuration = configuration
47
+ @conventions = conventions
48
+ end
49
+
50
+ # @return [Target, nil] nil when the action is excluded, unmapped or
51
+ # explicitly unmanaged
52
+ # @raise [PermissionForge::ConfigurationError] when a declaration or the
53
+ # resolver names a resource or key that is not a valid name
54
+ def call
55
+ return nil if unmanaged?
56
+
57
+ resource = resource_name
58
+ return nil unless resource
59
+
60
+ key = explicit_key || @action_name
61
+ crud = crud_for(key)
62
+ Target.new(resource: resource, action: key, crud: crud) unless crud && skip_rest_action?
63
+ end
64
+
65
+ private
66
+
67
+ # Excluded controller, or the host resolver said "never managed".
68
+ def unmanaged?
69
+ @configuration.controller_excluded?(@controller_path) || resolved == false
70
+ end
71
+
72
+ def declared
73
+ @declared ||= if @controller_class.respond_to?(:pf_action_guard_options)
74
+ @controller_class.pf_action_guard_options || EMPTY_OPTIONS
75
+ else
76
+ EMPTY_OPTIONS
77
+ end
78
+ end
79
+
80
+ # The host resolver's answer: [resource, key], false, or nil. A raising
81
+ # resolver propagates, and so does an answer of any other shape —
82
+ # treating either as "unmanaged" would fail open.
83
+ def resolved
84
+ return @resolved if defined?(@resolved)
85
+
86
+ answer = @configuration.controller_action_resolver&.call(@controller_path, @action_name)
87
+ unless answer.nil? || answer == false || answer.is_a?(Array)
88
+ raise PermissionForge::ConfigurationError,
89
+ "#{@controller_path}##{@action_name}: controller_action_resolver must return " \
90
+ "[resource, key], false or nil, got #{answer.inspect}"
91
+ end
92
+
93
+ @resolved = answer
94
+ end
95
+
96
+ def resolved_pair
97
+ resolved.is_a?(Array) ? resolved : []
98
+ end
99
+
100
+ # Declarations are validated when they are made (normalize_options);
101
+ # resolver answers are validated here.
102
+ def resource_name
103
+ declared[:resource] || resolved_part(0, RESOURCE_FORMAT, 'Resource name') || conventional_resource
104
+ end
105
+
106
+ # A key the controller or the resolver chose, as opposed to the action name.
107
+ def explicit_key
108
+ return @explicit_key if defined?(@explicit_key)
109
+
110
+ @explicit_key = declared[:actions][@action_name] || resolved_part(1, KEY_FORMAT, 'action key')
111
+ end
112
+
113
+ def resolved_part(index, format, kind)
114
+ value = resolved_pair[index]&.to_s.presence
115
+ value && valid!(value, format, kind)
116
+ end
117
+
118
+ # A resolver answer that cannot be used is a configuration bug. Raising
119
+ # fails closed; treating it as "unmapped" would leave the action unchecked.
120
+ def valid!(value, format, kind)
121
+ return value if value.match?(format)
122
+
123
+ raise PermissionForge::ConfigurationError,
124
+ "#{@controller_path}##{@action_name}: #{value.inspect} is not a valid #{kind}"
125
+ end
126
+
127
+ # An explicit key means CRUD only when it is one of can?'s CRUD words
128
+ # (`actions: { approve: :edit }`); an action name also counts when it is
129
+ # a REST action.
130
+ def crud_for(key)
131
+ return CRUD_KEYS[key] if explicit_key
132
+
133
+ REST_ACTIONS[key] || CRUD_KEYS[key]
134
+ end
135
+
136
+ def skip_rest_action?
137
+ declared[:rest_actions] == false && explicit_key.nil? && REST_ACTIONS.key?(@action_name)
138
+ end
139
+
140
+ def conventional_resource
141
+ return resolve_convention unless @conventions
142
+
143
+ @conventions.fetch(@controller_path) { @conventions[@controller_path] = resolve_convention }
144
+ end
145
+
146
+ # 'api/v1/customers' → Api::V1::Customer, then Customer.
147
+ def resolve_convention
148
+ candidates = [@controller_path.classify, @controller_path.split('/').last.to_s.classify].uniq
149
+ candidates.each do |candidate|
150
+ klass = model_class(candidate)
151
+ return klass.name if klass
152
+ end
153
+ nil
154
+ end
155
+
156
+ # safe_constantize answers nil for a missing constant. A model that
157
+ # exists but fails to load raises, and that propagates: swallowing it
158
+ # would turn the controller into "unmapped", which the guard allows.
159
+ def model_class(name)
160
+ return nil unless name.match?(RESOURCE_FORMAT)
161
+
162
+ klass = name.safe_constantize
163
+ klass if klass.is_a?(Class) && klass < ActiveRecord::Base && !klass.abstract_class?
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # Controller actions as permissions: which Resource and action key a
5
+ # controller action means (Mapping), whether any Permission Set manages it
6
+ # (ManagedIndex), and whether a user may run it (Authorizer).
7
+ #
8
+ # @api private
9
+ module ControllerActions
10
+ # Format of a ResourceAction name, and so of every action key.
11
+ KEY_FORMAT = /\A[a-z][a-z0-9_]{0,62}\z/
12
+
13
+ # A Resource name: a Ruby constant path (the one definition is
14
+ # PermissionForge::RESOURCE_NAME_FORMAT; the Resource model is autoloaded
15
+ # later than this file).
16
+ RESOURCE_FORMAT = PermissionForge::RESOURCE_NAME_FORMAT
17
+
18
+ # The before_action the guard concern installs.
19
+ GUARD_CALLBACK = :pf_authorize_action!
20
+
21
+ # Controller action names that mean a CRUD permission on the resource.
22
+ REST_ACTIONS = {
23
+ 'index' => :read, 'show' => :read,
24
+ 'new' => :create, 'create' => :create,
25
+ 'edit' => :edit, 'update' => :edit,
26
+ 'destroy' => :delete
27
+ }.freeze
28
+
29
+ # Action keys PermissionedUser#can? answers from the CRUD flags instead of
30
+ # the resource's custom actions — can?'s own table, so the two cannot drift.
31
+ CRUD_KEYS = PermissionForge::Concerns::ResourcePermissionChecker::CRUD_ACTIONS.transform_keys(&:to_s).freeze
32
+
33
+ EMPTY_OPTIONS = { resource: nil, actions: {}.freeze, rest_actions: true }.freeze
34
+
35
+ # Normalizes a controller's `permission_forge_actions` declaration into the
36
+ # frozen shape Mapping reads. The only constructor of that shape, so it is
37
+ # also where a declaration is validated.
38
+ #
39
+ # @param resource [String, Symbol, Class, nil]
40
+ # @param actions [Hash] controller action name => action key
41
+ # @param rest_actions [Boolean] false leaves index/show/new/... unmanaged
42
+ # @return [Hash] { resource: String|nil, actions: { String => String }, rest_actions: Boolean }
43
+ # @raise [ArgumentError] when the resource or a key is not a valid name
44
+ def self.normalize_options(resource: nil, actions: {}, rest_actions: true)
45
+ options = {
46
+ resource: resource_name(resource),
47
+ actions: actions.to_h.to_h { |name, key| [name.to_s, key.to_s] }.freeze,
48
+ rest_actions: rest_actions != false
49
+ }.freeze
50
+ validate_options!(options)
51
+ options
52
+ end
53
+
54
+ # Adds a declaration to the one a controller already has — its own earlier
55
+ # call, or an inherited one. A given resource or rest_actions replaces;
56
+ # actions are merged. Declaring twice therefore never drops the resource
57
+ # the first call named (which would leave the controller unchecked).
58
+ #
59
+ # @param current [Hash] a normalized declaration
60
+ # @return [Hash] a normalized, frozen declaration
61
+ def self.merge_options(current, **declaration)
62
+ rest_actions = declaration.fetch(:rest_actions, nil)
63
+ given = normalize_options(**declaration, rest_actions: rest_actions.nil? ? current[:rest_actions] : rest_actions)
64
+ { resource: given[:resource] || current[:resource],
65
+ actions: current[:actions].merge(given[:actions]).freeze,
66
+ rest_actions: given[:rest_actions] }.freeze
67
+ end
68
+
69
+ # A declaration the mapping could not use fails here, when the controller
70
+ # loads. Left alone, a typo (`resource: 'customer'`, `actions: { create:
71
+ # 'Merge' }`) would map the action to nothing and leave it unchecked.
72
+ def self.validate_options!(options)
73
+ resource = options[:resource]
74
+ if resource && !resource.match?(RESOURCE_FORMAT)
75
+ raise ArgumentError, "permission_forge_actions: #{resource.inspect} is not a Resource name " \
76
+ "(expected a model name such as 'Customer')"
77
+ end
78
+
79
+ invalid = options[:actions].values.grep_v(KEY_FORMAT)
80
+ return if invalid.empty?
81
+
82
+ raise ArgumentError, "permission_forge_actions: invalid action key #{invalid.map(&:inspect).join(', ')} " \
83
+ '(expected lowercase letters, digits and _, starting with a letter)'
84
+ end
85
+ private_class_method :validate_options!
86
+
87
+ # 'api/v1/customers' → Api::V1::CustomersController, the way the router
88
+ # names it; nil when there is no such class.
89
+ #
90
+ # @param controller_path [String]
91
+ # @return [Class, nil]
92
+ def self.controller_class_for(controller_path)
93
+ "#{controller_path}_controller".camelize.safe_constantize
94
+ end
95
+
96
+ # Whether the controller runs the guard: it includes the concern and has
97
+ # not removed the callback with a blanket skip_before_action. (A skip
98
+ # limited with only:/except: still counts as guarded.)
99
+ #
100
+ # @param controller_class [Class, nil]
101
+ # @return [Boolean]
102
+ # @raise [PermissionForge::ConfigurationError] when the controller's
103
+ # before_action chain cannot be read (`_process_action_callbacks`, a
104
+ # Rails internal): the answer is not guessed either way
105
+ def self.guarded?(controller_class)
106
+ return false unless controller_class.respond_to?(:pf_action_guard_options)
107
+ unless controller_class.respond_to?(:_process_action_callbacks)
108
+ raise PermissionForge::ConfigurationError,
109
+ "PermissionForge cannot read the before_action chain of #{controller_class} (_process_action_callbacks, " \
110
+ "ActionPack #{ActionPack.version}): this version of the gem does not support that Rails version"
111
+ end
112
+
113
+ controller_class._process_action_callbacks.any? { |callback| callback.filter == GUARD_CALLBACK }
114
+ end
115
+
116
+ def self.resource_name(resource)
117
+ name = resource.is_a?(Module) ? resource.name : resource
118
+ name.to_s.presence
119
+ end
120
+ private_class_method :resource_name
121
+ end
122
+ end