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,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ module SecurityAuditChecks
6
+ # Who holds access: stale, orphaned and leftover assignments, inactive
7
+ # users, people stacking many sets.
8
+ class AssignmentCheck < BaseCheck
9
+ MANY_SETS = 3
10
+
11
+ def call
12
+ [stale, missing_users, inactive_users, expired_uncleaned, many_sets, inactive_sets_assigned,
13
+ all_permanent].compact
14
+ end
15
+
16
+ private
17
+
18
+ def active
19
+ PermissionForge::PermissionSetUser.active_assignments
20
+ end
21
+
22
+ def stale_days
23
+ configuration.try(:admin_stale_assignment_days) || 90
24
+ end
25
+
26
+ def stale
27
+ result = StaleAssignmentAuditor.call(threshold_days: stale_days)
28
+ return if result[:stale_count].zero?
29
+
30
+ labels = labels_for(result[:assignments].pluck(:user_id))
31
+ finding(:medium, :stale_assignments, "Permanent access not reviewed in #{stale_days} days",
32
+ 'Confirm these people still need it (Keep on the admin Home), or give it an end date.',
33
+ items: result[:assignments].map do |row|
34
+ "#{labels[row[:user_id]]} — #{row[:permission_set]} (#{row[:days_stale]} days)"
35
+ end)
36
+ end
37
+
38
+ def missing_users
39
+ ids = active.distinct.pluck(:user_id) - user_ids
40
+ return if ids.empty?
41
+
42
+ finding(:medium, :missing_users, 'Assignments for users that no longer exist',
43
+ 'These rows grant access to ids the user table no longer has; a new user reusing an id ' \
44
+ 'would inherit it. Remove them.', items: ids.sort.map { |id| "User ##{id}" })
45
+ end
46
+
47
+ def inactive_users
48
+ check = configuration.user_active_check
49
+ return unless check
50
+
51
+ inactive = assigned_users.reject { |user| check.call(user) }
52
+ return if inactive.empty?
53
+
54
+ finding(:medium, :inactive_users, 'Deactivated users still hold access',
55
+ 'user_active_check says these users are inactive, but their assignments remain and apply if ' \
56
+ 'they are reactivated or use another way in. Remove the access.',
57
+ items: inactive.map { |user| label(user) })
58
+ end
59
+
60
+ def expired_uncleaned
61
+ count = PermissionForge::PermissionSetUser.expired.count
62
+ return if count.zero?
63
+
64
+ finding(:low, :expired_uncleaned, "#{count} expired assignment(s) not cleaned up",
65
+ 'Expired assignments no longer grant access but clutter reviews. Run ' \
66
+ '`rake permission_forge:cleanup_expired_assignments` or use Clean up on the admin Home.')
67
+ end
68
+
69
+ def many_sets
70
+ heavy = active.group(:user_id).count.select { |_, count| count >= MANY_SETS }
71
+ return if heavy.empty?
72
+
73
+ labels = labels_for(heavy.keys)
74
+ finding(:low, :many_sets, "People holding #{MANY_SETS} or more sets",
75
+ 'Granting sets add up (most permissive wins). Check their effective access on the admin Person page.',
76
+ items: heavy.sort_by { |_, count| -count }.map { |id, count| "#{labels[id]}: #{count} sets" })
77
+ end
78
+
79
+ def inactive_sets_assigned
80
+ sets = PermissionForge::PermissionSet.where(is_active: false).order(:name)
81
+ .select { |set| people_counts.fetch(set.id, 0).positive? }
82
+ return if sets.empty?
83
+
84
+ finding(:low, :inactive_sets_assigned, 'Turned-off sets still have people',
85
+ 'They grant nothing while off, but turning one on restores access silently.',
86
+ items: sets.map { |set| "#{set.name} · #{people(set.id)}" })
87
+ end
88
+
89
+ def all_permanent
90
+ total = active.count
91
+ return if total.zero? || active.where.not(expires_at: nil).exists?
92
+
93
+ finding(:info, :all_permanent, "All #{total} assignment(s) are permanent",
94
+ 'Consider end dates for temporary needs (contractors, projects, cover).')
95
+ end
96
+
97
+ def assigned_users
98
+ @assigned_users ||= user_model.where(id: active.distinct.select(:user_id)).to_a
99
+ end
100
+
101
+ def user_ids
102
+ @user_ids ||= user_model.where(id: active.distinct.select(:user_id)).pluck(:id)
103
+ end
104
+
105
+ def labels_for(ids)
106
+ users = user_model.where(id: ids).index_by(&:id)
107
+ ids.index_with { |id| users[id] ? label(users[id]) : "User ##{id}" }
108
+ end
109
+
110
+ def label(user)
111
+ PermissionForge::Admin::PersonLabel.new(user, configuration: configuration).name
112
+ end
113
+
114
+ def user_model
115
+ configuration.user_model
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ module SecurityAuditChecks
6
+ # The audit event log ({PermissionForge::Audit}): is it recording, can
7
+ # its actors be trusted, and are the failure settings strict enough.
8
+ # Event queries use the indexed event / actor_type / occurred_at columns
9
+ # and follow the tenant scoping of the AuditEvent model like any other.
10
+ class AuditTrailCheck < BaseCheck
11
+ RULES = %i[
12
+ table_missing audit_off shared_basic_auth anonymous_api unknown_actors self_granted
13
+ default_runtime_events hook_failures_only_logged change_failures_not_raised kept_forever
14
+ ].freeze
15
+
16
+ UNKNOWN_ACTOR_DAYS = 30
17
+ SUBJECT_LIMIT = 10
18
+ SELF_GRANT_SCAN_LIMIT = 5000
19
+
20
+ def call
21
+ RULES.filter_map { |rule| send(rule) }
22
+ end
23
+
24
+ private
25
+
26
+ def audit_events = PermissionForge::AuditEvent
27
+
28
+ # true, false, or nil when the database could not be asked.
29
+ def table?
30
+ return @table if defined?(@table)
31
+
32
+ @table = audit_events.table_exists?
33
+ rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
34
+ @table = nil
35
+ end
36
+
37
+ def table_missing
38
+ return if configuration.audit_events == false || table? != false
39
+
40
+ finding(:high, :audit_table_missing, 'The audit events table is missing',
41
+ "audit_events is #{configuration.audit_events.inspect} but the table #{audit_events.table_name} " \
42
+ "does not exist, so #{missing_table_effect}. Run bin/rails generate permission_forge:upgrade " \
43
+ 'and migrate.')
44
+ end
45
+
46
+ # :auto records nothing without the table; true tries every time.
47
+ def missing_table_effect
48
+ return 'no permission change is recorded (changes still go through)' unless configuration.audit_events == true
49
+ return 'every permission change fails (audit_change_failure_strategy :raise)' if
50
+ configuration.audit_change_failure_strategy == :raise
51
+
52
+ 'every permission change goes through unrecorded (audit_change_failure_strategy ' \
53
+ ":#{configuration.audit_change_failure_strategy})"
54
+ end
55
+
56
+ def audit_off
57
+ return unless configuration.audit_events == false
58
+
59
+ finding(:high, :audit_off, 'The audit event log is switched off',
60
+ 'audit_events is false: nobody can later tell who granted, changed or revoked access. Set it ' \
61
+ 'to :auto (or true) unless another system records these changes.')
62
+ end
63
+
64
+ def shared_basic_auth
65
+ return unless configuration.http_basic_auth_enabled && configuration.audit_actor_resolver.nil?
66
+
67
+ finding(:medium, :audit_shared_basic_auth, 'Admin changes can be attributed to one shared login',
68
+ 'The admin UI uses HTTP Basic auth and audit_actor_resolver is not set, so when ' \
69
+ 'current_user_resolver finds nobody in an admin request, its events name the shared ' \
70
+ 'username, not a person. Set audit_actor_resolver to return the signed-in admin.')
71
+ end
72
+
73
+ def anonymous_api
74
+ return unless configuration.api_enabled && configuration.audit_actor_resolver.nil?
75
+
76
+ finding(:medium, :audit_anonymous_api, 'API changes can be recorded as an anonymous API client',
77
+ 'The JSON API is enabled and audit_actor_resolver is not set, so when current_user_resolver ' \
78
+ 'finds nobody in an API request (a token or API key), its events name an anonymous ' \
79
+ '"API client", not the caller. Set audit_actor_resolver to return the token, key or user.')
80
+ end
81
+
82
+ def unknown_actors
83
+ return unless table?
84
+
85
+ count = audit_events.where(category: 'change', actor_type: 'unknown', occurred_at: UNKNOWN_ACTOR_DAYS.days.ago..)
86
+ .count
87
+ return if count.zero?
88
+
89
+ finding(:medium, :audit_unknown_actors,
90
+ "#{count} #{count == 1 ? 'change' : 'changes'} in #{UNKNOWN_ACTOR_DAYS} days " \
91
+ "#{count == 1 ? 'has' : 'have'} no known actor",
92
+ 'Changes were made where no user, API caller or system label was available (a console, job or ' \
93
+ 'script outside PermissionForge.with_audit_actor). Wrap such work in with_audit_actor, or set ' \
94
+ 'current_user_resolver / audit_actor_resolver.')
95
+ end
96
+
97
+ def self_granted
98
+ return unless table?
99
+
100
+ grants = self_grant_events
101
+ return if grants.empty?
102
+
103
+ held = PermissionForge::PermissionSetUser.active_assignments.where(id: grants.map(&:first)).pluck(:user_id).uniq
104
+ return if held.empty?
105
+
106
+ finding(:high, :audit_self_granted,
107
+ "#{people_label(held.size)} #{held.size == 1 ? 'holds' : 'hold'} access they granted to themselves",
108
+ 'The assignment.granted event names the same person as grantor and recipient. Confirm each ' \
109
+ 'was approved by someone else, and restrict who may assign sets (admin_assignment_authorization).',
110
+ items: held.first(SUBJECT_LIMIT).map { |id| "user #{id}" })
111
+ end
112
+
113
+ # [assignment id, subject id] of grants whose actor is their subject.
114
+ def self_grant_events
115
+ table = audit_events.arel_table
116
+ audit_events.where(event: 'assignment.granted', actor_type: 'user',
117
+ record_type: PermissionForge::PermissionSetUser.base_class.name)
118
+ .where(table[:actor_id].eq(table[:subject_user_id]))
119
+ .order(occurred_at: :desc).limit(SELF_GRANT_SCAN_LIMIT).pluck(:record_id, :subject_user_id)
120
+ .map { |record_id, subject| [record_id.to_i, subject] }
121
+ end
122
+
123
+ def default_runtime_events
124
+ groups = Array(configuration.audit_runtime_events)
125
+ return unless (groups - [:denied]).empty?
126
+ return no_runtime_events if groups.empty?
127
+
128
+ finding(:info, :audit_runtime_default, 'Only denied access is recorded at runtime',
129
+ 'audit_runtime_events holds just :denied, so reads of masked fields, rule bypasses and cache ' \
130
+ 'rebuilds leave no event. Add groups (:bypass, :field_read, ...) if you must show who saw what.')
131
+ end
132
+
133
+ def no_runtime_events
134
+ finding(:info, :audit_runtime_default, 'No runtime events are stored',
135
+ 'No runtime events are stored (audit_runtime_events is empty): denied access, reads of masked ' \
136
+ 'fields, rule bypasses and cache rebuilds leave no event (they are still published as ' \
137
+ 'notifications). Add :denied at least, and other groups if you must show who saw what.')
138
+ end
139
+
140
+ def hook_failures_only_logged
141
+ return unless configuration.audit_hook_failure_strategy == :log
142
+
143
+ finding(:low, :audit_hook_failures_logged, 'Audit hook failures are only logged',
144
+ 'audit_hook_failure_strategy is :log: a failing subscriber or runtime event store is not ' \
145
+ 'handed to Rails.error. Use :report (default) so it reaches your error tracker.')
146
+ end
147
+
148
+ def change_failures_not_raised
149
+ return if configuration.audit_change_failure_strategy == :raise
150
+
151
+ finding(:medium, :audit_changes_unrecorded, 'A permission change can go through without its audit event',
152
+ "audit_change_failure_strategy is :#{configuration.audit_change_failure_strategy}: when the event " \
153
+ 'cannot be written the change still succeeds and the record is lost. Use :raise (default).')
154
+ end
155
+
156
+ def kept_forever
157
+ return unless configuration.audit_retention_period.nil?
158
+
159
+ finding(:info, :audit_kept_forever, 'Audit events are kept forever',
160
+ 'audit_retention_period is nil. Confirm this matches your retention policy, or set a period ' \
161
+ 'so the purge service can remove older events.')
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ # @api private
6
+ module SecurityAuditChecks
7
+ # Shared plumbing for SecurityAudit checks: subclasses implement #call
8
+ # and build findings with #finding.
9
+ class BaseCheck
10
+ ITEM_LIMIT = 50
11
+
12
+ def initialize(configuration:, environment:)
13
+ @configuration = configuration
14
+ @environment = environment
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :configuration
20
+
21
+ def production?
22
+ @environment == 'production'
23
+ end
24
+
25
+ def finding(severity, code, title, detail, items: [])
26
+ SecurityAudit::Finding.new(check: check_name, severity: severity, code: code, title: title,
27
+ detail: detail, items: capped(items))
28
+ end
29
+
30
+ def capped(items)
31
+ extra = items.size - ITEM_LIMIT
32
+ extra.positive? ? items.first(ITEM_LIMIT) << "… and #{extra} more" : items
33
+ end
34
+
35
+ def check_name
36
+ self.class.name.demodulize.delete_suffix('Check').underscore
37
+ end
38
+
39
+ def active_granting_sets
40
+ PermissionForge::PermissionSet.active.granting
41
+ end
42
+
43
+ def people_counts
44
+ @people_counts ||= PermissionForge::PermissionSetUser.active_assignments.group(:permission_set_id).count
45
+ end
46
+
47
+ def people(set_id)
48
+ people_label(people_counts.fetch(set_id, 0))
49
+ end
50
+
51
+ def people_label(count)
52
+ "#{count} #{count == 1 ? 'person' : 'people'}"
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ module SecurityAuditChecks
6
+ # Configuration hardening: who can reach the admin, whether the cache is
7
+ # shared, fail-closed flags, GDPR and audit settings, and the JSON API.
8
+ class ConfigCheck < BaseCheck
9
+ RULES = %i[
10
+ admin_ui_exposed process_local_cache unconfigured_fields_pass write_protection_off nested_parent_fallback
11
+ nil_user_bypass masked_nil_user_bypass gdpr_defaults_off log_filtering_off audit_trail_off conditional_sets_lenient
12
+ detection_raises_in_production api_without_auth api_mutations_unguarded api_sequential_ids
13
+ api_rule_values_exposed tenantless_admin strict_overrides
14
+ ].freeze
15
+
16
+ def call
17
+ RULES.filter_map { |rule| send(rule) }
18
+ end
19
+
20
+ private
21
+
22
+ def admin_ui_exposed
23
+ return if configuration.http_basic_auth_enabled
24
+ return unless configuration.base_controller_class.to_s.delete_prefix('::') == 'ActionController::Base'
25
+
26
+ finding(:critical, :admin_ui_exposed, 'The admin UI has no authentication',
27
+ 'http_basic_auth_enabled is false and base_controller_class is ActionController::Base, so ' \
28
+ 'anyone who can reach the mount path can change permissions. Set base_controller_class to ' \
29
+ 'a controller that authenticates and authorizes admins, or turn HTTP Basic back on.')
30
+ end
31
+
32
+ def process_local_cache
33
+ return unless production? && configuration.cache_backend.to_s == 'memory'
34
+
35
+ finding(:high, :process_local_cache, 'Permission cache is local to each process',
36
+ 'cache_backend is :memory in production: a revoked permission stays cached in other Puma ' \
37
+ 'workers until their TTL expires. Use :rails_cache with a shared store.')
38
+ end
39
+
40
+ def unconfigured_fields_pass
41
+ strategy = configuration.unconfigured_field_strategy.to_s
42
+ return unless %w[pass_through warn].include?(strategy)
43
+
44
+ finding(:high, :unconfigured_fields_pass, 'Unconfigured fields are returned unfiltered',
45
+ "unconfigured_field_strategy is :#{strategy}, so a new column reaches filtered responses " \
46
+ 'before anyone decides who may read it. Use :block (the default).')
47
+ end
48
+
49
+ def write_protection_off
50
+ return unless configuration.masked_write_protection.to_s == 'off'
51
+
52
+ finding(:high, :write_protection_off, 'Masked values can be written back',
53
+ 'masked_write_protection is :off: saving a record that holds a masked value overwrites the ' \
54
+ 'real data with the mask. Use :skip (default) or :raise.')
55
+ end
56
+
57
+ def nested_parent_fallback
58
+ return unless configuration.nested_attributes_parent_fallback
59
+
60
+ finding(:medium, :nested_parent_fallback, 'Nested attributes fall back to the parent resource',
61
+ 'nested_attributes_parent_fallback is true: a `<assoc>_attributes` payload for a model the user ' \
62
+ "has no Resource entry for is filtered against the parent's fields, so a key editable on the parent " \
63
+ 'admits the same key on a child the user was never granted. Configure the child Resource and set ' \
64
+ 'it back to false (the default).')
65
+ end
66
+
67
+ def nil_user_bypass
68
+ strategies = { nil_user_strategy: configuration.nil_user_strategy,
69
+ response_filter_nil_user_strategy: configuration.response_filter_nil_user_strategy }
70
+ bypassing = strategies.select { |_, value| value.to_s == 'bypass' }.keys
71
+ return if bypassing.empty?
72
+
73
+ finding(:low, :nil_user_bypass, 'No current user means no permission checks',
74
+ "#{bypassing.join(' and ')} #{bypassing.one? ? 'is' : 'are'} :bypass. Make sure every request " \
75
+ 'that touches protected models is authenticated first, or use :deny / :raise.')
76
+ end
77
+
78
+ # The one nil-user setting that is about the getters themselves.
79
+ def masked_nil_user_bypass
80
+ return if configuration.hide_masked_attributes_without_user?
81
+
82
+ finding(:medium, :masked_nil_user_bypass, 'Masked attributes are readable with no current user',
83
+ 'masked_attributes_nil_user_strategy is :bypass: when nobody is signed in (a job, a mailer, the ' \
84
+ 'console, a resolver that returns nil) the getters, attributes and as_json of a masked model return ' \
85
+ 'the stored values, whatever nil_user_strategy says. Set it to :hide (the user model stays readable, as ' \
86
+ 'sign-in needs), and set the current user in ' \
87
+ 'the jobs and mailers that read masked data.')
88
+ end
89
+
90
+ def gdpr_defaults_off
91
+ return if configuration.gdpr_defaults
92
+
93
+ finding(:medium, :gdpr_defaults_off, 'GDPR defaults are off',
94
+ 'Generated fields for personal data are not masked by default. Set gdpr_defaults = true if ' \
95
+ 'you hold EU personal data.')
96
+ end
97
+
98
+ def log_filtering_off
99
+ return if configuration.filter_parameters_integration
100
+
101
+ finding(:low, :log_filtering_off, 'Personal-data parameters are not filtered from logs',
102
+ 'filter_parameters_integration is false. Enable it, or add ' \
103
+ 'PermissionForge.gdpr_filter_parameters to Rails filter_parameters yourself.')
104
+ end
105
+
106
+ def audit_trail_off
107
+ return if configuration.audit_trail_enabled
108
+
109
+ finding(:medium, :audit_trail_off, 'Permission changes are not attributed',
110
+ 'audit_trail_enabled is false, so permission records do not keep created_by / updated_by.')
111
+ end
112
+
113
+ def conditional_sets_lenient
114
+ return if configuration.conditional_sets_strict_mode
115
+
116
+ conditional = conditional_set_names
117
+ return if conditional.empty?
118
+
119
+ finding(:medium, :conditional_sets_lenient, 'Conditional sets apply when the request context is blank',
120
+ 'conditional_sets_strict_mode is false: where the request context is blank (no resolver, or one ' \
121
+ 'that returns {} in a job or the console), session-flag / IP / hours conditions are skipped and ' \
122
+ 'the set applies, MFA- or IP-gated grants included.',
123
+ items: conditional)
124
+ end
125
+
126
+ def detection_raises_in_production
127
+ return unless configuration.unscoped_query_detection.to_s == 'raise'
128
+ return unless Array(configuration.unscoped_query_detection_environments).map(&:to_s).include?('production')
129
+
130
+ finding(:medium, :detection_raises_in_production, 'Unscoped-query detection raises in production',
131
+ 'unscoped_query_detection is :raise for production, so a missed scope becomes a 500 for users. ' \
132
+ 'Use :log in production and :raise in test/CI.')
133
+ end
134
+
135
+ def api_without_auth
136
+ return unless configuration.api_enabled && configuration.api_auth.nil?
137
+
138
+ finding(:medium, :api_without_auth, 'The JSON API is enabled without api_auth',
139
+ 'Every API request returns 401. Configure api_auth, or turn the API off.')
140
+ end
141
+
142
+ def api_mutations_unguarded
143
+ return unless configuration.api_enabled && !configuration.api_require_authorization
144
+ return if configuration.api_action_authorization && configuration.api_assignment_authorization
145
+
146
+ finding(:high, :api_mutations_unguarded, 'Any authenticated API caller can change permissions',
147
+ 'api_auth proves identity, not admin rights. Set api_action_authorization and ' \
148
+ 'api_assignment_authorization, or keep api_require_authorization = true (the default) ' \
149
+ 'to refuse changes until they are set.')
150
+ end
151
+
152
+ # Tenancy on, and an admin base controller that sets no tenant.
153
+ def tenantless_admin
154
+ defaults = configuration.default_admin_controllers_under_tenancy
155
+ return if defaults.empty?
156
+
157
+ names = defaults.join(' and ')
158
+ if configuration.admin_tenantless_access_allowed?
159
+ finding(:high, :tenantless_admin, 'The admin can read and change every tenant',
160
+ 'tenant_model is set, admin_tenantless_access is :allow and ActionController::Base, which sets ' \
161
+ "no tenant, is still the base of #{names}: one admin request reads and changes the existing " \
162
+ 'Permission Sets, assignments and audit log of every tenant. Point each at a controller that ' \
163
+ 'sets the current tenant, and keep admin_tenantless_access :deny (the default).')
164
+ else
165
+ finding(:medium, :tenantless_admin, 'The admin base controllers set no tenant',
166
+ "tenant_model is set but ActionController::Base, which sets no tenant, is still the base of #{names}: " \
167
+ 'every admin request without one is refused (admin_tenantless_access :deny) unless middleware ' \
168
+ 'sets it. Point each at a controller that sets the current tenant.')
169
+ end
170
+ end
171
+
172
+ def api_sequential_ids
173
+ return unless configuration.api_enabled && configuration.api_record_identifier.to_s == 'id'
174
+
175
+ finding(:low, :api_sequential_ids, 'The API exposes sequential ids',
176
+ 'api_record_identifier is :id, which makes records easy to enumerate. Use :uuid.')
177
+ end
178
+
179
+ def api_rule_values_exposed
180
+ return unless configuration.api_enabled && !configuration.api_redact_rule_field_values
181
+
182
+ finding(:low, :api_rule_values_exposed, 'The API returns rule values',
183
+ 'Rule field_value can hold user ids or business data. Set api_redact_rule_field_values = true ' \
184
+ 'if that matters for your data.')
185
+ end
186
+
187
+ # Under the :strict preset, the options set looser than it (the
188
+ # findings above rate the risk of each; this one names them all).
189
+ def strict_overrides
190
+ return unless configuration.preset == :strict
191
+
192
+ looser = configuration.options_looser_than_strict
193
+ return if looser.empty?
194
+
195
+ finding(:low, :strict_overrides, 'Settings override the :strict preset',
196
+ "config.preset is :strict, but #{looser.size} #{looser.one? ? 'option is' : 'options are'} set " \
197
+ 'to a looser value. Check that each override is intended.',
198
+ items: looser.map { |option, value| strict_override_item(option, value) })
199
+ end
200
+
201
+ def strict_override_item(option, value)
202
+ strict = Configuration::Presets::STRICT.fetch(option)
203
+ "#{option} = #{value.inspect} (strict: #{strict.inspect})"
204
+ end
205
+
206
+ def conditional_set_names
207
+ PermissionForge::PermissionSet.active.conditional.order(:name).pluck(:name)
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ module SecurityAuditChecks
6
+ # Rows the models' validations would refuse but the database holds
7
+ # anyway (written by SQL, an older version, or another app).
8
+ class IntegrityCheck < BaseCheck
9
+ RESOURCE_RULES = {
10
+ 'edit without read' => ->(r) { r.edit_access && !r.read_access },
11
+ 'delete without read' => ->(r) { r.delete_access && !r.read_access },
12
+ 'Modify All without View All' => ->(r) { r.modify_all && !r.view_all }
13
+ }.freeze
14
+
15
+ FIELD_RULES = {
16
+ 'edit without read' => ->(field) { field.edit_access && !field.read_access },
17
+ 'masked without read' => ->(field) { field.mask_access && !field.read_access }
18
+ }.freeze
19
+
20
+ def call
21
+ [duplicate_fields, duplicate_actions, dependency_violations, unknown_models].compact
22
+ end
23
+
24
+ private
25
+
26
+ def duplicate_fields
27
+ groups = PermissionForge::ResourceField.group(:resource_id, :name).having('COUNT(*) > 1').count
28
+ return if groups.empty?
29
+
30
+ names = PermissionForge::Resource.where(id: groups.keys.map(&:first)).pluck(:id, :name).to_h
31
+ finding(:high, :duplicate_fields, 'Duplicate field rows',
32
+ 'Two rows for one field merge with mask AND-logic, so an unmasked duplicate unmasks the field. ' \
33
+ 'Run `bin/rails generate permission_forge:upgrade` to de-duplicate and add the unique index.',
34
+ items: groups.map { |(resource_id, name), count| "#{names[resource_id]}.#{name}: #{count} rows" })
35
+ end
36
+
37
+ def duplicate_actions
38
+ groups = PermissionForge::ResourceAction.group(:resource_id, :name).having('COUNT(*) > 1').count
39
+ return if groups.empty?
40
+
41
+ resources = PermissionForge::Resource.where(id: groups.keys.map(&:first)).includes(:permission_set).index_by(&:id)
42
+ finding(:medium, :duplicate_actions, 'Duplicate action rows',
43
+ 'Two rows for one action are OR-ed, so a permitting duplicate permits what the other row blocks. ' \
44
+ 'Remove the extra row; a unique index on (resource, name) keeps new ones out ' \
45
+ '(the install migration creates idx_pf_resource_actions_unique).',
46
+ items: groups.map do |(resource_id, name), count|
47
+ resource = resources[resource_id]
48
+ "#{resource&.permission_set&.name} › #{resource&.name}.#{name}: #{count} rows"
49
+ end)
50
+ end
51
+
52
+ def dependency_violations
53
+ items = resource_violations + field_violations
54
+ return if items.empty?
55
+
56
+ finding(:low, :dependency_violations, 'Grants that skip a required permission',
57
+ 'Validations require read before edit / delete / mask, and View All before Modify All. ' \
58
+ 'Fix these rows in the admin so they mean what they say.', items: items)
59
+ end
60
+
61
+ def resource_violations
62
+ granting_resources.flat_map do |resource|
63
+ violations(RESOURCE_RULES, resource).map { |what| "#{resource.permission_set.name} › #{resource.name}: #{what}" }
64
+ end
65
+ end
66
+
67
+ def field_violations
68
+ fields = PermissionForge::ResourceField.where(resource_id: granting_resources.map(&:id))
69
+ .where('edit_access = ? OR mask_access = ?', true, true)
70
+ .where(read_access: false).includes(resource: :permission_set)
71
+ fields.flat_map do |field|
72
+ resource = field.resource
73
+ violations(FIELD_RULES, field).map do |what|
74
+ "#{resource.permission_set.name} › #{resource.name}.#{field.name}: #{what}"
75
+ end
76
+ end
77
+ end
78
+
79
+ def violations(rules, record)
80
+ rules.select { |_, broken| broken.call(record) }.keys
81
+ end
82
+
83
+ def granting_resources
84
+ @granting_resources ||= PermissionForge::Resource.where(
85
+ permission_set_id: PermissionForge::PermissionSet.granting.select(:id)
86
+ ).includes(:permission_set).to_a
87
+ end
88
+
89
+ def unknown_models
90
+ names = PermissionForge::Resource.distinct.order(:name).pluck(:name).reject { |name| model?(name) }
91
+ return if names.empty?
92
+
93
+ finding(:low, :unknown_models, 'Resources for models this app does not have',
94
+ 'No ActiveRecord class matches these names, so the grants do nothing here. If another app ' \
95
+ 'shares these tables it may still use them — check there before deleting.', items: names)
96
+ end
97
+
98
+ def model?(name)
99
+ klass = name.safe_constantize
100
+ klass.is_a?(Class) && klass < ActiveRecord::Base
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end