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,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module PermissionForge
6
+ # Generators of the gem's own documentation (development only; nothing here
7
+ # runs in a host application).
8
+ #
9
+ # @api private
10
+ module Docs
11
+ # The "Option reference" of docs/configuration.md, rendered from the code:
12
+ # Admin::SettingsCatalog::DEFINITIONS give the sections, the options and
13
+ # their order (the admin's Settings page), Configuration.new the defaults,
14
+ # Configuration::StrategyValues::ALLOWED what a strategy option takes, and
15
+ # the en locale the section titles and the first text of an option that
16
+ # has none yet. Every other public writer of Configuration follows under
17
+ # "Other options". The prose under each heading is kept from the document
18
+ # as it stands; the headings, their order and the `**Default:**` lines are
19
+ # regenerated (`bundle exec rake docs:configuration`;
20
+ # docs/configuration_reference_test.rb fails while the file is stale).
21
+ #
22
+ # @api private
23
+ class ConfigurationReference
24
+ START_MARK = '<!-- option-reference:start -->'
25
+ END_MARK = '<!-- option-reference:end -->'
26
+ NOTE = "<!-- Generated by `bundle exec rake docs:configuration` from SettingsCatalog and Configuration.new.\n " \
27
+ "Edit the text under a heading here (or settings.items.<option> in the en locale for a new option):\n " \
28
+ 'the headings, their order and the **Default:** lines are regenerated. -->'
29
+ GENERATED_LINE = /\A\*\*Default:\*\*/
30
+ OTHER_SECTION = 'Other options'
31
+ PLACEHOLDER = '_Describe this option._'
32
+ LOCALE = File.expand_path('../../../config/locales/permission_forge.en.yml', __dir__)
33
+
34
+ # One `###` section of the reference: its title and the options under it.
35
+ Section = Struct.new(:title, :keys) do
36
+ def text(reference, existing)
37
+ intro = existing.section(title)
38
+ "### #{title}\n\n#{"#{intro}\n\n" unless intro.empty?}#{keys.map { |key| reference.entry(key, existing) }.join}"
39
+ end
40
+ end
41
+
42
+ attr_reader :document
43
+
44
+ # @param document [String] docs/configuration.md as it stands
45
+ def initialize(document:, configuration: Configuration.new, definitions: Admin::SettingsCatalog::DEFINITIONS,
46
+ locale: LOCALE)
47
+ @document = document
48
+ @configuration = configuration
49
+ @definitions = definitions
50
+ @locale = YAML.load_file(locale).dig('en', 'permission_forge', 'settings')
51
+ @values = Admin::SettingsValues.new
52
+ end
53
+
54
+ # The document with its reference regenerated.
55
+ def render
56
+ before, region, after = split
57
+ existing = ExistingProse.new(region)
58
+ "#{before}#{START_MARK}\n#{NOTE}\n\n#{sections.map { |section| section.text(self, existing) }.join}#{END_MARK}#{after}"
59
+ end
60
+
61
+ def stale? = render != document
62
+
63
+ # The option headings the reference holds, in order.
64
+ def keys = sections.flat_map(&:keys)
65
+
66
+ # One option's entry: heading, the generated default line, the text.
67
+ def entry(key, existing)
68
+ "#### #{key}\n\n#{default_line(key)}\n\n#{option_text(key, existing)}\n\n"
69
+ end
70
+
71
+ private
72
+
73
+ # The document before the start mark, the region between the marks, the rest.
74
+ def split
75
+ start = document.index(START_MARK) or raise ArgumentError, "#{START_MARK} not found in the document"
76
+ stop = document.index(END_MARK, start) or raise ArgumentError, "#{END_MARK} not found in the document"
77
+ [document[0...start], document[(start + START_MARK.length)...stop], document[(stop + END_MARK.length)..]]
78
+ end
79
+
80
+ def sections
81
+ @sections ||= begin
82
+ by_section = @definitions.group_by(&:section)
83
+ catalog = Admin::SettingsCatalog::SECTIONS.map { |key| Section.new(@locale['sections'].fetch(key), by_section.fetch(key, []).map(&:key)) }
84
+ others = writers - @definitions.map(&:key)
85
+ others.empty? ? catalog : catalog << Section.new(OTHER_SECTION, others)
86
+ end
87
+ end
88
+
89
+ def writers
90
+ (@configuration.class.public_instance_methods - Object.public_instance_methods)
91
+ .grep(/\A\w+=\z/).map { |name| name.to_s.chomp('=') }.sort
92
+ end
93
+
94
+ # The text under an option: what the document says, else the Settings page's name and description.
95
+ def option_text(key, existing)
96
+ text = existing.option(key)
97
+ return text unless text.empty?
98
+
99
+ name, desc = @locale['items'].fetch(key, {}).values_at('name', 'desc')
100
+ name ? "**#{name.sub(/\.\z/, '')}.** #{desc}" : PLACEHOLDER
101
+ end
102
+
103
+ def default_line(key)
104
+ takes = takes_text(key)
105
+ "**Default:** #{default_text(key)}#{" Takes #{takes}." if takes}"
106
+ end
107
+
108
+ def takes_text(key)
109
+ allowed = Configuration::StrategyValues::ALLOWED[key.to_sym] or return
110
+ values = allowed.map { |value| "`#{value.inspect}`" }
111
+ list = values.size > 1 ? "#{values[0..-2].join(', ')} or #{values.last}" : values.first
112
+ "#{list} (or its String)#{', or nil' if Configuration::StrategyValues::BLANK_ALLOWED.include?(key.to_sym)}"
113
+ end
114
+
115
+ def default_text(key)
116
+ value = @configuration.public_send(key)
117
+ case value
118
+ when nil then nil_text(definition(key))
119
+ when Proc then "the gem's own lambda"
120
+ when Hash then hash_text(key, value)
121
+ else "`#{@values.ruby_literal(value)}`"
122
+ end
123
+ end
124
+
125
+ def definition(key) = @definitions.find { |candidate| candidate.key == key }
126
+
127
+ def nil_text(definition)
128
+ case definition&.nil_label
129
+ when :default then '`nil` (the built-in behaviour)'
130
+ when :disabled then '`nil` (off)'
131
+ else '`nil`'
132
+ end
133
+ end
134
+
135
+ def hash_text(key, value)
136
+ return '`{}`' if value.empty?
137
+ return '`PermissionForge::Configuration::DEFAULT_GDPR_FIELD_PATTERNS`' if value == Configuration::DEFAULT_GDPR_FIELD_PATTERNS
138
+
139
+ pairs = @values.normalize(definition(key) || Admin::SettingsCatalog.d(key, 'other', :map), value)
140
+ pairs.map { |pattern, target| "`#{pattern}` (#{target})" }.join(', ')
141
+ end
142
+
143
+ # What the document says under each heading of the region: the section
144
+ # intros (keyed by title) and the option texts (keyed by option), the
145
+ # generated lines and the note left out.
146
+ class ExistingProse
147
+ def initialize(region)
148
+ @sections = Hash.new { |hash, key| hash[key] = +'' }
149
+ @options = Hash.new { |hash, key| hash[key] = +'' }
150
+ collect(region.sub(/\A\s*<!--.*?-->/m, ''))
151
+ end
152
+
153
+ def section(title) = @sections[title].strip
154
+ def option(key) = @options[key].strip
155
+
156
+ private
157
+
158
+ def collect(region)
159
+ bucket = +''
160
+ region.each_line do |line|
161
+ case line.chomp
162
+ when /\A### (.+)\z/ then bucket = @sections[Regexp.last_match(1).strip]
163
+ when /\A#### (\w+)\s*\z/ then bucket = @options[Regexp.last_match(1)]
164
+ when ConfigurationReference::GENERATED_LINE then next
165
+ else bucket << line
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # The one reader of a model's Active Record encryption declarations
5
+ # (`encrypts :email`): which columns hold ciphertext. Rails keeps the
6
+ # column's name, so nothing in the schema says so — the column of an
7
+ # encrypted attribute stores `{"p":"…","h":{…}}`, and SQL that compares it
8
+ # with a value compares the ciphertext:
9
+ #
10
+ # - a record rule cannot compare one (Operators::RuleColumn#encrypted?,
11
+ # Operators::ValueTyping: every built-in operator that takes a value
12
+ # matches nothing; is_null / is_not_null work, as the ciphertext is NULL
13
+ # exactly when the value is; RuleProblem :encrypted_column), whichever
14
+ # table of the rule's query it is in ({.models_on}: a table the query
15
+ # joins that is on no model along the rule's path);
16
+ # - the admin's people search leaves one out of its LIKE, and bulk matching
17
+ # of its lower-cased equality; a deterministically encrypted one matches
18
+ # a whole value exactly (`where(email: value)` encrypts the value);
19
+ # - a person's label reads one decrypted (Admin::PersonLabel).
20
+ #
21
+ # Rails' `encrypted_attributes` (no rdoc; a canary watches it) is nil on a
22
+ # model that encrypts nothing and holds Symbols — an `ignore_case`
23
+ # attribute's `original_<name>` column among them. Under single-table
24
+ # inheritance it is per class, and a subclass's `encrypts` adds to its
25
+ # parent's Set when the parent has one (`||= Set.new`): the parent's
26
+ # answer depends on what encrypts what, and in which order. A column holds
27
+ # ciphertext when any class that stores rows in it says so, so the answer
28
+ # is the hierarchy's on the model's table ({.family}): the class, its base
29
+ # class and every loaded class of the hierarchy on that table. A subclass
30
+ # Rails has not loaded yet (lazy loading in development) is not seen.
31
+ #
32
+ # The value Rails reads from a column it cannot decrypt is no value
33
+ # ({.decrypt}): under `support_unencrypted_data` Rails answers with the
34
+ # stored ciphertext instead of raising.
35
+ #
36
+ # @api private
37
+ module EncryptedAttributes
38
+ module_function
39
+
40
+ # Whether the column holds ciphertext: an attribute of that name is
41
+ # declared with `encrypts` by the model or a class of its hierarchy on
42
+ # its table ({.family}).
43
+ #
44
+ # @param model [Class, nil] a model class (anything else has none)
45
+ # @param name [String, Symbol] a column or attribute name, as the model has it
46
+ # @return [Boolean]
47
+ def encrypted?(model, name)
48
+ names(model).include?(name.to_s.to_sym)
49
+ end
50
+
51
+ # Whether the attribute is encrypted deterministically: the same value
52
+ # always gives the same ciphertext, so `where(name => value)` finds it.
53
+ #
54
+ # @param model [Class, nil]
55
+ # @param name [String, Symbol]
56
+ # @return [Boolean]
57
+ def deterministic?(model, name)
58
+ encrypted?(model, name) && Array(model.deterministic_encrypted_attributes).include?(name.to_s.to_sym)
59
+ end
60
+
61
+ # @param model [Class, nil]
62
+ # @return [Array<Symbol>] the encrypted attributes of the model's
63
+ # hierarchy on its table ({.family}; none for a non-model)
64
+ def names(model)
65
+ return [] unless model.respond_to?(:encrypted_attributes)
66
+
67
+ family(model).flat_map { |klass| Array(klass.encrypted_attributes) }.uniq
68
+ end
69
+
70
+ # The classes that store rows in the model's table: the model, its base
71
+ # class and every loaded descendant of the base class on the same table
72
+ # (single-table inheritance). Just the model for an abstract class or a
73
+ # class without a base class.
74
+ #
75
+ # @param model [Class]
76
+ # @return [Array<Class>]
77
+ def family(model)
78
+ return [model] unless model.respond_to?(:base_class) && !model.abstract_class?
79
+
80
+ base = model.base_class
81
+ table = model.table_name
82
+ [model, base, *base.descendants.select { |klass| !klass.abstract_class? && klass.table_name == table }].uniq
83
+ end
84
+
85
+ # Every loaded model class that stores rows in a table of that name:
86
+ # what a table a query joins stands for when it is on no model the
87
+ # caller knows (a `has_many :through`'s join table, a table a default
88
+ # scope or a host's join brings in: Operators::EncryptedAlias). Any
89
+ # loaded, non-abstract Active Record class whose table (its last
90
+ # segment, without a schema) is the name — the classes of a
91
+ # single-table hierarchy among them, so {.names} of any one of them
92
+ # covers the hierarchy. A class the application has not loaded yet
93
+ # (lazy loading in development) is not seen; nor is the database a
94
+ # class is on told apart (a table of that name in another database
95
+ # counts too: fail closed).
96
+ #
97
+ # @param table [String] the table's name, as the query gives it
98
+ # @param exact [Boolean] compare names as written (PostgreSQL), else
99
+ # without regard to case (Operators::RuleColumn.same_name?)
100
+ # @return [Array<Class>]
101
+ def models_on(table, exact: false)
102
+ ActiveRecord::Base.descendants.select do |klass|
103
+ next false if klass.abstract_class?
104
+
105
+ own = klass.table_name.to_s.split('.').last
106
+ own.present? && Operators::RuleColumn.same_name?(own, table.to_s, exact)
107
+ rescue StandardError
108
+ false # a class that cannot name its table (an anonymous one without `table_name`) stores rows in none
109
+ end
110
+ end
111
+
112
+ # Of `names` (column names), those that do not hold ciphertext: what SQL
113
+ # can compare as written.
114
+ #
115
+ # @param model [Class]
116
+ # @param names [Array<String>]
117
+ # @return [Array<String>]
118
+ def plain(model, names)
119
+ names.reject { |name| encrypted?(model, name) }
120
+ end
121
+
122
+ # Of `names`, the deterministically encrypted ones.
123
+ #
124
+ # @param model [Class]
125
+ # @param names [Array<String>]
126
+ # @return [Array<String>]
127
+ def deterministic(model, names)
128
+ names.select { |name| deterministic?(model, name) }
129
+ end
130
+
131
+ # The value a stored ciphertext holds, read through the attribute's type
132
+ # (past any reader the model overrides, as MaskedAttributes does); nil
133
+ # when this application cannot decrypt it (another key, not ciphertext).
134
+ # Under `support_unencrypted_data` Rails answers a value it cannot
135
+ # decrypt with the stored value itself: a result that is still
136
+ # ciphertext is nil too. A value stored in plaintext reads as written
137
+ # there (Rails' own reading).
138
+ #
139
+ # @param model [Class]
140
+ # @param name [String, Symbol]
141
+ # @param raw [Object] the value as stored (read_attribute_before_type_cast)
142
+ # @return [Object, nil]
143
+ def decrypt(model, name, raw)
144
+ value = model.type_for_attribute(name.to_s).deserialize(raw)
145
+ value unless value.is_a?(String) && ActiveRecord::Encryption.encryptor.encrypted?(value)
146
+ rescue ActiveRecord::Encryption::Errors::Base
147
+ nil
148
+ end
149
+ end
150
+ end
@@ -1,7 +1,9 @@
1
1
  require_relative 'query_scoping_warnings'
