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,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ # Operator -> SQL strategy registry for RLS policy generation, plus the
6
+ # value-quoting helpers the strategies call back into. Extracted from
7
+ # {RlsPolicyGenerator} for SRP/OCP — adding an operator translation is a
8
+ # new registry entry, not a generator change.
9
+ #
10
+ # Each strategy receives (col, value, generator) and returns a SQL boolean
11
+ # expression that selects the rows the app layer's strategy selects
12
+ # (Operators::Registry). The value is the rule's, typed for the column by
13
+ # the same Operators::ValueTyping — a literal, a list of them, or
14
+ # {SessionUser} where `current_user` stands for the session variable. LIKE
15
+ # is ILIKE on the column itself, as Arel writes it for PostgreSQL, with the
16
+ # app layer's escaping. Where the app layer's query raises, the policy
17
+ # shows no row: it is never the wider of the two.
18
+ #
19
+ # @api private
20
+ module RlsOperatorSql
21
+ # `current_user` in a policy: the SQL the session user's id is read with.
22
+ SessionUser = Struct.new(:sql)
23
+
24
+ UNUSABLE = Operators::ValueTyping::UNUSABLE
25
+
26
+ OPERATOR_SQL = {
27
+ Operators::Constants::EQUAL => ->(col, value, g) { "#{col} = #{g.value_sql(value)}" },
28
+ Operators::Constants::NOT_EQUAL => ->(col, value, g) { "#{col} <> #{g.value_sql(value)}" },
29
+ Operators::Constants::LESS_THAN => ->(col, value, g) { "#{col} < #{g.value_sql(value)}" },
30
+ Operators::Constants::GREATER_THAN => ->(col, value, g) { "#{col} > #{g.value_sql(value)}" },
31
+ Operators::Constants::LESS_THAN_OR_EQUAL => ->(col, value, g) { "#{col} <= #{g.value_sql(value)}" },
32
+ Operators::Constants::GREATER_THAN_OR_EQUAL => ->(col, value, g) { "#{col} >= #{g.value_sql(value)}" },
33
+ Operators::Constants::IN => ->(col, values, g) { "#{col} IN (#{g.list_sql(values)})" },
34
+ Operators::Constants::NOT_IN => ->(col, values, g) { "#{col} NOT IN (#{g.list_sql(values)})" },
35
+ Operators::Constants::CONTAINS => ->(col, value, g) { g.like_sql(col, value, prefix: '%', suffix: '%') },
36
+ Operators::Constants::NOT_CONTAINS => lambda { |col, value, g|
37
+ "NOT #{g.like_sql(col, value, prefix: '%', suffix: '%')}"
38
+ },
39
+ Operators::Constants::STARTS_WITH => ->(col, value, g) { g.like_sql(col, value, prefix: '', suffix: '%') },
40
+ Operators::Constants::ENDS_WITH => ->(col, value, g) { g.like_sql(col, value, prefix: '%', suffix: '') },
41
+ Operators::Constants::BETWEEN => ->(col, bounds, g) { "#{col} BETWEEN #{g.list_sql(bounds, ' AND ')}" },
42
+ Operators::Constants::IS_NULL => ->(col, _value, _g) { "#{col} IS NULL" },
43
+ Operators::Constants::IS_NOT_NULL => ->(col, _value, _g) { "#{col} IS NOT NULL" }
44
+ }.freeze
45
+
46
+ # The operators whose app-layer strategy adds the NULL rows under
47
+ # include_nulls. The others ignore the flag, and so does the policy.
48
+ NULL_INCLUDING = [
49
+ Operators::Constants::EQUAL, Operators::Constants::NOT_EQUAL,
50
+ Operators::Constants::IN, Operators::Constants::NOT_IN
51
+ ].freeze
52
+
53
+ # The column types PostgreSQL has ILIKE for. On a column of any other
54
+ # type the app layer's LIKE query raises, so the rule compiles to FALSE.
55
+ LIKE_TYPES = %i[string text citext].freeze
56
+
57
+ # How the session user's id is read for a comparison, by the caster the
58
+ # column's type has in the app layer: the id itself where the column is
59
+ # a number or of a type nothing is known about, its text on a string
60
+ # column. A column of any other type (a boolean, a date) cannot equal an
61
+ # id — the rule is UNUSABLE there, as it is in the app layer.
62
+ SESSION_USER_SQL = {
63
+ Operators::ValueCasters::UNTYPED => :uid_sql,
64
+ Operators::ValueCasters::NUMBER => :numeric_uid_sql,
65
+ Operators::ValueCasters::Number::SINGLE_PRECISION => :numeric_uid_sql,
66
+ Operators::ValueCasters::TEXT => :uid_text_sql
67
+ }.freeze
68
+
69
+ # A rule's value as the app layer compares it with the column.
70
+ #
71
+ # @param target [Operators::RuleColumn] the column the rule names
72
+ # @return [Object] a typed literal, an Array of them, a SessionUser, or UNUSABLE
73
+ def typed_value(operator, value, target)
74
+ return UNUSABLE if target.malformed? || unmatchable?(operator, target.column)
75
+ return session_user(operator, target) if Operators::ValueTyping.current_user?(operator, value)
76
+
77
+ Operators::ValueTyping.call(operator, value, target)
78
+ end
79
+
80
+ def value_sql(value)
81
+ case value
82
+ when SessionUser then value.sql
83
+ else connection.quote(value)
84
+ end
85
+ end
86
+
87
+ def list_sql(values, separator = ', ')
88
+ values.map { |value| connection.quote(value) }.join(separator)
89
+ end
90
+
91
+ def like_sql(col, value, prefix:, suffix:)
92
+ pattern = if value.is_a?(SessionUser)
93
+ [connection.quote(prefix), value.sql, connection.quote(suffix)].join(' || ')
94
+ else
95
+ connection.quote("#{prefix}#{Operators::Registry.sanitize_like(value)}#{suffix}")
96
+ end
97
+ "#{col} ILIKE #{pattern} ESCAPE #{connection.quote(Operators::Registry::LIKE_ESCAPE)}"
98
+ end
99
+
100
+ private
101
+
102
+ # A LIKE operator on a column PostgreSQL cannot match a pattern with. (A
103
+ # column the model does not have fails the sync, as it fails the query.)
104
+ def unmatchable?(operator, column)
105
+ return false unless column && Operators::ValueTyping::SHAPES[operator] == :pattern
106
+
107
+ column.try(:array?) || LIKE_TYPES.exclude?(column.type)
108
+ end
109
+
110
+ # A comparison takes the id as the column's type lets it. A LIKE pattern
111
+ # is built from the id's text whatever the column (and a null check
112
+ # takes no value at all).
113
+ def session_user(operator, target)
114
+ single = Operators::ValueTyping::SHAPES[operator] == :single
115
+ reader = single ? SESSION_USER_SQL[target.caster] : :uid_text_sql
116
+ sql = send(reader) if reader
117
+ sql ? SessionUser.new(sql) : UNUSABLE
118
+ end
119
+
120
+ def uid_text_sql
121
+ "(#{uid_sql})::text"
122
+ end
123
+
124
+ # An id that is not a number (a uuid) is never the value of a number column.
125
+ def numeric_uid_sql
126
+ column = PermissionForge::PermissionSetUser.columns_hash['user_id']
127
+ uid_sql if column.nil? || Operators::ValueCasters.for(column).equal?(Operators::ValueCasters::NUMBER)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,365 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module PermissionForge
6
+ module Services
7
+ # Level 3 — Postgres Row-Level Security policy generation.
8
+ #
9
+ # Builds the DDL for one protected model: enable/force RLS plus four
10
+ # policies (SELECT/UPDATE/DELETE share a USING expression; INSERT is
11
+ # WITH CHECK (true) because app-level rules never gate creation).
12
+ #
13
+ # The USING expression is a hybrid of static and dynamic SQL:
14
+ # - MEMBERSHIP is dynamic — EXISTS subqueries against
15
+ # permission_set_users / permission_sets evaluated per statement, so
16
+ # assigning or revoking a user NEVER requires a policy resync.
17
+ # - RULE CONTENT is static — each granting set's active FILTER-type rules
18
+ # are compiled to SQL at generation time as
19
+ # (NOT member(set) OR (rule1 AND rule2 ...)) conjuncts. Editing rule
20
+ # rows requires `rake permission_forge:rls:sync` (drift is detected via
21
+ # a digest stored as the SELECT policy's comment).
22
+ #
23
+ # Semantics parity with the application layer:
24
+ # - view_all/modify_all in a granting set bypasses rules; a muting set
25
+ # with view_all/modify_all cancels that bypass (both checked
26
+ # dynamically). Only a resource row in effect counts (r.is_active,
27
+ # ps.is_active), and with tenancy on only one of the row's tenant: a
28
+ # view_all grant in tenant A never bypasses the rules on tenant B's
29
+ # rows, nor on rows of no tenant (narrower than an ambient-tenant app
30
+ # build there, never wider).
31
+ # - A granting set's rules are compiled whatever the set's and its
32
+ # resource row's is_active; the membership EXISTS reads both states
33
+ # per statement, so switching a set or a resource off or on needs no
34
+ # re-sync and the policy is never wider than the app layer in between.
35
+ # - A user in no rule-bearing set sees all rows (rules only restrict).
36
+ # - Muting sets contribute no rules; conditional sets' rules are included
37
+ # (more restrictive than the app, never less — documented).
38
+ # - Missing session variable follows nil_user_strategy: :bypass allows,
39
+ # :deny/:raise hides every row.
40
+ # - A rule's value is typed for its column by the app layer's own
41
+ # Operators::ValueTyping; a value the column cannot hold compiles to
42
+ # FALSE, as the app layer matches nothing for it (see RlsOperatorSql).
43
+ # - Join-type rules and dotted field names are NOT translatable — they are
44
+ # reported in :skipped_rules, never silently dropped. So are the rules
45
+ # of a Resource named after an STI subclass of the model: a policy is
46
+ # per table and compiled for the class that includes the concern, so
47
+ # those rules (which the app layer applies to the subclass, see
48
+ # ResourceChain) hold at the application layer only.
49
+ #
50
+ # This service only BUILDS SQL strings (testable on any adapter);
51
+ # PermissionForge::RLS executes them against Postgres.
52
+ #
53
+ # @api private
54
+ class RlsPolicyGenerator
55
+ POLICY_NAMES = {
56
+ select: 'permission_forge_select',
57
+ insert: 'permission_forge_insert',
58
+ update: 'permission_forge_update',
59
+ delete: 'permission_forge_delete'
60
+ }.freeze
61
+
62
+ DIGEST_COMMENT_PREFIX = 'permission_forge digest: '
63
+
64
+ # Bumped whenever the generated SQL *shape* changes (not just rule data),
65
+ # so existing installs get a drift warning and re-sync after upgrading.
66
+ # 3: what a rule compiles to follows the app layer — typed values, ILIKE,
67
+ # include_nulls only for the operators that honour it there.
68
+ # 4: the view_all bypass needs the resource row to be active and, with
69
+ # tenancy on, in the row's tenant.
70
+ # 5: a rule set's membership EXISTS joins its resource row and needs it
71
+ # active; the rules are compiled whatever the row's — or the set's —
72
+ # state (the set's is_active was already read live).
73
+ GENERATOR_VERSION = 5
74
+
75
+ include RlsOperatorSql
76
+
77
+ def self.call(model_class:, configuration: PermissionForge.configuration)
78
+ new(model_class: model_class, configuration: configuration).call
79
+ end
80
+
81
+ def initialize(model_class:, configuration: PermissionForge.configuration)
82
+ @model_class = model_class
83
+ @configuration = configuration
84
+ @conn = model_class.connection
85
+ @skipped_rules = []
86
+ end
87
+
88
+ # @return [Hash] { table:, statements: Array<String>, digest:, skipped_rules: }
89
+ def call
90
+ note_subclass_rules
91
+ statements = [
92
+ "ALTER TABLE #{quoted_table} ENABLE ROW LEVEL SECURITY",
93
+ "ALTER TABLE #{quoted_table} FORCE ROW LEVEL SECURITY",
94
+ *POLICY_NAMES.values.map { |name| "DROP POLICY IF EXISTS #{name} ON #{quoted_table}" },
95
+ *policy_statements,
96
+ comment_statement
97
+ ]
98
+
99
+ { table: table_name, statements: statements, digest: digest, skipped_rules: @skipped_rules }
100
+ end
101
+
102
+ # Canonical fingerprint of everything static in the policy. Stored as the
103
+ # SELECT policy's comment; a mismatch at boot means rules changed since
104
+ # the last sync.
105
+ def digest
106
+ parts = [
107
+ GENERATOR_VERSION,
108
+ @configuration.nil_user_strategy,
109
+ @configuration.rls_session_variable,
110
+ table_name,
111
+ tenant_predicate_sql,
112
+ rule_sets.map { |set| [set[:id], set[:resource_id], set[:rules].map { |r| rule_fingerprint(r) }.sort] }.sort
113
+ ]
114
+ Digest::SHA256.hexdigest(parts.inspect)
115
+ end
116
+
117
+ private
118
+
119
+ # Adapter connection used for quoting by RlsOperatorSql helpers too.
120
+ def connection
121
+ @conn
122
+ end
123
+
124
+ def table_name
125
+ @model_class.table_name
126
+ end
127
+
128
+ def quoted_table
129
+ @conn.quote_table_name(table_name)
130
+ end
131
+
132
+ def resource_name
133
+ @model_class.name
134
+ end
135
+
136
+ def policy_statements
137
+ using = using_expression
138
+ [
139
+ "CREATE POLICY #{POLICY_NAMES[:select]} ON #{quoted_table} FOR SELECT USING (#{using})",
140
+ "CREATE POLICY #{POLICY_NAMES[:insert]} ON #{quoted_table} FOR INSERT WITH CHECK (true)",
141
+ "CREATE POLICY #{POLICY_NAMES[:update]} ON #{quoted_table} FOR UPDATE USING (#{using}) WITH CHECK (true)",
142
+ "CREATE POLICY #{POLICY_NAMES[:delete]} ON #{quoted_table} FOR DELETE USING (#{using})"
143
+ ]
144
+ end
145
+
146
+ def comment_statement
147
+ "COMMENT ON POLICY #{POLICY_NAMES[:select]} ON #{quoted_table} IS " \
148
+ "#{@conn.quote("#{DIGEST_COMMENT_PREFIX}#{digest}")}"
149
+ end
150
+
151
+ # :bypass -> uid IS NULL OR (view_all OR rules)
152
+ # :deny/:raise -> uid IS NOT NULL AND (view_all OR rules)
153
+ # The explicit uid guard matters: with a NULL uid every membership EXISTS
154
+ # is false, which would make the rules conjunction vacuously TRUE — the
155
+ # deny strategies must therefore hide rows before that branch is reached.
156
+ def using_expression
157
+ guarded = "(#{view_all_bypass_sql} OR #{rules_conjunction_sql})"
158
+ if @configuration.nil_user_strategy == :bypass
159
+ "((#{uid_sql}) IS NULL OR #{guarded})"
160
+ else
161
+ "((#{uid_sql}) IS NOT NULL AND #{guarded})"
162
+ end
163
+ end
164
+
165
+ # current_setting(var, true) returns NULL when unset but '' after RESET,
166
+ # hence the NULLIF. Cast to the real user_id column type so uuid or
167
+ # bigint PKs both work.
168
+ def uid_sql
169
+ var = @conn.quote(@configuration.rls_session_variable)
170
+ "NULLIF(current_setting(#{var}, true), '')::#{user_id_cast_type}"
171
+ end
172
+
173
+ def user_id_cast_type
174
+ PermissionForge::PermissionSetUser.columns_hash['user_id']&.sql_type || 'bigint'
175
+ end
176
+
177
+ # Granting-set view_all/modify_all bypasses rules; a muting set with
178
+ # view_all/modify_all cancels the bypass. Both checked dynamically.
179
+ #
180
+ # GDPR/consent guard: activation conditions (session flag / IP / login
181
+ # hours) cannot be evaluated inside a database policy, so only
182
+ # UNCONDITIONAL sets may grant the bypass — a consent-gated set granting
183
+ # view_all must never widen DB-level access while its condition is
184
+ # unmet. Muting sets keep canceling regardless (restrictive direction is
185
+ # always safe).
186
+ def view_all_bypass_sql
187
+ "(#{view_all_exists_sql(muting: false)} AND NOT #{view_all_exists_sql(muting: true)})"
188
+ end
189
+
190
+ def view_all_exists_sql(muting:)
191
+ <<~SQL.squish
192
+ EXISTS (
193
+ SELECT 1 FROM #{psu_table} psu
194
+ JOIN #{ps_table} ps ON ps.id = psu.permission_set_id
195
+ JOIN #{resources_table} r ON r.permission_set_id = ps.id
196
+ WHERE psu.user_id = (#{uid_sql})
197
+ AND #{assignment_active_sql}
198
+ AND ps.is_active = TRUE AND ps.is_muting = #{muting ? 'TRUE' : 'FALSE'}
199
+ #{"AND #{unconditional_set_sql}" unless muting}
200
+ AND r.name = #{@conn.quote(resource_name)}
201
+ AND r.is_active = TRUE
202
+ #{tenant_predicate_sql}
203
+ AND (r.view_all = TRUE OR r.modify_all = TRUE)
204
+ )
205
+ SQL
206
+ end
207
+
208
+ # Ties the resource row to the protected row's tenant, when tenancy is
209
+ # on and both tables have the tenant column: `AND r.account_id =
210
+ # "customers"."account_id"`. A NULL tenant on either side matches
211
+ # nothing. Empty otherwise (no tenant to compare).
212
+ def tenant_predicate_sql
213
+ column = @configuration.tenant_enabled? && @configuration.resolved_tenant_column.to_s
214
+ return '' unless column && [@model_class, PermissionForge::Resource].all? { |klass| klass.column_names.include?(column) }
215
+
216
+ quoted = @conn.quote_column_name(column)
217
+ "AND r.#{quoted} = #{quoted_table}.#{quoted}"
218
+ end
219
+
220
+ # Mirrors PermissionSet.unconditional (activation conditions absent).
221
+ def unconditional_set_sql
222
+ "(ps.requires_session_flag IS NULL OR ps.requires_session_flag = '') " \
223
+ "AND (ps.allowed_ips IS NULL OR ps.allowed_ips = '') " \
224
+ 'AND ps.allowed_hours_start IS NULL'
225
+ end
226
+
227
+ # One conjunct per rule-bearing granting set:
228
+ # (NOT member(set) OR (rule1 AND rule2 ...))
229
+ # A user in none of these sets passes every conjunct (rules only
230
+ # restrict); a member must satisfy that set's rules. TRUE when no set has
231
+ # translatable rules.
232
+ def rules_conjunction_sql
233
+ conjuncts = rule_sets.map do |set|
234
+ "(NOT #{membership_exists_sql(set[:id], set[:resource_id])} OR (#{set[:rules].map { |r| rule_sql(r) }.join(' AND ')}))"
235
+ end
236
+ return 'TRUE' if conjuncts.empty?
237
+
238
+ "(#{conjuncts.join(' AND ')})"
239
+ end
240
+
241
+ # Member of the set, with the set and the resource row the rules belong
242
+ # to both active, as the app layer reads them: a switched-off resource
243
+ # holds nobody to its rules.
244
+ def membership_exists_sql(set_id, resource_id)
245
+ <<~SQL.squish
246
+ EXISTS (
247
+ SELECT 1 FROM #{psu_table} psu
248
+ JOIN #{ps_table} ps ON ps.id = psu.permission_set_id
249
+ JOIN #{resources_table} r ON r.id = #{Integer(resource_id)} AND r.is_active = TRUE
250
+ WHERE psu.user_id = (#{uid_sql})
251
+ AND psu.permission_set_id = #{Integer(set_id)}
252
+ AND ps.is_active = TRUE
253
+ AND #{assignment_active_sql}
254
+ )
255
+ SQL
256
+ end
257
+
258
+ def assignment_active_sql
259
+ clauses = ['(psu.expires_at IS NULL OR psu.expires_at > now())']
260
+ clauses << 'psu.is_active = TRUE' if PermissionForge::PermissionSetUser.column_names.include?('is_active')
261
+ clauses.join(' AND ')
262
+ end
263
+
264
+ def psu_table
265
+ @conn.quote_table_name(PermissionForge::PermissionSetUser.table_name)
266
+ end
267
+
268
+ def ps_table
269
+ @conn.quote_table_name(PermissionForge::PermissionSet.table_name)
270
+ end
271
+
272
+ def resources_table
273
+ @conn.quote_table_name(PermissionForge::Resource.table_name)
274
+ end
275
+
276
+ # A value the column cannot hold makes the rule FALSE — the app layer
277
+ # matches nothing for it, NULL rows included.
278
+ def rule_sql(rule)
279
+ operator, field_name = rule.values_at(:operator, :field_name)
280
+ value = typed_value(operator, rule.field_value, Operators::RuleColumn.find(field_name, [@model_class]))
281
+ return 'FALSE' if value.equal?(UNUSABLE)
282
+
283
+ col = "#{quoted_table}.#{@conn.quote_column_name(field_name)}"
284
+ base = OPERATOR_SQL.fetch(operator).call(col, value, self)
285
+ rule.include_nulls? && NULL_INCLUDING.include?(operator) ? "((#{base}) OR #{col} IS NULL)" : "(#{base})"
286
+ end
287
+
288
+ # Granting sets with active, translatable FILTER-type rules on this
289
+ # resource, whatever the set's or the resource row's own is_active (the
290
+ # membership EXISTS reads both per statement: a set switched off at
291
+ # sync time and on later must not be wider than the app layer until the
292
+ # next re-sync). Join-type rules and dotted field names go to
293
+ # :skipped_rules — bounded coverage is reported, never silent.
294
+ def rule_sets
295
+ @rule_sets ||= PermissionForge::Resource.where(name: resource_name).filter_map do |resource|
296
+ permission_set = resource.permission_set
297
+ next unless permission_set && !permission_set.is_muting
298
+
299
+ rules = partition_rules(resource, permission_set)
300
+ next if rules.empty?
301
+
302
+ { id: permission_set.id, resource_id: resource.id, rules: rules }
303
+ end
304
+ end
305
+
306
+ def partition_rules(resource, permission_set)
307
+ resource.permission_set_rules.active.filter_map do |rule|
308
+ reason = untranslatable_reason(rule)
309
+ if reason
310
+ @skipped_rules << { permission_set: permission_set.name, rule: rule.name, reason: reason }
311
+ next
312
+ end
313
+
314
+ rule
315
+ end
316
+ end
317
+
318
+ # The active rules of resources named after the model's loaded STI
319
+ # subclasses, in effect in a granting set: not compiled (see the class
320
+ # comment), reported.
321
+ def note_subclass_rules
322
+ names = subclass_names
323
+ return if names.empty?
324
+
325
+ subclass_rules(names).each do |rule|
326
+ @skipped_rules << { permission_set: rule.permission_set.name, rule: rule.name, reason: :sti_subclass_resource }
327
+ end
328
+ end
329
+
330
+ # The loaded descendants on this table (a subclass with a table of its
331
+ # own is another policy's business), minus one that answers to one of
332
+ # the model's own names (a test double, a renamed subclass).
333
+ def subclass_names
334
+ subclasses = @model_class.descendants.select { |klass| klass.table_name == table_name }
335
+ subclasses.filter_map(&:name).uniq - ResourceChain.names(@model_class).map(&:to_s)
336
+ end
337
+
338
+ # The active rules of the resource rows in effect (Resource.in_effect),
339
+ # in granting sets, named after the model's loaded STI subclasses.
340
+ def subclass_rules(names)
341
+ resources = PermissionForge::Resource.in_effect.where(name: names)
342
+ .where(PermissionForge::PermissionSet.table_name => { is_muting: false })
343
+ PermissionForge::PermissionSetRule.active.where(resource_id: resources.select(:id)).includes(:permission_set)
344
+ end
345
+
346
+ # A field_name that is not a name is translated, whatever the rule is:
347
+ # to FALSE, as the app layer matches nothing for it.
348
+ def untranslatable_reason(rule)
349
+ return unless Operators::RuleColumn.name?(rule.field_name)
350
+ return :join_rule if rule.join_rule?
351
+ return :dotted_field_name if rule.field_name.to_s.include?('.')
352
+ return :unknown_operator unless OPERATOR_SQL.key?(rule.operator)
353
+
354
+ nil
355
+ end
356
+
357
+ # The compiled SQL is part of it: what a rule compiles to also depends on
358
+ # the model (the column's type, an enum's stored values). All text, so
359
+ # the fingerprints of one set sort whatever the rules are.
360
+ def rule_fingerprint(rule)
361
+ rule.values_at(:operator, :field_name, :field_value, :include_nulls).map(&:to_s) << rule_sql(rule)
362
+ end
363
+ end
364
+ end
365
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ module SecurityAuditChecks
6
+ # Controller actions as permissions: managed actions nobody is permitted
7
+ # to run, managed actions whose controller does not run the guard, rows
8
+ # whose name can never be an action key, rows that match no controller
9
+ # action, and discovery switched on while no controller includes the guard.
10
+ class ActionCheck < BaseCheck
11
+ # One action row in effect (PermissionForge::ResourceAction.in_effect).
12
+ LiveRow = Struct.new(:tenant, :resource, :action, :permitted, :muting, :set) do
13
+ def target = [tenant, resource, action]
14
+ def key = [resource, action]
15
+ def label = "#{resource} › #{action}"
16
+ def grants? = permitted && !muting
17
+ def valid_key? = action.match?(PermissionForge::ControllerActions::KEY_FORMAT)
18
+
19
+ # 'Sales › Customer › "archive "' — quoted, so stray spaces show.
20
+ def quoted_label = "#{set} › #{resource} › #{action.inspect}"
21
+ end
22
+
23
+ # @param catalog [PermissionForge::Admin::ControllerCatalog, nil] the
24
+ # app's controller actions; read from the routes on first use when nil
25
+ def initialize(configuration:, environment:, catalog: nil)
26
+ super(configuration: configuration, environment: environment)
27
+ @catalog = catalog
28
+ end
29
+
30
+ def call
31
+ [ungranted_actions, unguarded_actions, invalid_keys, unlinked_actions, guard_missing].compact
32
+ end
33
+
34
+ private
35
+
36
+ def discovery?
37
+ configuration.controller_action_discovery?
38
+ end
39
+
40
+ def catalog
41
+ @catalog ||= PermissionForge::Admin::ControllerCatalog.new(configuration: configuration)
42
+ end
43
+
44
+ # { 'Customer' => { 'export' => [ControllerCatalog::Entry, ...] } }
45
+ def endpoints
46
+ @endpoints ||= Hash.new { |index, resource| index[resource] = catalog.for_resource(resource).group_by(&:key) }
47
+ end
48
+
49
+ # Managed, and reachable through a controller that does not run the
50
+ # guard: there the row restricts nobody unless the code checks can?.
51
+ # Covers a partial rollout, which guard_missing (no guard anywhere)
52
+ # does not see.
53
+ def unguarded_actions
54
+ return unless discovery?
55
+
56
+ items = live_rows.map(&:key).uniq.sort.filter_map { |resource, action| unguarded_item(resource, action) }
57
+ return if items.empty?
58
+
59
+ finding(:medium, :unguarded_actions, 'Managed actions on controllers that do not run the guard',
60
+ 'A Permission Set manages these actions, but the listed controller actions run in a controller ' \
61
+ 'that does not include PermissionForge::Concerns::ControllerActionGuard (or skips it for every ' \
62
+ 'action). Through those routes everyone can still run the action, whatever the admin shows, ' \
63
+ 'unless your code checks can?. Include the guard in those controllers.', items: items)
64
+ end
65
+
66
+ # "Customer › merge: customers/merges#create", or nil when every
67
+ # controller action behind the key runs the guard (or none maps to it).
68
+ def unguarded_item(resource, action)
69
+ unguarded = endpoints[resource].fetch(action, []).reject(&:guarded?).map(&:endpoint).join(', ')
70
+ "#{resource} › #{action}: #{unguarded}" unless unguarded.empty?
71
+ end
72
+
73
+ # Managed (a row is in effect) but permitted by no granting set: the
74
+ # guard and can? refuse everyone.
75
+ def ungranted_actions
76
+ ungranted = live_rows.group_by(&:target).values.reject { |rows| rows.any?(&:grants?) }
77
+ return if ungranted.empty?
78
+
79
+ finding(:low, :ungranted_actions, 'Managed actions nobody is permitted to run',
80
+ 'A row for these actions is in effect, so only people whose Permission Sets permit them can ' \
81
+ 'run them — and no active granting set does. Permit each one in a set, or remove its rows to ' \
82
+ 'stop managing it.', items: ungranted.map { |rows| ungranted_item(rows) }.uniq.sort)
83
+ end
84
+
85
+ def ungranted_item(rows)
86
+ "#{rows.first.label}: only in #{rows.map(&:set).uniq.sort.join(', ')}"
87
+ end
88
+
89
+ # Rows in effect under a name that is not an action key. The admin and
90
+ # the JSON API refuse such names; older rows, seeds and other apps
91
+ # writing to these tables may still carry them. The guard only matches
92
+ # one when a controller action is literally called that.
93
+ def invalid_keys
94
+ invalid = live_rows.reject(&:valid_key?)
95
+ return if invalid.empty?
96
+
97
+ finding(:low, :invalid_action_keys, 'Action rows whose name is not a valid action key',
98
+ 'Action keys are lowercase letters, digits and underscores, starting with a letter. A row ' \
99
+ 'under any other name is not the key of the action it looks like ("Export" does not cover ' \
100
+ '"export"), so that action stays unmanaged while the admin shows a row for it. The row only ' \
101
+ 'takes effect where your code checks can? with exactly this name, or where a controller ' \
102
+ 'action is literally called that. Rename each row to the key of the action it is for.',
103
+ items: invalid.map(&:quoted_label).uniq.sort)
104
+ end
105
+
106
+ def unlinked_actions
107
+ return unless discovery?
108
+
109
+ unlinked = configured_actions.reject { |resource, action| endpoints[resource].key?(action) }
110
+ return if unlinked.empty?
111
+
112
+ finding(:info, :unlinked_actions, 'Actions not linked to a controller action',
113
+ 'No controller action maps to these keys. That is expected for an action your code checks ' \
114
+ 'with can?. Otherwise the key is misspelled or its route is gone, and the row does nothing.',
115
+ items: unlinked.map { |resource, action| "#{resource} › #{action}" })
116
+ end
117
+
118
+ def guard_missing
119
+ return unless discovery? && !catalog.any_guarded?
120
+
121
+ finding(live_rows.any? ? :medium : :low, :action_guard_missing,
122
+ 'Controller actions are listed, but no controller checks them',
123
+ 'controller_action_discovery is on, yet no listed controller includes ' \
124
+ 'PermissionForge::Concerns::ControllerActionGuard. Permitting or blocking an action in the ' \
125
+ 'admin changes nothing at runtime unless your code checks can?. Include the guard in the ' \
126
+ 'controllers you want checked.')
127
+ end
128
+
129
+ def live_rows
130
+ @live_rows ||= PermissionForge::ResourceAction.in_effect.pluck(*live_columns).map { |values| LiveRow.new(*values) }
131
+ end
132
+
133
+ # "Managed" is per tenant, so rows of different tenants never cover each other.
134
+ def live_columns
135
+ [tenant_column, column(PermissionForge::Resource, :name), column(PermissionForge::ResourceAction, :name),
136
+ column(PermissionForge::ResourceAction, :is_permitted), column(PermissionForge::PermissionSet, :is_muting),
137
+ column(PermissionForge::PermissionSet, :name)]
138
+ end
139
+
140
+ def tenant_column
141
+ name = configuration.resolved_tenant_column
142
+ name ? column(PermissionForge::Resource, name) : Arel.sql('NULL')
143
+ end
144
+
145
+ def column(model, name)
146
+ model.arel_table[name]
147
+ end
148
+
149
+ # [[resource name, action key], ...] for every action row, on or off.
150
+ def configured_actions
151
+ resource_name = column(PermissionForge::Resource, :name)
152
+ PermissionForge::ResourceAction.joins(:resource).distinct.order(resource_name, :name).pluck(resource_name, :name)
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end