permission_forge 0.14.1 → 0.15.0

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 (347) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2372 -0
  3. data/README.md +76 -25
  4. data/app/controllers/concerns/permission_forge/api/crud_actions.rb +3 -2
  5. data/app/controllers/concerns/permission_forge/api/input_shapes.rb +200 -0
  6. data/app/controllers/concerns/permission_forge/api/pagination.rb +12 -5
  7. data/app/controllers/concerns/permission_forge/api/response_renderer.rb +37 -1
  8. data/app/controllers/concerns/permission_forge/api_authentication.rb +13 -2
  9. data/app/controllers/concerns/permission_forge/cache_confirmation.rb +42 -11
  10. data/app/controllers/concerns/permission_forge/change_review.rb +42 -0
  11. data/app/controllers/concerns/permission_forge/copy_refusal.rb +39 -0
  12. data/app/controllers/concerns/permission_forge/param_shapes.rb +207 -7
  13. data/app/controllers/concerns/permission_forge/save_notice.rb +69 -0
  14. data/app/controllers/permission_forge/api/audit_events_controller.rb +1 -1
  15. data/app/controllers/permission_forge/api/base_controller.rb +163 -12
  16. data/app/controllers/permission_forge/api/dashboard_controller.rb +29 -4
  17. data/app/controllers/permission_forge/api/permission_set_group_users_controller.rb +5 -2
  18. data/app/controllers/permission_forge/api/permission_set_groups_controller.rb +5 -4
  19. data/app/controllers/permission_forge/api/permission_set_rules_controller.rb +7 -5
  20. data/app/controllers/permission_forge/api/permission_set_users_controller.rb +80 -19
  21. data/app/controllers/permission_forge/api/permission_sets_controller.rb +12 -6
  22. data/app/controllers/permission_forge/api/resource_actions_controller.rb +1 -1
  23. data/app/controllers/permission_forge/api/resource_fields_controller.rb +3 -4
  24. data/app/controllers/permission_forge/api/resources_controller.rb +26 -12
  25. data/app/controllers/permission_forge/api/unmatched_routes_controller.rb +20 -0
  26. data/app/controllers/permission_forge/application_controller.rb +39 -5
  27. data/app/controllers/permission_forge/assignments_controller.rb +36 -34
  28. data/app/controllers/permission_forge/bulk_assignments_controller.rb +8 -4
  29. data/app/controllers/permission_forge/field_lookups_controller.rb +1 -1
  30. data/app/controllers/permission_forge/maintenance_controller.rb +6 -2
  31. data/app/controllers/permission_forge/people_controller.rb +14 -14
  32. data/app/controllers/permission_forge/permission_set_editor.rb +20 -6
  33. data/app/controllers/permission_forge/permission_set_group_users_controller.rb +3 -1
  34. data/app/controllers/permission_forge/permission_set_groups_controller.rb +23 -9
  35. data/app/controllers/permission_forge/permission_set_rules_controller.rb +20 -10
  36. data/app/controllers/permission_forge/permission_sets_controller.rb +23 -24
  37. data/app/controllers/permission_forge/resource_actions_controller.rb +11 -6
  38. data/app/controllers/permission_forge/resource_fields_controller.rb +37 -19
  39. data/app/controllers/permission_forge/resources_controller.rb +12 -11
  40. data/app/frontend/permission_forge/pf_admin.css +99 -17
  41. data/app/frontend/permission_forge/pf_admin.js +36 -0
  42. data/app/helpers/permission_forge/application_helper.rb +22 -72
  43. data/app/helpers/permission_forge/audit_attribution_helper.rb +77 -0
  44. data/app/helpers/permission_forge/audit_helper.rb +57 -119
  45. data/app/helpers/permission_forge/audit_records_helper.rb +103 -0
  46. data/app/helpers/permission_forge/audit_sentences_helper.rb +56 -2
  47. data/app/helpers/permission_forge/badges_helper.rb +74 -17
  48. data/app/helpers/permission_forge/changes_helper.rb +24 -6
  49. data/app/helpers/permission_forge/conditions_helper.rb +119 -0
  50. data/app/helpers/permission_forge/labels_helper.rb +134 -0
  51. data/app/helpers/permission_forge/people_helper.rb +11 -19
  52. data/app/helpers/permission_forge/rule_problems_helper.rb +73 -0
  53. data/app/helpers/permission_forge/sentences_helper.rb +119 -98
  54. data/app/helpers/permission_forge/settings_helper.rb +2 -2
  55. data/app/helpers/permission_forge/ui_helper.rb +65 -4
  56. data/app/models/permission_forge/audit_event.rb +5 -1
  57. data/app/models/permission_forge/permission_set.rb +208 -18
  58. data/app/models/permission_forge/permission_set_group.rb +10 -4
  59. data/app/models/permission_forge/permission_set_group_user.rb +3 -1
  60. data/app/models/permission_forge/permission_set_rule.rb +254 -17
  61. data/app/models/permission_forge/permission_set_user.rb +38 -3
  62. data/app/models/permission_forge/resource.rb +18 -8
  63. data/app/models/permission_forge/resource_action.rb +15 -6
  64. data/app/models/permission_forge/resource_field.rb +97 -11
  65. data/app/views/permission_forge/assignments/confirm_destroy.html.erb +4 -4
  66. data/app/views/permission_forge/assignments/new.html.erb +10 -6
  67. data/app/views/permission_forge/audit_events/index.html.erb +1 -1
  68. data/app/views/permission_forge/audit_events/show.html.erb +16 -9
  69. data/app/views/permission_forge/bulk_assignments/confirm_revoke.html.erb +3 -3
  70. data/app/views/permission_forge/bulk_assignments/new.html.erb +3 -3
  71. data/app/views/permission_forge/field_lookups/show.html.erb +2 -2
  72. data/app/views/permission_forge/home/_broken_rules_card.html.erb +35 -0
  73. data/app/views/permission_forge/home/_conditions_card.html.erb +28 -0
  74. data/app/views/permission_forge/home/_ending_card.html.erb +11 -5
  75. data/app/views/permission_forge/home/_expired_card.html.erb +1 -1
  76. data/app/views/permission_forge/home/_mask_strategies_card.html.erb +27 -0
  77. data/app/views/permission_forge/home/_privacy_card.html.erb +21 -7
  78. data/app/views/permission_forge/home/_rules_card.html.erb +1 -1
  79. data/app/views/permission_forge/home/_secret_rules_card.html.erb +33 -0
  80. data/app/views/permission_forge/home/_stale_card.html.erb +7 -5
  81. data/app/views/permission_forge/home/_tidy_card.html.erb +1 -1
  82. data/app/views/permission_forge/home/index.html.erb +5 -6
  83. data/app/views/permission_forge/people/_access_tab.html.erb +26 -19
  84. data/app/views/permission_forge/people/_audit_tab.html.erb +8 -8
  85. data/app/views/permission_forge/people/_effective_field_source.html.erb +13 -7
  86. data/app/views/permission_forge/people/_effective_tab.html.erb +16 -15
  87. data/app/views/permission_forge/people/_effective_why.html.erb +20 -16
  88. data/app/views/permission_forge/people/_why_item.html.erb +1 -1
  89. data/app/views/permission_forge/people/confirm_remove_all.html.erb +3 -3
  90. data/app/views/permission_forge/people/erasure_preview.html.erb +1 -1
  91. data/app/views/permission_forge/permission_set_groups/confirm_destroy.html.erb +3 -3
  92. data/app/views/permission_forge/permission_set_groups/index.html.erb +3 -3
  93. data/app/views/permission_forge/permission_set_groups/show.html.erb +28 -19
  94. data/app/views/permission_forge/permission_set_rules/_builder.html.erb +35 -15
  95. data/app/views/permission_forge/permission_set_rules/_preview.html.erb +17 -0
  96. data/app/views/permission_forge/permission_set_rules/confirm_destroy.html.erb +3 -3
  97. data/app/views/permission_forge/permission_set_rules/preview.html.erb +12 -0
  98. data/app/views/permission_forge/permission_sets/_assignments_tab.html.erb +37 -22
  99. data/app/views/permission_forge/permission_sets/_audit_tab.html.erb +4 -4
  100. data/app/views/permission_forge/permission_sets/_condition_problems.html.erb +15 -0
  101. data/app/views/permission_forge/permission_sets/_conditions_tab.html.erb +14 -8
  102. data/app/views/permission_forge/permission_sets/_header.html.erb +6 -5
  103. data/app/views/permission_forge/permission_sets/_records_summary.html.erb +2 -2
  104. data/app/views/permission_forge/permission_sets/_resources_tab.html.erb +4 -4
  105. data/app/views/permission_forge/permission_sets/confirm_destroy.html.erb +4 -4
  106. data/app/views/permission_forge/permission_sets/confirm_switch_mode.html.erb +3 -3
  107. data/app/views/permission_forge/permission_sets/index.html.erb +6 -6
  108. data/app/views/permission_forge/permission_sets/new.html.erb +2 -2
  109. data/app/views/permission_forge/permission_sets/review.html.erb +13 -3
  110. data/app/views/permission_forge/permission_sets/show.html.erb +7 -5
  111. data/app/views/permission_forge/resource_actions/_across_sets.html.erb +7 -10
  112. data/app/views/permission_forge/resource_actions/_effect.html.erb +5 -5
  113. data/app/views/permission_forge/resource_actions/_form.html.erb +1 -1
  114. data/app/views/permission_forge/resource_actions/_managed_note.html.erb +5 -3
  115. data/app/views/permission_forge/resource_actions/confirm_destroy.html.erb +1 -1
  116. data/app/views/permission_forge/resource_actions/new.html.erb +1 -1
  117. data/app/views/permission_forge/resource_actions/show.html.erb +2 -2
  118. data/app/views/permission_forge/resource_fields/_explain.html.erb +19 -15
  119. data/app/views/permission_forge/resource_fields/show.html.erb +21 -12
  120. data/app/views/permission_forge/resources/_actions_tab.html.erb +4 -4
  121. data/app/views/permission_forge/resources/_fields_tab.html.erb +17 -9
  122. data/app/views/permission_forge/resources/_rules_tab.html.erb +9 -6
  123. data/app/views/permission_forge/resources/_sync.html.erb +8 -5
  124. data/app/views/permission_forge/resources/confirm_destroy.html.erb +3 -3
  125. data/app/views/permission_forge/resources/new.html.erb +3 -2
  126. data/app/views/permission_forge/resources/show.html.erb +6 -6
  127. data/app/views/permission_forge/settings/_row.html.erb +2 -2
  128. data/app/views/permission_forge/settings/show.html.erb +10 -1
  129. data/app/views/permission_forge/shared/_across_sets.html.erb +8 -9
  130. data/app/views/permission_forge/shared/_audit_events.html.erb +2 -2
  131. data/app/views/permission_forge/shared/_audit_footer.html.erb +13 -2
  132. data/app/views/permission_forge/shared/_crud_cell.html.erb +6 -6
  133. data/app/views/permission_forge/shared/_mode_banners.html.erb +1 -1
  134. data/app/views/permission_forge/shared/_savebar.html.erb +4 -2
  135. data/config/locales/permission_forge.en.yml +480 -69
  136. data/config/locales/permission_forge.mk.yml +499 -79
  137. data/config/locales/permission_forge.sq.yml +509 -80
  138. data/config/routes.rb +38 -23
  139. data/lib/generators/permission_forge/install/install_generator.rb +21 -0
  140. data/lib/generators/permission_forge/install/templates/initializer.rb.tt +35 -34
  141. data/lib/generators/permission_forge/install/templates/migration.rb.tt +5 -5
  142. data/lib/permission_forge/activation_conditions.rb +200 -0
  143. data/lib/permission_forge/admin/action_label.rb +33 -0
  144. data/lib/permission_forge/admin/action_rows.rb +3 -1
  145. data/lib/permission_forge/admin/actor_label.rb +52 -0
  146. data/lib/permission_forge/admin/attention_items.rb +152 -31
  147. data/lib/permission_forge/admin/audit_log.rb +4 -7
  148. data/lib/permission_forge/admin/audit_trail.rb +33 -17
  149. data/lib/permission_forge/admin/bulk_people_matcher.rb +32 -14
  150. data/lib/permission_forge/admin/chain_entries.rb +42 -0
  151. data/lib/permission_forge/admin/change_set.rb +12 -1
  152. data/lib/permission_forge/admin/change_set_parts/base.rb +5 -4
  153. data/lib/permission_forge/admin/change_set_parts/context.rb +3 -1
  154. data/lib/permission_forge/admin/change_set_parts/field_edit.rb +4 -1
  155. data/lib/permission_forge/admin/change_set_parts/field_levels.rb +1 -1
  156. data/lib/permission_forge/admin/change_set_parts/new_actions.rb +1 -1
  157. data/lib/permission_forge/admin/change_set_parts/new_fields.rb +38 -11
  158. data/lib/permission_forge/admin/change_set_parts/resource_edit.rb +17 -1
  159. data/lib/permission_forge/admin/change_set_parts/resource_flags.rb +1 -1
  160. data/lib/permission_forge/admin/change_set_parts/set_attrs.rb +4 -0
  161. data/lib/permission_forge/admin/change_set_parts/values.rb +10 -4
  162. data/lib/permission_forge/admin/column_fields.rb +22 -21
  163. data/lib/permission_forge/admin/conditions_form.rb +47 -15
  164. data/lib/permission_forge/admin/controller_catalog.rb +42 -14
  165. data/lib/permission_forge/admin/editable_lock.rb +83 -3
  166. data/lib/permission_forge/admin/effective_access.rb +147 -48
  167. data/lib/permission_forge/admin/effective_fields.rb +75 -67
  168. data/lib/permission_forge/admin/error_sentence.rb +47 -0
  169. data/lib/permission_forge/admin/expiry_presets.rb +17 -0
  170. data/lib/permission_forge/admin/exports/holdings.rb +6 -3
  171. data/lib/permission_forge/admin/field_across_sets.rb +7 -4
  172. data/lib/permission_forge/admin/field_chain.rb +109 -0
  173. data/lib/permission_forge/admin/field_detail.rb +24 -11
  174. data/lib/permission_forge/admin/field_label.rb +109 -0
  175. data/lib/permission_forge/admin/field_level.rb +4 -68
  176. data/lib/permission_forge/admin/field_provenance.rb +132 -0
  177. data/lib/permission_forge/admin/field_rows.rb +26 -11
  178. data/lib/permission_forge/admin/last_changes.rb +14 -0
  179. data/lib/permission_forge/admin/list_sentence.rb +32 -0
  180. data/lib/permission_forge/admin/mask_strategy_choices.rb +57 -0
  181. data/lib/permission_forge/admin/model_catalog.rb +83 -39
  182. data/lib/permission_forge/admin/model_label.rb +66 -0
  183. data/lib/permission_forge/admin/people_candidates.rb +21 -9
  184. data/lib/permission_forge/admin/people_directory.rb +23 -5
  185. data/lib/permission_forge/admin/permission_set_factory.rb +6 -15
  186. data/lib/permission_forge/admin/person_label.rb +105 -16
  187. data/lib/permission_forge/admin/prefill.rb +92 -0
  188. data/lib/permission_forge/admin/record_name.rb +45 -0
  189. data/lib/permission_forge/admin/rule_builder.rb +77 -3
  190. data/lib/permission_forge/admin/rule_form.rb +44 -10
  191. data/lib/permission_forge/admin/rule_form_input.rb +27 -11
  192. data/lib/permission_forge/admin/rule_notes.rb +43 -0
  193. data/lib/permission_forge/admin/rule_preview.rb +96 -0
  194. data/lib/permission_forge/admin/sensitive_columns.rb +10 -62
  195. data/lib/permission_forge/admin/settings_catalog.rb +64 -12
  196. data/lib/permission_forge/admin/state_label.rb +97 -0
  197. data/lib/permission_forge/admin_method_override.rb +44 -0
  198. data/lib/permission_forge/api/schema.rb +322 -0
  199. data/lib/permission_forge/api/serializers/base_serializer.rb +87 -1
  200. data/lib/permission_forge/api/serializers/permission_set_group_user_serializer.rb +1 -1
  201. data/lib/permission_forge/api/serializers/permission_set_rule_serializer.rb +1 -1
  202. data/lib/permission_forge/api/serializers/permission_set_serializer.rb +5 -2
  203. data/lib/permission_forge/api/serializers/permission_set_user_serializer.rb +1 -1
  204. data/lib/permission_forge/api/serializers/resource_action_serializer.rb +1 -1
  205. data/lib/permission_forge/api/serializers/resource_field_serializer.rb +1 -1
  206. data/lib/permission_forge/api/serializers/resource_serializer.rb +1 -1
  207. data/lib/permission_forge/api_authorizations.rb +59 -0
  208. data/lib/permission_forge/api_only_admin.rb +81 -0
  209. data/lib/permission_forge/audit/actor_codes.rb +32 -0
  210. data/lib/permission_forge/audit/actor_resolver.rb +1 -2
  211. data/lib/permission_forge/audit/boot_warnings.rb +29 -9
  212. data/lib/permission_forge/audit/event_catalog.rb +16 -6
  213. data/lib/permission_forge/audit/event_query.rb +3 -3
  214. data/lib/permission_forge/audit/reason_codes.rb +25 -0
  215. data/lib/permission_forge/audit/runtime.rb +9 -2
  216. data/lib/permission_forge/audit/settings_observer.rb +2 -2
  217. data/lib/permission_forge/audit/unmasked_without_user.rb +169 -0
  218. data/lib/permission_forge/audit.rb +24 -10
  219. data/lib/permission_forge/boot_checks.rb +180 -63
  220. data/lib/permission_forge/cache/base.rb +10 -0
  221. data/lib/permission_forge/cache/null_cache.rb +5 -0
  222. data/lib/permission_forge/cache/outcome.rb +119 -0
  223. data/lib/permission_forge/cache/rails_cache.rb +6 -0
  224. data/lib/permission_forge/cache/report.rb +150 -0
  225. data/lib/permission_forge/cache/user_keys.rb +8 -4
  226. data/lib/permission_forge/cache_warnings.rb +140 -0
  227. data/lib/permission_forge/concerns/auto_permission_scoping.rb +19 -2
  228. data/lib/permission_forge/concerns/has_tenant_scoping.rb +1 -1
  229. data/lib/permission_forge/concerns/has_uuid.rb +1 -1
  230. data/lib/permission_forge/concerns/keeps_copied_values.rb +48 -0
  231. data/lib/permission_forge/concerns/masked_attributes/alias_access.rb +12 -2
  232. data/lib/permission_forge/concerns/masked_attributes/virtual_readers.rb +6 -4
  233. data/lib/permission_forge/concerns/masked_attributes.rb +150 -34
  234. data/lib/permission_forge/concerns/normalizes_label.rb +78 -0
  235. data/lib/permission_forge/concerns/permission_cache_accessor.rb +3 -3
  236. data/lib/permission_forge/concerns/permission_scoped_findable.rb +121 -29
  237. data/lib/permission_forge/concerns/permissioned_user.rb +1 -1
  238. data/lib/permission_forge/concerns/records_audit_events.rb +13 -6
  239. data/lib/permission_forge/concerns/requires_name.rb +37 -0
  240. data/lib/permission_forge/concerns/validates_column_lengths.rb +24 -0
  241. data/lib/permission_forge/concerns/validates_dependency.rb +5 -2
  242. data/lib/permission_forge/concerns/validates_required_columns.rb +74 -0
  243. data/lib/permission_forge/conditional_sets.rb +13 -6
  244. data/lib/permission_forge/configuration/admin_ui_settings.rb +18 -4
  245. data/lib/permission_forge/configuration/audit_settings.rb +2 -1
  246. data/lib/permission_forge/configuration/controller_action_settings.rb +5 -4
  247. data/lib/permission_forge/configuration/masked_attributes_settings.rb +73 -19
  248. data/lib/permission_forge/configuration/presets.rb +20 -11
  249. data/lib/permission_forge/configuration/query_scoping_settings.rb +55 -0
  250. data/lib/permission_forge/configuration/strategy_values.rb +2 -1
  251. data/lib/permission_forge/configuration.rb +41 -18
  252. data/lib/permission_forge/controller_actions/managed_index.rb +8 -7
  253. data/lib/permission_forge/database_reach.rb +93 -0
  254. data/lib/permission_forge/deprecation.rb +1 -1
  255. data/lib/permission_forge/docs/api_samples.rb +250 -0
  256. data/lib/permission_forge/docs/configuration_reference.rb +172 -0
  257. data/lib/permission_forge/encrypted_attributes.rb +150 -0
  258. data/lib/permission_forge/engine.rb +46 -14
  259. data/lib/permission_forge/execution_state/restore.rb +74 -0
  260. data/lib/permission_forge/execution_state.rb +8 -1
  261. data/lib/permission_forge/field_level.rb +123 -0
  262. data/lib/permission_forge/field_names.rb +145 -0
  263. data/lib/permission_forge/field_variants.rb +96 -0
  264. data/lib/permission_forge/generated_text.rb +95 -0
  265. data/lib/permission_forge/inspection_filters.rb +83 -0
  266. data/lib/permission_forge/managed_fields.rb +66 -0
  267. data/lib/permission_forge/masked_models.rb +25 -19
  268. data/lib/permission_forge/masking_sync.rb +100 -25
  269. data/lib/permission_forge/model_bindings.rb +30 -19
  270. data/lib/permission_forge/model_lookup.rb +42 -0
  271. data/lib/permission_forge/operators/encrypted_alias.rb +67 -0
  272. data/lib/permission_forge/operators/registry.rb +6 -0
  273. data/lib/permission_forge/operators/rule_column.rb +242 -27
  274. data/lib/permission_forge/operators/rule_path.rb +179 -0
  275. data/lib/permission_forge/operators/rule_problem.rb +309 -0
  276. data/lib/permission_forge/operators/rule_sources.rb +96 -0
  277. data/lib/permission_forge/operators/value_casters/postgresql_types.rb +237 -0
  278. data/lib/permission_forge/operators/value_casters/temporal.rb +9 -20
  279. data/lib/permission_forge/operators/value_casters.rb +61 -13
  280. data/lib/permission_forge/operators/value_typing.rb +83 -7
  281. data/lib/permission_forge/query_scoping_warnings.rb +22 -17
  282. data/lib/permission_forge/rake_audit.rb +5 -4
  283. data/lib/permission_forge/rake_dry_run.rb +32 -0
  284. data/lib/permission_forge/rake_output.rb +189 -0
  285. data/lib/permission_forge/record_read_access.rb +51 -0
  286. data/lib/permission_forge/resource_chain.rb +14 -2
  287. data/lib/permission_forge/schema_read/ordered_types.rb +72 -0
  288. data/lib/permission_forge/schema_read.rb +335 -0
  289. data/lib/permission_forge/secret_columns.rb +49 -0
  290. data/lib/permission_forge/sensitive_columns.rb +114 -0
  291. data/lib/permission_forge/services/attribute_access_guard.rb +22 -14
  292. data/lib/permission_forge/services/bulk_permission_set_assignment.rb +16 -8
  293. data/lib/permission_forge/services/concerns/clonable.rb +9 -0
  294. data/lib/permission_forge/services/concerns/settles_cache.rb +58 -0
  295. data/lib/permission_forge/services/consent_withdrawal_handler.rb +37 -19
  296. data/lib/permission_forge/services/data_subject_erasure.rb +83 -45
  297. data/lib/permission_forge/services/expired_assignment_cleaner.rb +62 -25
  298. data/lib/permission_forge/services/extra_field_defaults.rb +1 -1
  299. data/lib/permission_forge/services/field_permission_merger.rb +44 -17
  300. data/lib/permission_forge/services/filters_response_attributes.rb +20 -5
  301. data/lib/permission_forge/services/gdpr_field_auditor/text_report.rb +33 -5
  302. data/lib/permission_forge/services/gdpr_field_auditor.rb +68 -48
  303. data/lib/permission_forge/services/host_key_guard.rb +8 -5
  304. data/lib/permission_forge/services/mask_value.rb +72 -11
  305. data/lib/permission_forge/services/permission_cache_clearer.rb +36 -15
  306. data/lib/permission_forge/services/permission_hash_builder.rb +48 -19
  307. data/lib/permission_forge/services/permission_rule_applier.rb +131 -68
  308. data/lib/permission_forge/services/permission_set_cloner.rb +32 -8
  309. data/lib/permission_forge/services/permission_set_condition_evaluator.rb +38 -36
  310. data/lib/permission_forge/services/permission_set_group_cloner.rb +2 -1
  311. data/lib/permission_forge/services/permits_attributes/result.rb +31 -0
  312. data/lib/permission_forge/services/permits_attributes.rb +48 -27
  313. data/lib/permission_forge/services/resource_field_generator/row_builder.rb +96 -0
  314. data/lib/permission_forge/services/resource_field_generator.rb +71 -42
  315. data/lib/permission_forge/services/resource_with_fields_builder.rb +2 -1
  316. data/lib/permission_forge/services/rls_operator_sql.rb +83 -24
  317. data/lib/permission_forge/services/rls_policy_generator.rb +83 -39
  318. data/lib/permission_forge/services/rls_read_access_sql.rb +131 -0
  319. data/lib/permission_forge/services/security_audit/action_check.rb +49 -5
  320. data/lib/permission_forge/services/security_audit/assignment_check.rb +2 -1
  321. data/lib/permission_forge/services/security_audit/audit_trail_check.rb +1 -3
  322. data/lib/permission_forge/services/security_audit/conditions_check.rb +92 -0
  323. data/lib/permission_forge/services/security_audit/config_check.rb +41 -18
  324. data/lib/permission_forge/services/security_audit/integrity_check.rb +71 -4
  325. data/lib/permission_forge/services/security_audit/masking_without_user.rb +66 -0
  326. data/lib/permission_forge/services/security_audit/personal_data_check.rb +7 -11
  327. data/lib/permission_forge/services/security_audit/rule_check.rb +180 -50
  328. data/lib/permission_forge/services/security_audit/text_report.rb +14 -2
  329. data/lib/permission_forge/services/security_audit/unmanaged_actions.rb +145 -0
  330. data/lib/permission_forge/services/security_audit/user_model_check.rb +4 -3
  331. data/lib/permission_forge/services/security_audit.rb +16 -4
  332. data/lib/permission_forge/services/stale_assignment_auditor.rb +8 -5
  333. data/lib/permission_forge/timestamp_fields.rb +60 -0
  334. data/lib/permission_forge/unique_conflict.rb +117 -0
  335. data/lib/permission_forge/version.rb +1 -1
  336. data/lib/permission_forge/virtual_fields/discovery.rb +2 -1
  337. data/lib/permission_forge/virtual_fields.rb +15 -5
  338. data/lib/permission_forge/warnings.rb +263 -0
  339. data/lib/permission_forge/web_request.rb +248 -0
  340. data/lib/permission_forge.rb +54 -0
  341. data/lib/tasks/permission_forge.rake +26 -6
  342. data/lib/tasks/permission_forge_audit_events.rake +8 -4
  343. data/lib/tasks/permission_forge_cache.rake +12 -12
  344. data/lib/tasks/permission_forge_docs.rake +48 -0
  345. data/lib/tasks/permission_forge_expiration.rake +11 -5
  346. data/lib/tasks/permission_forge_gdpr.rake +36 -20
  347. metadata +84 -1
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # The validation error a uniqueness race would have shown.
5
+ #
6
+ # Four models validate uniqueness with a unique index behind the validation
7
+ # (a Resource or a field name per set or resource, one assignment or
8
+ # membership per person): two requests that pass the validation together
9
+ # leave the second INSERT to the index, which raises
10
+ # ActiveRecord::RecordNotUnique. The admin and the API answer it the way
11
+ # they answer the validation — a 422 keyed by the attribute, the form's
12
+ # page with the message — from the index the error names. The adapters
13
+ # word the error differently (SQLite names the columns, PostgreSQL and
14
+ # MySQL the index), so the index name and every `table.column` of the
15
+ # index are looked for, and nothing else of the message is parsed.
16
+ #
17
+ # @api private
18
+ module UniqueConflict
19
+ # A uniqueness validation (its attribute and validator) of a model.
20
+ Conflict = Data.define(:model, :attribute, :validator) do
21
+ # The validation's own errors, as `record.errors` would hold them:
22
+ # `:taken` with the validator's `message:` option, as the validator
23
+ # adds it.
24
+ #
25
+ # @return [ActiveModel::Errors]
26
+ def errors
27
+ list = model.new.errors
28
+ list.add(attribute, :taken, **validator.options.slice(:message))
29
+ list
30
+ end
31
+ end
32
+
33
+ class << self
34
+ # `{ user_id: ["has already been taken"] }`: the validation's message
35
+ # for the unique index `error` names, keyed by the attribute like
36
+ # `record.errors.messages`; nil when the index belongs to no
37
+ # uniqueness validation of the models (a uuid index, a host's own
38
+ # index, a message naming nothing).
39
+ #
40
+ # @param error [ActiveRecord::RecordNotUnique]
41
+ # @return [Hash{Symbol => Array<String>}, nil]
42
+ def messages(error)
43
+ find(error)&.errors&.messages
44
+ end
45
+
46
+ # The same as one sentence in the current locale ("Name already exists
47
+ # in this permission set", Admin::ErrorSentence); nil as for .messages.
48
+ #
49
+ # @param error [ActiveRecord::RecordNotUnique]
50
+ # @return [String, nil]
51
+ def sentence(error)
52
+ conflict = find(error)
53
+ conflict && Admin::ErrorSentence.first(conflict.errors)
54
+ end
55
+
56
+ # The uniqueness validation behind the unique index `error` names.
57
+ #
58
+ # @param error [ActiveRecord::RecordNotUnique]
59
+ # @return [Conflict, nil]
60
+ def find(error)
61
+ text = error.message.to_s
62
+ models.each do |model|
63
+ index = unique_indexes(model).find { |candidate| names?(text, candidate, model.table_name) }
64
+ return conflict_for(model, index) if index
65
+ end
66
+ nil
67
+ end
68
+
69
+ # The uniqueness validator of `model` whose attribute and scope are
70
+ # exactly the columns of `index`, with that attribute.
71
+ #
72
+ # @param model [Class] one of the models
73
+ # @param index [ActiveRecord::ConnectionAdapters::IndexDefinition]
74
+ # @return [Conflict, nil]
75
+ def conflict_for(model, index)
76
+ columns = Array(index.columns).map(&:to_s).sort
77
+ model.validators.each do |validator|
78
+ attribute = validated_attribute(validator, columns)
79
+ return Conflict.new(model: model, attribute: attribute, validator: validator) if attribute
80
+ end
81
+ nil
82
+ end
83
+
84
+ private
85
+
86
+ # The attribute of a uniqueness validator that, with the validator's
87
+ # scope, is exactly `columns` (sorted); nil for any other validator.
88
+ def validated_attribute(validator, columns)
89
+ return unless validator.kind == :uniqueness
90
+
91
+ scope = Array(validator.options[:scope]).map(&:to_s)
92
+ validator.attributes.find { |name| ([name.to_s] + scope).sort == columns }
93
+ end
94
+
95
+ def models
96
+ PermissionForge::MODEL_NAMES.map { |name| PermissionForge.const_get(name) }
97
+ end
98
+
99
+ # The unique indexes of the model's table; none when the table cannot
100
+ # be read (no table yet, no database): nothing is named then.
101
+ def unique_indexes(model)
102
+ model.connection_pool.with_connection { |connection| connection.indexes(model.table_name) }.select(&:unique)
103
+ rescue ActiveRecord::ActiveRecordError
104
+ []
105
+ end
106
+
107
+ # Whether the adapter's message names the index: by its name
108
+ # (PostgreSQL, MySQL) or by each of its columns as table.column (SQLite).
109
+ def names?(text, index, table)
110
+ return true if index.name.present? && text.match?(/\b#{Regexp.escape(index.name.to_s)}\b/)
111
+
112
+ columns = Array(index.columns).map(&:to_s)
113
+ columns.any? && columns.all? { |column| text.include?("#{table}.#{column}") }
114
+ end
115
+ end
116
+ end
117
+ end
@@ -1,3 +1,3 @@
1
1
  module PermissionForge
2
- VERSION = '0.14.1'.freeze
2
+ VERSION = '0.15.0'.freeze
3
3
  end
@@ -31,7 +31,8 @@ module PermissionForge
31
31
 
32
32
  # @return [Array<String>] discovered names, never a column
33
33
  def call
34
- SOURCES.flat_map { |source| send(source) }.uniq - @klass.column_names
34
+ names = SOURCES.flat_map { |source| send(source) }.uniq
35
+ names - SchemaRead.columns_among(@klass, names)
35
36
  rescue StandardError
36
37
  []
37
38
  end
@@ -47,9 +47,12 @@ module PermissionForge
47
47
  @extra = extra_unreadable(e)
48
48
  end
49
49
 
50
- # Declared names that are neither a column nor an ignored field.
50
+ # Declared names that are neither a column nor an ignored field. Whether
51
+ # a declared name is a column is asked once more after the schema is
52
+ # read again (PermissionForge::SchemaRead): a migration may have turned
53
+ # it into one.
51
54
  def declared
52
- @declared ||= extra - excluded
55
+ @declared ||= extra.reject { |name| ignored_fields.include?(name) || SchemaRead.column?(@klass, name, ignored: false) }
53
56
  end
54
57
 
55
58
  # Listed only while discovery is on; never implicitly granted.
@@ -74,10 +77,13 @@ module PermissionForge
74
77
  end
75
78
 
76
79
  # Whether the model has `name` as a virtual field, listed or not (a
77
- # field configured while discovery was on stays a virtual field).
80
+ # field configured while discovery was on stays a virtual field). A name
81
+ # that is no column is asked again after the schema is read again, once
82
+ # (PermissionForge::SchemaRead): a field a migration just turned into a
83
+ # column is a column.
78
84
  def include?(name)
79
85
  name = name.to_s
80
- @klass.column_names.exclude?(name) && (extra.include?(name) || discoverable.include?(name))
86
+ !SchemaRead.column?(@klass, name, ignored: false) && (extra.include?(name) || discoverable.include?(name))
81
87
  end
82
88
 
83
89
  private
@@ -87,7 +93,11 @@ module PermissionForge
87
93
  end
88
94
 
89
95
  def excluded
90
- @klass.column_names + Array(@configuration.ignored_fields).map(&:to_s)
96
+ SchemaRead.column_names(@klass) + ignored_fields
97
+ end
98
+
99
+ def ignored_fields
100
+ Array(@configuration.ignored_fields).map(&:to_s)
91
101
  end
92
102
 
93
103
  # Keeps the error for callers that must say what it costs them (reader
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent/map'
4
+
5
+ module PermissionForge
6
+ # The one emitter of the gem's boot and configuration warnings (a per-process
7
+ # cache, a fail-open setting, a model that cannot be masked, ...). Every such
8
+ # warning is one `emit` call; this module decides the channel, so that a
9
+ # warning about a fail-open condition is read by somebody.
10
+ #
11
+ # The channel policy (`channels`):
12
+ # - the log (Rails.logger.warn) always;
13
+ # - the terminal (standard error) where the log is not read: in development
14
+ # (`bin/rails server` boots before it attaches its console logger; a
15
+ # console, `bin/rails runner`, a rake task and `db:seed` never attach one)
16
+ # and, in every other environment but test, in any process that is not a
17
+ # web server (a console, a runner, a rake task, a seed, a job worker) —
18
+ # unless the logger already writes to standard output or error (a
19
+ # container, RAILS_LOG_TO_STDOUT), which would print it twice;
20
+ # - nothing while `bin/rails generate` or `destroy` boots the app
21
+ # (generator_running?): the install generator boots before it writes the
22
+ # initializer, so its warnings describe a configuration the app is about
23
+ # to stop having (R5 #120);
24
+ # - the terminal gets a key once per process (a human needs one line); the
25
+ # log gets every emission the site makes — a site that may repeat (a
26
+ # retried model, a store outage) decides itself when its condition is new.
27
+ #
28
+ # A SECURITY warning (severity :security) is a condition under which access
29
+ # is wider than configured, or a change goes unrecorded; it carries the
30
+ # prefix `SECURITY warning:`. Adding a severity is one SEVERITIES entry.
31
+ #
32
+ # A warning's audience (AUDIENCES) is the processes it is about. :all (the
33
+ # default): every process — the cache, the models, the rules, the audit
34
+ # table are used by a runner, a rake task, a job as much as by a server.
35
+ # :server: a warning about the admin screens or the JSON API, which only
36
+ # a web server process serves (HTTP Basic credentials, the actor of an
37
+ # admin change, the API's authorization lambdas, an API-only host's
38
+ # middlewares, tenantless admin access); outside a server process
39
+ # (server_process?) it goes to the log only. A runner does not serve the
40
+ # admin, and the credentials it would warn about are often set for the
41
+ # server alone (round 7, a-onboarding #23).
42
+ #
43
+ # Nothing here raises for a channel: a terminal that cannot be written is
44
+ # skipped, a missing logger is skipped. Never called at require time.
45
+ #
46
+ # @example
47
+ # PermissionForge::Warnings.emit(:api_authorization_gap,
48
+ # 'API is enabled but ... (privilege escalation).', severity: :security)
49
+ #
50
+ # @api private
51
+ module Warnings
52
+ # The text each severity puts before the message.
53
+ SEVERITIES = { security: 'SECURITY warning: ', config: '' }.freeze
54
+
55
+ # Every warning starts with it.
56
+ PREFIX = '[PermissionForge] '
57
+
58
+ # The commands `bin/rails generate` and `bin/rails destroy` run: Rails
59
+ # loads the command's class before it boots the app (canary
60
+ # :generator_commands). Rails::Generators alone does not say it: a gem may
61
+ # require rails/generators as it loads.
62
+ GENERATOR_COMMANDS = %w[Rails::Command::GenerateCommand Rails::Command::DestroyCommand].freeze
63
+
64
+ # The processes a warning is about (see the module): every process, or
65
+ # a web server's only. Adding an audience is one entry here and a rule
66
+ # in .channels.
67
+ AUDIENCES = %i[all server].freeze
68
+
69
+ # What a web server's own command has loaded before it boots the app,
70
+ # never what `require "<gem>"` (Bundler.require in a runner) loads:
71
+ # `bin/rails server` (Rails::Server), `puma` (Puma::CLI), `unicorn` and
72
+ # `unicorn_rails` (Unicorn::Launcher), Passenger's Rack loaders
73
+ # (PhusionPassenger::App), `falcon serve` / `falcon host`
74
+ # (Falcon::Command). Another server (thin, a custom Rack launcher)
75
+ # counts as a process whose log is not read: its standard error gets
76
+ # the warning unless the logger already writes there, and a :server
77
+ # warning goes to its log only.
78
+ SERVER_COMMANDS = %w[Rails::Server Puma::CLI Unicorn::Launcher PhusionPassenger::App Falcon::Command].freeze
79
+
80
+ # Servers whose command defines no constant of its own, by program name
81
+ # (the basename of Process.argv0, the name the process was started
82
+ # with, or of $PROGRAM_NAME): `pitchfork` (bin/pitchfork requires what
83
+ # `require "pitchfork"` does, and pitchfork rewrites $0 into its process
84
+ # title: Process.argv0 keeps the name), `rackup` (`bundle exec rackup`
85
+ # boots Rack's handler, never Puma::CLI; `bundle exec` loads a Ruby
86
+ # binstub in its own process and sets $0 to it, so Process.argv0 reads
87
+ # `bundle` there). A server started some other way counts as a process
88
+ # whose log is not read (see SERVER_COMMANDS).
89
+ SERVER_PROGRAMS = %w[pitchfork rackup].freeze
90
+
91
+ # The channels, in writing order.
92
+ CHANNELS = %i[log terminal].freeze
93
+
94
+ # What the channel policy reads about the process a warning is written
95
+ # from: the environment (an ActiveSupport::EnvironmentInquirer), whether
96
+ # it is a web server, whether a generator is booting it, and its logger.
97
+ # Warnings.context builds the current one; tests build their own.
98
+ Context = Data.define(:env, :server, :generator, :logger)
99
+
100
+ # Writes warnings to a terminal under a Context. Warnings.emit uses one
101
+ # for this process; tests inject an io and a Context.
102
+ class Emitter
103
+ # @param io [IO] the terminal
104
+ # @param context [Context]
105
+ def initialize(io: $stderr, context: Warnings.context)
106
+ @io = io
107
+ @context = context
108
+ end
109
+
110
+ # @param key [Symbol, String] what the warning is about (the terminal
111
+ # gets each key once per process); a per-subject warning puts the
112
+ # subject in the key (`"masked_models.unmaskable.#{name}"`)
113
+ # @param message [String] the sentence(s), without the gem prefix: it
114
+ # names the condition, its consequence and the remedy
115
+ # @param severity [Symbol] a SEVERITIES key: :security for a condition
116
+ # under which access widens or a change goes unrecorded, else :config
117
+ # @param audience [Symbol] an AUDIENCES entry: :server for a warning
118
+ # about the admin or the API a server process serves (the log only
119
+ # in any other process), else :all
120
+ # @return [String] the text written
121
+ # @raise [ArgumentError] for a severity not in SEVERITIES, an audience
122
+ # not in AUDIENCES
123
+ def emit(key, message, severity: :config, audience: :all)
124
+ text = "#{PREFIX}#{Warnings.severity_prefix(severity)}#{message}"
125
+ Warnings.channels(@context, audience: audience).each do |channel|
126
+ channel == :log ? @context.logger&.warn(text) : terminal(key, text)
127
+ end
128
+ text
129
+ end
130
+
131
+ private
132
+
133
+ # Once per process and key. A terminal that is closed or gone (a
134
+ # broken pipe) is skipped: the log has the warning.
135
+ def terminal(key, text)
136
+ return unless Warnings.first_on_terminal?(key)
137
+
138
+ @io.puts(text)
139
+ rescue IOError, SystemCallError
140
+ nil
141
+ end
142
+ end
143
+
144
+ class << self
145
+ # Writes one warning where the policy says (an Emitter for this process).
146
+ #
147
+ # @param (see Emitter#emit)
148
+ # @return [String] the text written
149
+ def emit(key, message, severity: :config, audience: :all)
150
+ Emitter.new.emit(key, message, severity: severity, audience: audience)
151
+ end
152
+
153
+ # The channels a warning goes to under `context`: [] while a generator
154
+ # runs, [:log] where the log is read — and for a :server warning in a
155
+ # process that is not a server —, [:log, :terminal] elsewhere (see the
156
+ # module).
157
+ #
158
+ # @param context [Context]
159
+ # @param audience [Symbol] an AUDIENCES entry
160
+ # @return [Array<Symbol>]
161
+ # @raise [ArgumentError] for an audience not in AUDIENCES
162
+ def channels(context = context(), audience: :all)
163
+ check_audience!(audience)
164
+ return [] if context.generator
165
+ return CHANNELS.first(1) if audience == :server && !context.server
166
+
167
+ terminal?(context) ? CHANNELS : CHANNELS.first(1)
168
+ end
169
+
170
+ # This process, with `overrides` for a test.
171
+ #
172
+ # @param overrides [Hash] env:, server:, generator:, logger:
173
+ # @return [Context]
174
+ def context(**overrides)
175
+ current = { env: Rails.env, server: server_process?, generator: generator_running?, logger: current_logger }
176
+ Context.new(**current, **overrides)
177
+ end
178
+
179
+ # Forgets which keys the terminal got (tests; a process has one boot).
180
+ #
181
+ # @return [void]
182
+ def reset!
183
+ written.clear
184
+ end
185
+
186
+ # Whether `bin/rails generate` or `bin/rails destroy` is booting the
187
+ # app: their command class is loaded; a server, a console, a runner or
188
+ # a rake task loads its own.
189
+ #
190
+ # @return [Boolean]
191
+ def generator_running?
192
+ GENERATOR_COMMANDS.any? { |name| Object.const_defined?(name, false) }
193
+ end
194
+
195
+ # Whether this process is a web server (SERVER_COMMANDS,
196
+ # SERVER_PROGRAMS): its log is where its warnings are read, outside
197
+ # development, and the :server warnings are about it.
198
+ #
199
+ # @param started_as [String] the name the process was started with (Process.argv0)
200
+ # @param program_name [String] $PROGRAM_NAME as it is now
201
+ # @return [Boolean]
202
+ def server_process?(started_as: Process.argv0, program_name: $PROGRAM_NAME)
203
+ SERVER_COMMANDS.any? { |name| Object.const_defined?(name, false) } ||
204
+ [started_as, program_name].any? { |program| SERVER_PROGRAMS.include?(File.basename(program.to_s)) }
205
+ end
206
+
207
+ # Whether `logger` already writes to standard output or error (a
208
+ # container setup, RAILS_LOG_TO_STDOUT, a logger the app configured):
209
+ # the terminal then has the warning once the log is written.
210
+ #
211
+ # @param logger [Logger, nil]
212
+ # @return [Boolean]
213
+ def logger_on_terminal?(logger = current_logger)
214
+ !logger.nil? && ActiveSupport::Logger.logger_outputs_to?(logger, $stdout, $stderr)
215
+ end
216
+
217
+ # @param severity [Symbol]
218
+ # @return [String] what SEVERITIES puts before the message
219
+ # @raise [ArgumentError] for a severity not in SEVERITIES
220
+ def severity_prefix(severity)
221
+ SEVERITIES.fetch(severity) do
222
+ raise ArgumentError, "unknown warning severity #{severity.inspect}; one of #{SEVERITIES.keys.inspect}"
223
+ end
224
+ end
225
+
226
+ # Whether the terminal has not had `key` in this process yet (and
227
+ # remembers that it now has).
228
+ #
229
+ # @param key [Symbol, String]
230
+ # @return [Boolean]
231
+ def first_on_terminal?(key)
232
+ written.put_if_absent(key.to_s, true).nil?
233
+ end
234
+
235
+ private
236
+
237
+ def check_audience!(audience)
238
+ return if AUDIENCES.include?(audience)
239
+
240
+ raise ArgumentError, "unknown warning audience #{audience.inspect}; one of #{AUDIENCES.inspect}"
241
+ end
242
+
243
+ # Rails.logger, nil before the app has one.
244
+ def current_logger
245
+ Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
246
+ end
247
+
248
+ # Never in test (a test run reads its log); in development always; in
249
+ # any other environment in a process that is not a server — each unless
250
+ # the log already shows on the terminal.
251
+ def terminal?(context)
252
+ env = context.env
253
+ return false if env.test?
254
+
255
+ (env.development? || !context.server) && !logger_on_terminal?(context.logger)
256
+ end
257
+
258
+ def written
259
+ @written ||= Concurrent::Map.new
260
+ end
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack/body_proxy'
4
+
5
+ module PermissionForge
6
+ # The one definition of "a web request is in progress": the Rack request
7
+ # the host application is serving right now, from Middleware until the
8
+ # response is finished. The engine puts Middleware at the top of the
9
+ # host's stack (Engine: `config.app_middleware.unshift`), unless a
10
+ # middleware is inserted above it (`config.middleware.insert_before 0`, as
11
+ # rack-cors documents, or a gem that inserts at 0 later): StackCheck warns
12
+ # at boot and SecurityAudit lists them. So the host's own controllers, the
13
+ # mounted engine's admin screens and API, every middleware below it (a
14
+ # tenant lookup, an authentication middleware) and the before_actions of
15
+ # the host's ApplicationController count.
16
+ #
17
+ # Outside one: background jobs run by a worker, `perform_later` on an
18
+ # adapter that runs elsewhere (`:async` runs on its own threads), Action
19
+ # Cable channel actions (they run on the cable server's worker pool), rake
20
+ # tasks, `bin/rails runner`, the console, and ActionController::TestCase
21
+ # controller tests (they call the controller without the middleware
22
+ # stack; integration and system tests go through it). Inside one: a job
23
+ # run with `perform_now` (or the `:inline` adapter) and a mail delivered
24
+ # with `deliver_now` during the request.
25
+ #
26
+ # It decides config.masked_attributes_nil_user_strategy = :hide_in_requests
27
+ # (Configuration#masked_attributes_without_user_for) and where
28
+ # field.unmasked_without_user is recorded.
29
+ #
30
+ # The state is ExecutionState slot :pf_web_request (the request's env), in
31
+ # the :execution scope, kept at the end of an executor run: the middleware
32
+ # sets it before the request's run starts and puts back what it found when
33
+ # the response is finished (`rack.response_finished`, else when the server
34
+ # closes the body), or at once when the application raises. Under Rails'
35
+ # :thread isolation (the default) every fiber of the request's thread sees
36
+ # it (an Enumerator, a streamed body), and so does the thread
37
+ # ActionController::Live runs the action in (it shares the request's
38
+ # isolated state). Under `isolation_level = :fiber` each fiber has a state
39
+ # of its own: a fiber the request starts (Enumerator#next, a Live thread)
40
+ # does not see it, and a read there is outside the request — Falcon needs
41
+ # :fiber, as Rails itself does.
42
+ #
43
+ # A slot left behind (a server that neither runs the callbacks nor closes
44
+ # the body) hides more where it is seen, never less, and is never put back
45
+ # by a later request: an env whose application call has returned is not
46
+ # a request in progress around the next one.
47
+ #
48
+ # @api private
49
+ module WebRequest
50
+ KEY = :pf_web_request
51
+
52
+ # Recorded request values are cut to this many characters (as the audit
53
+ # context's request id and IP are).
54
+ VALUE_LIMIT = 255
55
+
56
+ # Reads the slot (ExecutionState.reader).
57
+ READ = ExecutionState.reader(KEY)
58
+
59
+ class << self
60
+ # @return [Boolean] whether this code runs inside a web request (asked
61
+ # on every guarded read with nobody signed in: the slot's store is
62
+ # read directly)
63
+ def active?
64
+ !READ.call.nil?
65
+ end
66
+
67
+ # @return [Hash, nil] the Rack env of the request in progress
68
+ def env
69
+ ExecutionState[KEY]
70
+ end
71
+
72
+ # The request in progress, for an audit event or a log line: its
73
+ # method, path (without the query string) and, once the router has
74
+ # matched it, endpoint ("controller_path#action"); {} outside a
75
+ # request. Read when asked, never kept.
76
+ #
77
+ # @return [Hash{String => String, nil}]
78
+ def details
79
+ env = self.env
80
+ return {} unless env
81
+
82
+ request = ActionDispatch::Request.new(env)
83
+ { 'method' => cut(request.request_method), 'path' => cut(request.path), 'endpoint' => endpoint(request) }
84
+ rescue StandardError
85
+ {}
86
+ end
87
+
88
+ # Runs the block as a web request (tests; Middleware keeps it past the
89
+ # block itself).
90
+ #
91
+ # @param env [Hash] a Rack env
92
+ # @return the block's value
93
+ def within(env = {}, &)
94
+ ExecutionState.with(KEY, env, &)
95
+ end
96
+
97
+ private
98
+
99
+ # What the router matched, read without writing: Request#path_parameters
100
+ # would store {} in the env of a request not routed yet (canary
101
+ # :path_parameters_key).
102
+ def endpoint(request)
103
+ params = request.get_header(ActionDispatch::Http::Parameters::PARAMETERS_KEY)
104
+ controller = params[:controller] if params.is_a?(Hash)
105
+ cut("#{controller}##{params[:action]}") if controller
106
+ end
107
+
108
+ def cut(value)
109
+ value.to_s[0, VALUE_LIMIT].presence
110
+ end
111
+ end
112
+
113
+ # Marks every request the host application serves (see WebRequest). At
114
+ # the top of the host's middleware stack, above ActionDispatch::Executor
115
+ # (StackCheck names what was inserted above it).
116
+ #
117
+ # @api private
118
+ class Middleware
119
+ # Set in the env once the application has returned the response: an
120
+ # env still in the slot then is no request around the next one.
121
+ RETURNED = 'permission_forge.web_request.returned'
122
+
123
+ # @param app [#call] the next middleware
124
+ def initialize(app)
125
+ @app = app
126
+ end
127
+
128
+ # @param env [Hash]
129
+ # @return [Array] the Rack response, its body wrapped when the server
130
+ # does not announce `rack.response_finished`
131
+ def call(env)
132
+ finish = Finish.new(env)
133
+ returned = false
134
+ begin
135
+ status, headers, body = @app.call(env)
136
+ returned = true
137
+ ensure
138
+ returned ? env[RETURNED] = true : finish.call
139
+ end
140
+ [status, headers, finished(env, body, finish)]
141
+ end
142
+
143
+ private
144
+
145
+ # Until the response is finished: Rack 3 servers call the
146
+ # `rack.response_finished` callbacks once it is sent (ActionDispatch::Executor
147
+ # completes its run there too); others close the body.
148
+ def finished(env, body, finish)
149
+ callbacks = env['rack.response_finished']
150
+ return Rack::BodyProxy.new(body, &finish) unless callbacks
151
+
152
+ callbacks << finish
153
+ body
154
+ end
155
+ end
156
+
157
+ # The end of one request: puts back what the slot held (a request around
158
+ # it, or nothing) and, for the outermost request, forgets what runtime
159
+ # events it published once (Audit::Runtime.once?): reads made above
160
+ # ActionDispatch::Executor are outside its run, which would not clear
161
+ # them.
162
+ #
163
+ # @api private
164
+ class Finish
165
+ # @param env [Hash] the request's env, which the slot holds from now on
166
+ def initialize(env)
167
+ ExecutionState.put_back(KEY, nil) if Finish.left_behind?(ExecutionState[KEY])
168
+ @outermost = !WebRequest.active?
169
+ @restore = ExecutionState.set_until_restored(KEY, env)
170
+ end
171
+
172
+ # Whether the env in the slot is a request left behind: its application
173
+ # call returned and nobody finished its response (not a request around
174
+ # the next one).
175
+ #
176
+ # @param env [Hash, nil]
177
+ # @return [Boolean]
178
+ def self.left_behind?(env)
179
+ env.is_a?(Hash) && env[Middleware::RETURNED] == true
180
+ end
181
+
182
+ # Takes and ignores the arguments of a `rack.response_finished` callback.
183
+ # @return [void]
184
+ def call(*)
185
+ Audit::Runtime.reset! if @restore.call && @outermost
186
+ end
187
+
188
+ # @return [Proc] for Rack::BodyProxy's block
189
+ def to_proc = method(:call).to_proc
190
+ end
191
+
192
+ # Whether anything sits above Middleware in the built stack, read once the
193
+ # application has booted (BootChecks; SecurityAudit): a read there is
194
+ # outside the request, so :hide_in_requests does not hide it and :bypass
195
+ # does not record it.
196
+ #
197
+ # @api private
198
+ module StackCheck
199
+ class << self
200
+ # @param application [Rails::Application, nil]
201
+ # @return [Array<String>, nil] the names above Middleware (every name
202
+ # when it is not in the stack at all); nil when the stack cannot be
203
+ # read yet
204
+ def middlewares_above(application = default_application)
205
+ names = application && ApiOnlyAdmin.middleware_names(application)
206
+ return unless names
207
+
208
+ names.first(names.index(Middleware.name) || names.size)
209
+ end
210
+
211
+ # The boot warning, under the strategies that depend on the request
212
+ # being marked: :hide_in_requests (reads above are not hidden) and
213
+ # :bypass (they are not recorded).
214
+ #
215
+ # @param config [Configuration]
216
+ # @return [void]
217
+ def check(config, application = default_application)
218
+ strategy = config.masked_attributes_nil_user_strategy
219
+ return if strategy == :hide
220
+
221
+ above = middlewares_above(application)
222
+ return if above.blank?
223
+
224
+ if strategy == :hide_in_requests
225
+ Warnings.emit(:web_request_middleware_above_hide, message(above, 'are not hidden'), severity: :security, audience: :server)
226
+ else
227
+ Warnings.emit(:web_request_middleware_above_record, message(above, 'are not recorded'), severity: :config, audience: :server)
228
+ end
229
+ end
230
+
231
+ # @param above [Array<String>]
232
+ # @return [String]
233
+ def message(above, consequence)
234
+ "#{above.join(', ')} #{above.one? ? 'runs' : 'run'} above PermissionForge::WebRequest::Middleware: masked " \
235
+ "attributes read there with nobody signed in #{consequence} (masked_attributes_nil_user_strategy). Move " \
236
+ 'the middleware below it (config.middleware.insert_after PermissionForge::WebRequest::Middleware, ...) ' \
237
+ 'if it reads masked models.'
238
+ end
239
+
240
+ private
241
+
242
+ def default_application
243
+ Rails.application if defined?(Rails) && Rails.respond_to?(:application)
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end