2
+ require_relative 'cache_warnings'
2
3
  require_relative 'boot_checks'
3
4
  require_relative 'masking_sync'
4
5
  require_relative 'masked_models'
6
+ require_relative 'admin_method_override'
5
7
 
6
8
  module PermissionForge
7
9
  # Wires the gem into the host application: initializers, the executor and
@@ -16,6 +18,41 @@ module PermissionForge
16
18
  g.test_framework :minitest
17
19
  end
18
20
 
21
+ # The admin's edit and delete forms POST with a hidden `_method`: the
22
+ # engine's own stack (it runs for the engine's routes only) turns them
23
+ # into PATCH / DELETE, so an API-only host, which has no
24
+ # Rack::MethodOverride, needs nothing for it. The JSON API's paths are
25
+ # left alone (AdminMethodOverride).
26
+ config.middleware.use PermissionForge::AdminMethodOverride
27
+
28
+ # Every request the host application serves is a web request
29
+ # (PermissionForge::WebRequest) from the top of its stack until the
30
+ # response is finished: masked_attributes_nil_user_strategy
31
+ # :hide_in_requests hides there, and :bypass records
32
+ # field.unmasked_without_user. At the top of the stack (unless a
33
+ # middleware is inserted above it with insert_before 0: StackCheck names
34
+ # it at boot), so that a middleware or a before_action that reads a model
35
+ # before anyone is signed in counts too.
36
+ config.app_middleware.unshift PermissionForge::WebRequest::Middleware
37
+
38
+ # A denial nobody rescues answers 403, not 500: the row-rule refusal of
39
+ # find_by / find_by! and the action guard's refusal (Rails' exceptions
40
+ # app renders the status: public/403.html when the host has one, else an
41
+ # empty body). A host's rescue_from runs first and still decides.
42
+ #
43
+ # Set as the gem loads, as ActiveRecord's railtie sets its own: before
44
+ # ActionDispatch copies the setting into ExceptionWrapper, and before the
45
+ # host's config/application.rb, whose entry for either name wins. Class
46
+ # names, not constants: nothing is loaded for this.
47
+ config.action_dispatch.rescue_responses.merge!(
48
+ 'PermissionForge::Concerns::PermissionScopedFindable::NotAuthorizedError' => :forbidden,
49
+ 'PermissionForge::ActionNotAuthorizedError' => :forbidden,
50
+ # A request value the admin cannot read (ParamShapes::InvalidParam, an
51
+ # ActionController::BadRequest): 400 as its parent, which Rails maps by
52
+ # exact class name only. The API answers it with a 422 itself.
53
+ 'PermissionForge::ParamShapes::InvalidParam' => :bad_request
54
+ )
55
+
19
56
  # The models (app/models/permission_forge) are autoloaded like any engine
