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,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ module SecurityAuditChecks
6
+ # Personal data shown unmasked, from GdprFieldAuditor. Fields an admin
7
+ # marked "This is intended" in the privacy audit are left out.
8
+ class PersonalDataCheck < BaseCheck
9
+ def call
10
+ report = GdprFieldAuditor.call(configuration: configuration)
11
+ [unmasked(report), implicit_virtual(report), cross_set(report), unmapped(report)].compact
12
+ end
13
+
14
+ private
15
+
16
+ def unmasked(report)
17
+ rows = report.select { |row| row[:type] == :unmasked_field && acknowledged_ids.exclude?(row[:resource_field_id]) }
18
+ return if rows.empty?
19
+
20
+ finding(:medium, :unmasked_personal_data, 'Personal data readable unmasked',
21
+ 'These fields look like personal data and are readable without a mask. Mask them, or mark ' \
22
+ 'them "This is intended" in the admin privacy audit if that access is deliberate.',
23
+ items: rows.map do |row|
24
+ "#{row[:permission_set]} › #{row[:resource_name]}.#{row[:field_name]} (suggested: #{row[:suggested_strategy]})"
25
+ end)
26
+ end
27
+
28
+ # Declared virtual fields left on the implicit grant: readable and
29
+ # editable, unmasked, without anyone having chosen that. A field whose
30
+ # only row is switched off is one of them.
31
+ def implicit_virtual(report)
32
+ rows = report.select { |row| row[:type] == :implicit_virtual_grant }
33
+ return if rows.empty?
34
+
35
+ finding(:medium, :implicit_virtual_personal_data, 'Personal data in virtual fields granted by default',
36
+ 'These virtual fields look like personal data and have no active field row, so ' \
37
+ 'extra_fields_default_access = :grant makes them readable and editable, unmasked. Give them a ' \
38
+ 'level, or set extra_fields_default_access = :unconfigured so they follow unconfigured_field_strategy.',
39
+ items: rows.map do |row|
40
+ "#{row[:permission_set]} › #{row[:resource_name]}.#{row[:field_name]} (suggested: #{row[:suggested_strategy]})"
41
+ end)
42
+ end
43
+
44
+ def cross_set(report)
45
+ rows = report.select { |row| row[:type] == :cross_set_unmasking }
46
+ return if rows.empty?
47
+
48
+ finding(:medium, :cross_set_unmasking, 'One set unmasks what another masks',
49
+ 'Masks combine with AND: anyone holding both sets sees the full value.',
50
+ items: rows.map { |row| "#{row[:permission_set]} › #{row[:resource_name]}.#{row[:field_name]}" })
51
+ end
52
+
53
+ def unmapped(report)
54
+ rows = report.select { |row| row[:type] == :unmapped_column }
55
+ return if rows.empty?
56
+
57
+ passes = %w[pass_through warn].include?(configuration.unconfigured_field_strategy.to_s)
58
+ detail = if passes
59
+ 'unconfigured_field_strategy lets them through unfiltered. Add a field row, or switch ' \
60
+ 'the existing one back on.'
61
+ else
62
+ 'They are blocked from filtered responses until configured; add a field row (or switch ' \
63
+ 'the existing one back on) to decide.'
64
+ end
65
+ finding(passes ? :high : :low, :unmapped_personal_data, 'Personal-data columns with no active field row', detail,
66
+ items: rows.map { |row| "#{row[:resource]}.#{row[:column]}" })
67
+ end
68
+
69
+ def acknowledged_ids
70
+ @acknowledged_ids ||= if PermissionForge::Admin::AttentionItems.acknowledgement_supported?
71
+ PermissionForge::ResourceField.where.not(sensitive_acknowledged_at: nil).pluck(:id).to_set
72
+ else
73
+ Set.new
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ module SecurityAuditChecks
6
+ # Privileged access: View All / Modify All, sets that look temporary,
7
+ # and sets that grant delete almost everywhere.
8
+ class PrivilegeCheck < BaseCheck
9
+ TEMPORARY_NAME = /\b(dev|debug|dbg|test|tmp|temp|sandbox|demo)\b|full access/i
10
+ BROAD_DELETE_MIN = 10
11
+ BROAD_DELETE_SHARE = 0.75
12
+
13
+ def call
14
+ [bypass_grants, temporary_sets, broad_delete].compact
15
+ end
16
+
17
+ private
18
+
19
+ def granting_resources
20
+ @granting_resources ||= PermissionForge::Resource.active.where(permission_set_id: active_granting_sets.select(:id))
21
+ .includes(:permission_set).order(:name).to_a
22
+ end
23
+
24
+ def bypass_grants
25
+ bypassing = granting_resources.select(&:rules_bypassed?)
26
+ return if bypassing.empty?
27
+
28
+ held = bypassing.any? { |resource| people_counts.fetch(resource.permission_set_id, 0).positive? }
29
+ finding(held ? :medium : :low, :bypass_grants, 'View All / Modify All skip every record rule',
30
+ 'People with these grants see (and with Modify All change) every record of the resource. ' \
31
+ 'Keep them for administrators and give them an end date where possible.',
32
+ items: bypassing.map { |resource| bypass_item(resource) })
33
+ end
34
+
35
+ def bypass_item(resource)
36
+ grant = resource.modify_all ? 'View All + Modify All' : 'View All'
37
+ "#{resource.permission_set.name} › #{resource.name}: #{grant} · #{people(resource.permission_set_id)}"
38
+ end
39
+
40
+ def temporary_sets
41
+ sets = PermissionForge::PermissionSet.active.order(:name).select { |set| set.name.match?(TEMPORARY_NAME) }
42
+ return if sets.empty?
43
+
44
+ held = sets.map(&:id).any? { |id| people_counts.key?(id) }
45
+ finding(held ? :medium : :low, :temporary_sets, 'Sets that look temporary are still active',
46
+ 'Their names suggest development, debugging or testing. Confirm they belong in this ' \
47
+ 'environment, or delete them.',
48
+ items: sets.map { |set| "#{set.name} · #{people(set.id)}" })
49
+ end
50
+
51
+ def broad_delete
52
+ broad = granting_resources.group_by(&:permission_set).filter_map do |set, resources|
53
+ deletable = resources.count(&:delete_access)
54
+ next if deletable < BROAD_DELETE_MIN || deletable < resources.size * BROAD_DELETE_SHARE
55
+
56
+ "#{set.name}: delete on #{deletable} of #{resources.size} resources · #{people(set.id)}"
57
+ end
58
+ return if broad.empty?
59
+
60
+ finding(:low, :broad_delete, 'Sets grant delete almost everywhere',
61
+ 'Delete is rarely needed broadly. If people other than administrators hold these sets, ' \
62
+ 'move delete into a separate set.', items: broad)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ module SecurityAuditChecks
6
+ # Record (row-level) rules: whether the app's query scoping applies them,
7
+ # rules whose value makes them match nothing, rules View All makes
8
+ # pointless, and likely misconfigurations.
9
+ class RuleCheck < BaseCheck
10
+ TEXT_OPERATORS = %w[contains starts_with ends_with].freeze
11
+ # What `current_user` is typed as here: an id is a number or its text,
12
+ # whoever the user is, and never a boolean or a date.
13
+ ANY_USER_ID = 0
14
+
15
+ def call
16
+ return [no_rules] unless active_rules.exists?
17
+
18
+ [unenforced, unusable_values, bypassed, text_operator_on_ids].compact
19
+ end
20
+
21
+ private
22
+
23
+ def active_rules
24
+ PermissionForge::PermissionSetRule.active
25
+ end
26
+
27
+ def no_rules
28
+ finding(:info, :no_rules, 'No record rules are in use',
29
+ 'Anyone with read access to a resource sees every record of it. That is fine if your app ' \
30
+ 'limits records itself; otherwise add rules and enable auto_query_scoping.')
31
+ end
32
+
33
+ def unenforced
34
+ findings = PermissionForge::Admin::AttentionItems.new(configuration: configuration).unenforced_rules
35
+ return if findings.empty?
36
+
37
+ held = findings.any? { |item| item.people_count.positive? }
38
+ finding(held ? :high : :medium, :unenforced_rules, 'Record rules are not applied to every query',
39
+ unenforced_detail, items: findings.map { |item| unenforced_item(item) })
40
+ end
41
+
42
+ def unenforced_detail
43
+ if configuration.auto_query_scoping_enabled?
44
+ 'These are join rules: auto_query_scoping skips them, so only find_by / permission_scoped apply ' \
45
+ 'them. Route the queries through Model.permission_scoped.'
46
+ else
47
+ 'auto_query_scoping is off, so these rules only apply to find_by / permission_scoped; a bare ' \
48
+ 'where / all / association query returns every record. Enable auto_query_scoping = :filter_only ' \
49
+ '(see docs/query-scoping.md), and unscoped_query_detection to find the call sites.'
50
+ end
51
+ end
52
+
53
+ def unenforced_item(item)
54
+ "#{item.permission_set.name} › #{item.resource.name}: #{item.rule_count} rule(s) · " \
55
+ "#{people_label(item.people_count)}"
56
+ end
57
+
58
+ def unusable_values
59
+ rules = rules_in_effect.select { |rule| unusable_value?(rule) }
60
+ return if rules.empty?
61
+
62
+ held = rules.any? { |rule| people_counts.fetch(rule.resource.permission_set_id, 0).positive? }
63
+ finding(held ? :medium : :low, :unusable_rule_values, 'Rules that match nothing',
64
+ 'The value of these rules is not one their column can hold: text on a number column, a word that ' \
65
+ 'is no boolean, an unknown enum label, a date that is not ISO 8601 (on MySQL and SQLite), a list ' \
66
+ 'or range with the wrong number of values — or the field name is not a name (a trailing or ' \
67
+ 'doubled dot). Such a rule matches nothing: everyone it applies to is denied every record of ' \
68
+ 'the resource. Correct the rule or remove it (see "How Values Are Compared" in ' \
69
+ 'docs/row-level-rules.md).',
70
+ items: rules.map { |rule| unusable_item(rule) })
71
+ end
72
+
73
+ # Active rules of active resources in active granting sets: the rules
74
+ # that are applied right now (a muting set's rules never are). Not the
75
+ # ones View All skips — nobody is denied by those; they are listed
76
+ # under bypassed_rules.
77
+ def rules_in_effect
78
+ resources = PermissionForge::Resource.in_effect.merge(PermissionForge::PermissionSet.granting)
79
+ active_rules.where(resource_id: resources.select(:id)).includes(resource: :permission_set).order(:resource_id, :id)
80
+ .reject { |rule| rule.resource.rules_bypassed? }
81
+ end
82
+
83
+ # Typed the way the application layer types it (Operators::ValueTyping).
84
+ # A model that is not there, or cannot be read, says nothing about the
85
+ # value — that is the integrity check's finding, not one of this check.
86
+ def unusable_value?(rule)
87
+ target = rule_column(rule)
88
+ return false unless target
89
+
90
+ operator = rule.operator
91
+ value = rule.field_value
92
+ value = ANY_USER_ID if Operators::ValueTyping.current_user?(operator, value)
93
+ Operators::ValueTyping.call(operator, value, target).equal?(Operators::ValueTyping::UNUSABLE)
94
+ rescue ActiveRecord::ActiveRecordError, NameError, ArgumentError
95
+ false
96
+ end
97
+
98
+ # The column the rule names, found the way PermissionRuleApplier finds
99
+ # it. A rule whose association path cannot be followed is left out: the
100
+ # engine raises for it, it does not match nothing. Tables are checked
101
+ # first: asking a missing one for its columns fails the statement, and
102
+ # with it a transaction the audit may run in.
103
+ def rule_column(rule)
104
+ model = rule.resource.name.safe_constantize
105
+ return unless model.is_a?(Class) && model < ActiveRecord::Base
106
+
107
+ path = rule.join_rule? ? rule.field_accessor_path.to_s.split('.') : []
108
+ models = Operators::RuleColumn.models_along(model, path)
109
+ Operators::RuleColumn.find(rule.field_name, models) if models.any? && models.all?(&:table_exists?)
110
+ end
111
+
112
+ def unusable_item(rule)
113
+ resource = rule.resource
114
+ path = rule.field_accessor_path.presence if rule.join_rule?
115
+ field = [resource.name, path, rule.field_name].compact.join('.')
116
+ "#{resource.permission_set.name} › #{field} #{rule.operator} #{rule.field_value.inspect}: #{rule.name} · " \
117
+ "#{people(resource.permission_set_id)}"
118
+ end
119
+
120
+ def bypassed
121
+ rules = active_rules.includes(resource: :permission_set).select { |rule| rule.resource&.rules_bypassed? }
122
+ return if rules.empty?
123
+
124
+ finding(:low, :bypassed_rules, 'Rules ignored because View All is on',
125
+ 'View All / Modify All skip these rules. Remove the rule or the bypass so the setup says what it does.',
126
+ items: rules.map { |rule| "#{rule.resource.permission_set.name} › #{rule.resource.name}: #{rule.name}" })
127
+ end
128
+
129
+ def text_operator_on_ids
130
+ rules = active_rules.where(operator: TEXT_OPERATORS).includes(:resource)
131
+ .select { |rule| rule.field_name.to_s.end_with?('id') }
132
+ return if rules.empty?
133
+
134
+ finding(:low, :text_operator_on_ids, 'Text matching on id columns',
135
+ 'contains / starts_with / ends_with on an id matches more records than intended (12 contains 1). ' \
136
+ 'Use equal or in.', items: rules.map { |rule| "#{rule.resource.name}.#{rule.field_name}: #{rule.name}" })
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ module SecurityAuditChecks
6
+ # Plain-text rendering of a SecurityAudit::Report for terminals and logs.
7
+ class TextReport
8
+ def initialize(report)
9
+ @report = report
10
+ end
11
+
12
+ def to_s
13
+ [header, *@report.findings.map { |finding| section(finding) }, footer].join("\n\n")
14
+ end
15
+
16
+ private
17
+
18
+ def header
19
+ counts = @report.counts.map { |severity, count| "#{severity.to_s.capitalize}: #{count}" }.join(' · ')
20
+ ["PermissionForge security audit — #{@report.generated_at.utc.iso8601}", preset, counts].compact.join("\n")
21
+ end
22
+
23
+ def preset
24
+ case @report.preset
25
+ when nil then nil
26
+ when :compat then 'Preset: :compat (the library defaults; config.preset = :strict closes the fail-open ones)'
27
+ else "Preset: #{@report.preset.inspect}"
28
+ end
29
+ end
30
+
31
+ def section(finding)
32
+ lines = ["[#{finding.severity.to_s.upcase}] #{finding.title} (#{finding.check}/#{finding.code})",
33
+ " #{finding.detail}"]
34
+ lines.concat(Array(finding.items).map { |item| " - #{item}" })
35
+ lines.join("\n")
36
+ end
37
+
38
+ def footer
39
+ return 'No findings.' if @report.findings.empty?
40
+
41
+ 'Read-only: nothing was changed. FORMAT=json for machine output; FAIL_ON=high to fail CI.'
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ module SecurityAuditChecks
6
+ # The user model (config.user_class): what sign-in needs from it, which
7
+ # runs before anyone is signed in, and what it shows then.
8
+ class UserModelCheck < BaseCheck
9
+ RULES = %i[user_model_scoped user_model_unmasked_without_user].freeze
10
+
11
+ def call
12
+ return [] unless user_model
13
+
14
+ RULES.filter_map { |rule| send(rule) }
15
+ end
16
+
17
+ private
18
+
19
+ def user_model
20
+ return @user_model if defined?(@user_model)
21
+
22
+ model = configuration.user_class.to_s.safe_constantize
23
+ @user_model = model.is_a?(Class) ? model : nil
24
+ end
25
+
26
+ # Record rules on the user model, with nobody signed in denied.
27
+ def user_model_scoped
28
+ return unless user_model < PermissionForge::Concerns::PermissionScopedFindable
29
+ return unless %i[deny raise].include?(configuration.nil_user_strategy)
30
+
31
+ finding(:low, :user_model_scoped, 'The user model cannot be looked up before sign-in',
32
+ "#{user_model.name} includes PermissionScopedFindable and nil_user_strategy is " \
33
+ ":#{configuration.nil_user_strategy}: sign-in, session restore, password reset and " \
34
+ 'confirmation run before anyone is signed in, and their lookups (where, find_by, ' \
35
+ 'authenticate_by) find nothing. Remove the concern from the user model (scope a user ' \
36
+ 'directory with permission_scoped in its controller), or run those lookups inside ' \
37
+ 'PermissionForge.without_permission_scope.')
38
+ end
39
+
40
+ # :hide exempts the user model so sign-in can read it.
41
+ def user_model_unmasked_without_user
42
+ return unless configuration.hide_masked_attributes_without_user?
43
+
44
+ names = user_model_resource_names
45
+ return if names.empty?
46
+
47
+ finding(:low, :user_model_unmasked_without_user, 'The user model is not masked with nobody signed in',
48
+ 'masked_attributes_nil_user_strategy is :hide, but the user model is exempt so that sign-in ' \
49
+ "can read it: with nobody signed in, #{user_model.name}'s columns — password digests and " \
50
+ 'reset or confirmation tokens included — read as stored through its getters and as_json. ' \
51
+ 'Do not render it with no current user. Another authenticatable model (a second Devise ' \
52
+ 'scope) is not exempt: with a Resource row its sign-in reads hidden values.',
53
+ items: names)
54
+ end
55
+
56
+ # The Resource names that mask the user model: its own and its STI
57
+ # ancestors' (ResourceChain), and its subclasses'.
58
+ def user_model_resource_names
59
+ chain = PermissionForge::ResourceChain.names(user_model).map(&:to_s)
60
+ PermissionForge::Resource.distinct.order(:name).pluck(:name).select do |name|
61
+ chain.include?(name) || configuration.user_model_class?(name.safe_constantize)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ # Read-only security review of a PermissionForge setup: configuration,
6
+ # privileged grants, assignments, data integrity, record rules,
7
+ # personal-data exposure and controller actions. Backs
8
+ # `rake permission_forge:audit`.
9
+ #
10
+ # Each check is a class answering `#call` with an array of Finding,
11
+ # listed by `SecurityAudit.checks` (config, privilege, assignment, rule,
12
+ # integrity, personal data, controller actions, the audit trail and the
13
+ # user model);
14
+ # add one by passing `checks:` to `call` or by overriding `.checks`
15
+ # (OCP). Nothing is ever changed.
16
+ #
17
+ # @example
18
+ # report = SecurityAudit.call
19
+ # report.counts # => { critical: 0, high: 1, ... }
20
+ # report.at_least(:high) # => [#<Finding ...>]
21
+ #
22
+ # @api public
23
+ class SecurityAudit
24
+ SEVERITIES = %i[critical high medium low info].freeze
25
+
26
+ # @api private
27
+ Finding = Struct.new(:check, :severity, :code, :title, :detail, :items, keyword_init: true) do
28
+ def to_h = super.merge(items: items || [])
29
+ def rank = SEVERITIES.index(severity)
30
+ end
31
+
32
+ # preset: the configuration's preset (:compat or :strict).
33
+ #
34
+ # @api private
35
+ Report = Struct.new(:findings, :generated_at, :preset, keyword_init: true) do
36
+ def counts = SEVERITIES.index_with { |severity| findings.count { |finding| finding.severity == severity } }
37
+
38
+ def at_least(severity)
39
+ limit = SEVERITIES.index(severity.to_sym) || SEVERITIES.size
40
+ findings.select { |finding| finding.rank <= limit }
41
+ end
42
+
43
+ def to_h = { generated_at: generated_at.iso8601, preset: preset, counts: counts, findings: findings.map(&:to_h) }
44
+ end
45
+
46
+ # @return [Array<Class>] the check classes run by default
47
+ def self.checks
48
+ [
49
+ SecurityAuditChecks::ConfigCheck, SecurityAuditChecks::PrivilegeCheck,
50
+ SecurityAuditChecks::AssignmentCheck, SecurityAuditChecks::RuleCheck,
51
+ SecurityAuditChecks::IntegrityCheck, SecurityAuditChecks::PersonalDataCheck,
52
+ SecurityAuditChecks::ActionCheck, SecurityAuditChecks::AuditTrailCheck,
53
+ SecurityAuditChecks::UserModelCheck
54
+ ]
55
+ end
56
+
57
+ # Runs the read-only security review.
58
+ # @return [Report]
59
+ def self.call(...) = new(...).call
60
+
61
+ def initialize(configuration: PermissionForge.configuration, checks: self.class.checks,
62
+ environment: defined?(Rails) ? Rails.env.to_s : 'development')
63
+ @configuration = configuration
64
+ @checks = checks
65
+ @environment = environment
66
+ end
67
+
68
+ # @return [Report] the findings, most severe first
69
+ def call
70
+ findings = @checks.flat_map { |check| check.new(configuration: @configuration, environment: @environment).call }
71
+ Report.new(findings: findings.sort_by(&:rank), generated_at: Time.current, preset: @configuration.preset)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,68 @@
1
+ module PermissionForge
2
+ module Services
3
+ # Audits permanent permission set assignments that haven't been reviewed
4
+ # within a configurable threshold (default: 90 days). Reviewed means the
5
+ # last access review "Keep" (reviewed_at), else the grant (created_at) —
6
+ # PermissionSetUser.stale_before; editing an assignment is not a review.
7
+ # Supports GDPR Art. 5(1)(e) — Storage Limitation.
8
+ #
9
+ # @example Find stale assignments
10
+ # result = StaleAssignmentAuditor.call
11
+ # # => { stale_count: 5, affected_user_ids: [1, 2], assignments: [...] }
12
+ #
13
+ # @example Custom threshold
14
+ # result = StaleAssignmentAuditor.call(threshold_days: 30)
15
+ #
16
+ # With a tenant ambient, that tenant's assignments; with none, every
17
+ # tenant's — also under acts_as_tenant's require_tenant
18
+ # (Concerns::AcrossTenants).
19
+ #
20
+ # @api public
21
+ class StaleAssignmentAuditor
22
+ include Concerns::AcrossTenants
23
+
24
+ # @param threshold_days [Integer] how long a permanent assignment may go unreviewed
25
+ # @return [Hash] see {#call}
26
+ def self.call(...) = new(...).call
27
+
28
+ def initialize(threshold_days: 90)
29
+ @threshold_days = threshold_days
30
+ end
31
+
32
+ # @return [Hash] `{ stale_count:, affected_user_ids:, assignments: }`
33
+ def call
34
+ across_tenants do
35
+ stale = stale_assignments
36
+ {
37
+ stale_count: stale.count,
38
+ affected_user_ids: stale.distinct.pluck(:user_id),
39
+ threshold_days: @threshold_days,
40
+ assignments: stale.includes(:permission_set).map { |psu| row_for(psu) }
41
+ }
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def row_for(psu)
48
+ last_reviewed = psu.last_reviewed_at
49
+ {
50
+ user_id: psu.user_id,
51
+ permission_set: psu.permission_set&.name,
52
+ last_updated: psu.updated_at,
53
+ last_reviewed_at: last_reviewed,
54
+ reviewed_by_id: psu.try(:reviewed_by_id),
55
+ days_stale: ((Time.current - last_reviewed) / 1.day).round
56
+ }
57
+ end
58
+
59
+ def stale_assignments
60
+ PermissionForge::PermissionSetUser.stale_before(threshold_cutoff)
61
+ end
62
+
63
+ def threshold_cutoff
64
+ @threshold_days.days.ago
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ # Level 2 — DETECT (config.unscoped_query_detection = :log | :raise).
6
+ #
7
+ # Subscribes to sql.active_record and reports SELECT/UPDATE/DELETE
8
+ # statements that touch a protected model's table without carrying the
9
+ # pf_scoped annotation (added by permission_scoped, Level 1 auto-scoping,
10
+ # and internal gem paths). INSERTs, schema/EXPLAIN queries, and anything
11
+ # executed inside PermissionForge.without_permission_scope (which also
12
+ # covers the gem's own internal probes) are never flagged.
13
+ #
14
+ # :log -> Rails.logger.warn with the SQL and the first app call site
15
+ # :raise -> PermissionForge::UnscopedQueryError
16
+ #
17
+ # Either way the finding is published as query.unscoped (model, table,
18
+ # mode, call site — not the SQL, which can carry values), at most once per
19
+ # request per table.
20
+ #
21
+ # Detection is environment-gated via
22
+ # config.unscoped_query_detection_environments (default: development, test)
23
+ # — it is a migration aid and CI guard, not a production control. The
24
+ # notification subscriber runs synchronously on the querying thread, so
25
+ # :raise surfaces at the offending call site.
26
+ #
27
+ # @api private
28
+ class UnscopedQueryDetector
29
+ MARKER = "/* #{PermissionForge::Concerns::PermissionScopedFindable::SCOPED_ANNOTATION} */".freeze
30
+ STATEMENT_PATTERN = /\A\s*(?:SELECT|UPDATE|DELETE)\b/i
31
+ IGNORED_PAYLOAD_NAMES = ['SCHEMA', 'EXPLAIN', nil].freeze
32
+
33
+ class << self
34
+ # Installs the sql.active_record subscriber (idempotent).
35
+ def install!
36
+ # rubocop:disable-next Naming/MemoizedInstanceVariableName -- @subscriber is the handle uninstall! needs
37
+ @subscriber ||= ActiveSupport::Notifications.subscribe('sql.active_record') do |*, payload|
38
+ check(payload)
39
+ end
40
+ end
41
+
42
+ # Removes the subscriber (used by tests to isolate detection state).
43
+ def uninstall!
44
+ return unless @subscriber
45
+
46
+ ActiveSupport::Notifications.unsubscribe(@subscriber)
47
+ @subscriber = nil
48
+ end
49
+
50
+ def installed?
51
+ !@subscriber.nil?
52
+ end
53
+
54
+ # Evaluates a single sql.active_record payload. Public for direct
55
+ # testing without a live subscriber.
56
+ def check(payload)
57
+ return unless PermissionForge.configuration.unscoped_query_detection_active?
58
+ return if PermissionForge.scope_suspended?
59
+ return if IGNORED_PAYLOAD_NAMES.include?(payload[:name])
60
+
61
+ sql = payload[:sql].to_s
62
+ return unless sql.match?(STATEMENT_PATTERN)
63
+ return if sql.include?(MARKER)
64
+
65
+ table = protected_table_in(sql)
66
+ return unless table
67
+
68
+ report(sql, table)
69
+ end
70
+
71
+ private
72
+
73
+ def protected_table_in(sql)
74
+ PermissionForge::Concerns::PermissionScopedFindable.protected_table_names.find do |table|
75
+ sql.match?(/\b#{Regexp.escape(table)}\b/)
76
+ end
77
+ end
78
+
79
+ def report(sql, table)
80
+ publish(table)
81
+ message = I18n.t(
82
+ 'permission_forge.errors.unscoped_query',
83
+ table: table,
84
+ default: "Unscoped query on protected table '#{table}'. Use permission_scoped / find_by, " \
85
+ 'enable auto_query_scoping, or wrap intentional access in ' \
86
+ 'PermissionForge.without_permission_scope { }.'
87
+ )
88
+
89
+ raise PermissionForge::UnscopedQueryError, "#{message} SQL: #{sql}" if raise_mode?
90
+
91
+ Rails.logger&.warn("[PermissionForge] #{message} SQL: #{truncate_sql(sql)}#{app_call_site}")
92
+ end
93
+
94
+ def publish(table)
95
+ return unless PermissionForge::Audit.instrument?(PermissionForge::Audit::Runtime::UNSCOPED)
96
+
97
+ PermissionForge::Audit::Runtime.unscoped(
98
+ model: protected_model_for(table), table: table,
99
+ data: { 'mode' => PermissionForge.configuration.unscoped_query_detection.to_s, 'call_site' => cleaned_backtrace_frame }
100
+ )
101
+ end
102
+
103
+ def protected_model_for(table)
104
+ PermissionForge::Concerns::PermissionScopedFindable.protected_model_names.find do |model_name|
105
+ model_name.safe_constantize&.table_name == table
106
+ end
107
+ end
108
+
109
+ def raise_mode?
110
+ PermissionForge.configuration.unscoped_query_detection == :raise
111
+ end
112
+
113
+ def truncate_sql(sql)
114
+ sql.length > 300 ? "#{sql[0, 300]}…" : sql
115
+ end
116
+
117
+ # The subscriber runs synchronously on the querying thread, so caller
118
+ # still contains the app frame that triggered the query.
119
+ def app_call_site
120
+ frame = cleaned_backtrace_frame
121
+ frame ? " Called from: #{frame}" : ''
122
+ end
123
+
124
+ def cleaned_backtrace_frame
125
+ return caller.first unless defined?(Rails) && Rails.respond_to?(:backtrace_cleaner)
126
+
127
+ Rails.backtrace_cleaner.clean(caller).first
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end