20
57
  # model: their class bodies read the configuration after the host's
21
58
  # config/initializers, so nothing is re-declared after boot. No engine
@@ -47,7 +84,11 @@ module PermissionForge
47
84
  new_filters = PermissionForge.gdpr_filter_parameters
48
85
  app.config.filter_parameters += new_filters
49
86
 
50
- Rails.logger.info "[PermissionForge] Added #{new_filters.size} GDPR patterns to filter_parameters."
87
+ # Not while a generator boots the app (BootChecks): the install
88
+ # generator's boot predates the initializer it writes.
89
+ unless PermissionForge::BootChecks.generator_running?
90
+ Rails.logger.info "[PermissionForge] Added #{new_filters.size} GDPR patterns to filter_parameters."
91
+ end
51
92
  end
52
93
  end
53
94
 
@@ -87,6 +128,10 @@ module PermissionForge
87
128
  PermissionForge::BootChecks.run(PermissionForge.configuration)
88
129
  end
89
130
 
131
+ # The checks that read the routes, once they are drawn: at boot, or at
132
+ # their first use where Rails 8 draws them lazily (eager_load off).
133
+ config.after_routes_loaded { |app| PermissionForge::BootChecks.routes_loaded(app) }
134
+
90
135
  # A setting a model reads as it loads (user_class, audit_user_class,
91
136
  # tenant_model) changed by the host's own after_initialize, once a
92
137
  # production boot has eager-loaded the models: the classes keep what they
@@ -96,18 +141,5 @@ module PermissionForge
96
141
  initializer 'permission_forge.model_bindings', after: :load_config_initializers do |app|
97
142
  app.config.after_initialize { PermissionForge::ModelBindings.warn! }
98
143
  end
99
-
100
- # Deprecated: the masking machinery left the Engine (0.14). Removed in
101
- # PermissionForge::Deprecation::HORIZON.
102
- #
103
- # @return [Boolean] what MaskedModels.apply returns
104
- def self.apply_masked_attributes_to_managed_models(only: nil)
105
- PermissionForge::Deprecation.warn(
106
- 'PermissionForge::Engine.apply_masked_attributes_to_managed_models is deprecated; ' \
107
- 'use PermissionForge::MaskedModels.apply(only:)',
108
- callstack: caller_locations(1) # the host's call, not this method
109
- )
110
- PermissionForge::MaskedModels.apply(only: only)
111
- end
112
144
  end
113
145
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module ExecutionState
5
+ # What .set_until_restored hands back: puts back what the slot held when
6
+ # it was set — once, and only where it was set (ExecutionState.owner: the
7
+ # fiber of a :fiber slot, the execution context of an :execution slot).
8
+ # Called from anywhere else (a Rack server finishing the response on
9
+ # another thread) it does nothing there, and the slot stays set where it
10
+ # was.
11
+ #
12
+ # @api private
13
+ class Restore
14
+ # @param name [Symbol] a key of KEYS
15
+ # @param previous [Object, nil] what the slot held
16
+ def initialize(name, previous)
17
+ @name = name
18
+ @previous = previous
19
+ @owner = ExecutionState.owner(name)
20
+ @done = false
21
+ end
22
+
23
+ # Takes and ignores the arguments a `rack.response_finished` callback
24
+ # is given.
25
+ #
26
+ # @return [Boolean] whether it put the value back (false: done
27
+ # already, or called elsewhere)
28
+ def call(*)
29
+ return false if @done || !ExecutionState.owner(@name).equal?(@owner)
30
+
31
+ @done = true
32
+ ExecutionState.put_back(@name, @previous)
33
+ true
34
+ end
35
+ end
36
+
37
+ class << self
38
+ # Sets the slot past the end of the current block: for a value that
39
+ # must outlive the call that sets it (a Rack middleware keeps it until
40
+ # the response is finished, after its #call returned).
41
+ #
42
+ # @param name [Symbol] a key of KEYS
43
+ # @return [Restore] puts back what the slot held
44
+ def set_until_restored(name, value)
45
+ restore = Restore.new(name, self[name])
46
+ self[name] = value
47
+ restore
48
+ end
49
+
50
+ # A read-only reader of one slot, for a hot path that asks the same slot
51
+ # on every call without the lookup of #[] (WebRequest.active?): the
52
+ # store is looked up once, here.
53
+ #
54
+ # @param name [Symbol] a key of KEYS
55
+ # @return [Proc] returns what the slot holds
56
+ def reader(name)
57
+ store = storage(name)
58
+ -> { store[name] }
59
+ end
60
+
61
+ # Puts `previous` back in the slot (nil empties it), as .with does when
62
+ # its block ends.
63
+ #
64
+ # @param name [Symbol] a key of KEYS
65
+ # @param previous [Object, nil]
66
+ # @return [void]
67
+ def put_back(name, previous)
68
+ store = storage(name)
69
+ previous.nil? ? store.delete(name) : store[name] = previous
70
+ nil
71
+ end
72
+ end
73
+ end
74
+ end
@@ -122,7 +122,11 @@ module PermissionForge
122
122
  Key.new(:pf_audit_transaction_correlation, :execution, :clean,
123
123
  '[transaction, id] of the correlation id minted for the outermost open transaction'),
124
124
  Key.new(:pf_audit_runtime_once, :execution, :clean,
125
- 'Hash of the runtime audit events published once per request (Audit::Runtime.once?)')
125
+ 'Hash of the runtime audit events published once per request (Audit::Runtime.once?)'),
126
+ Key.new(:pf_web_request, :execution, :keep,
127
+ 'the Rack env of the web request in progress (WebRequest::Middleware, until the response is finished). ' \
128
+ 'Execution: under :thread isolation every fiber of the request sees it. Seen elsewhere it hides more (fails closed); emptied ' \
129
+ 'inside the request by a run completed there it would hide nothing: kept, the middleware puts it back')
126
130
  ].to_h { |key| [key.name, key] }.freeze
127
131
 
128
132
  CLEANED = KEYS.values.select { |key| key.at_run_end == :clean }.map(&:name).freeze
@@ -227,6 +231,9 @@ module PermissionForge
227
231
  end
228
232
  end
229
233
 
234
+ # Where slot `name` is kept now, the fiber or the execution context (Restore compares it).
235
+ def owner(name) = storage(name).equal?(FiberStorage) ? Fiber.current : ActiveSupport::IsolatedExecutionState.context
236
+
230
237
  # Empties every slot of this fiber and execution context: tests.
231
238
  #
232
239
  # @return [void]
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # Maps the single "access level" of a field row to ResourceField flags and
5
+ # back. Granting sets offer Hidden / Masked / Read-only / Editable; muting
6
+ # sets offer Unchanged / Force mask / Hide, where the same flags mean
7
+ # "remove" (see PermissionHashBuilder#mute_single_field). The admin's
8
+ # controls, the ChangeSet, the effective-access views and the one row
9
+ # builder (ResourceFieldGenerator::RowBuilder) all read the tables here.
10
+ # Before 0.15.0 this lived at PermissionForge::Admin::FieldLevel, which is
11
+ # kept as an alias.
12
+ #
13
+ # @api private
14
+ module FieldLevel
15
+ GRANTING = {
16
+ 'hidden' => { read_access: false, edit_access: false, mask_access: false },
17
+ 'masked' => { read_access: true, edit_access: false, mask_access: true },
18
+ 'read' => { read_access: true, edit_access: false, mask_access: false },
19
+ 'edit' => { read_access: true, edit_access: true, mask_access: false }
20
+ }.freeze
21
+
22
+ MUTING = {
23
+ 'leave' => { read_access: false, edit_access: false, mask_access: false },
24
+ 'mask' => { read_access: false, edit_access: false, mask_access: true },
25
+ 'hide' => { read_access: true, edit_access: true, mask_access: false }
26
+ }.freeze
27
+
28
+ # Ordered from most closed to most open (granting) — used to rank levels.
29
+ GRANTING_ORDER = %w[hidden masked read edit].freeze
30
+
31
+ module_function
32
+
33
+ def levels_for(muting:)
34
+ muting ? MUTING.keys : GRANTING.keys
35
+ end
36
+
37
+ # The level of a column with no setting: Unchanged in a Muting
38
+ # Permission Set (it mutes nothing: one state, one name), none in a
39
+ # granting one (unconfigured_field_strategy decides, and the row says so).
40
+ def unconfigured_level(muting:)
41
+ muting ? 'leave' : nil
42
+ end
43
+
44
+ # Whether a level is a row: every level of the set's kind, except
45
+ # Unchanged in a Muting Permission Set — it mutes nothing, which is what
46
+ # no row means. The one definition every path that creates rows reads
47
+ # (the row builder, the admin's presets and bulk add, the generator).
48
+ #
49
+ # @return [Boolean]
50
+ def row?(level, muting:)
51
+ valid?(level, muting: muting) && !(muting && level.to_s == 'leave')
52
+ end
53
+
54
+ # The level a row gets on its Resource: on a granting set Editable needs
55
+ # the Resource's Edit (the admin's level control disables it, turning
56
+ # the Resource's Edit off cascades Editable rows to Read-only, the
57
+ # effective-access views cap it), so without it Editable is Read-only —
58
+ # the row says what the field allows. A muting set's levels take access
59
+ # away and are never capped. The one definition every path that writes a
60
+ # level reads: the row builder (after the privacy default, so an
61
+ # Editable personal-data field becomes Read-only, never Masked) and the
62
+ # Fields tab's level edits.
63
+ #
64
+ # @param level [String]
65
+ # @param resource_edit [Boolean] the Resource grants Edit
66
+ # @param muting [Boolean]
67
+ # @return [String]
68
+ def capped(level, resource_edit:, muting:)
69
+ level = level.to_s
70
+ level == 'edit' && !muting && !resource_edit ? 'read' : level
71
+ end
72
+
73
+ def flags_for(level, muting:)
74
+ (muting ? MUTING : GRANTING).fetch(level.to_s) { raise ArgumentError, "Unknown field level: #{level.inspect}" }
75
+ end
76
+
77
+ def valid?(level, muting:)
78
+ (muting ? MUTING : GRANTING).key?(level.to_s)
79
+ end
80
+
81
+ # Derives the level from a field's flags. Flag combinations the UI never
82
+ # writes (e.g. edit + mask) collapse to the closest level.
83
+ # What a muting row takes away, for provenance (the WHY panel): its
84
+ # level, except a row that mutes edit but not read (API / console
85
+ # only), which takes editing away and leaves the field readable:
86
+ # 'no_edit', a level no form offers.
87
+ def muting_provenance(field)
88
+ level = level_of(field, muting: true)
89
+ level == 'hide' && !field.read_access ? 'no_edit' : level
90
+ end
91
+
92
+ def level_of(field, muting:)
93
+ read = field.read_access
94
+ edit = field.edit_access
95
+ mask = field.mask_access
96
+ return muting_level(read, edit, mask) if muting
97
+
98
+ return 'hidden' unless read
99
+ return 'masked' if mask
100
+
101
+ edit ? 'edit' : 'read'
102
+ end
103
+
104
+ def muting_level(read, edit, mask)
105
+ return 'hide' if read || edit
106
+
107
+ mask ? 'mask' : 'leave'
108
+ end
109
+
110
+ # Level an engine field-permission entry resolves to
111
+ # ({ read_access:, edit_access:, mask_access: } from the permissions hash).
112
+ def effective_level(entry)
113
+ return 'hidden' if entry.nil? || !entry[:read_access]
114
+ return 'masked' if entry[:mask_access]
115
+
116
+ entry[:edit_access] ? 'edit' : 'read'
117
+ end
118
+
119
+ def rank(level)
120
+ GRANTING_ORDER.index(level.to_s) || -1
121
+ end
122
+ end
123
+ end