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
@@ -12,8 +12,9 @@ module PermissionForge
12
12
  # offset is converted to it, and SQLite gets the text it compares with.
13
13
  # A date column takes the day of a date-time, a time column the time of
14
14
  # day. Anything else — another notation, `now`, `tomorrow`, the id that
15
- # `current_user` stands for — is UNUSABLE, unless the caster is the one
16
- # for a database that reads such text itself ({#as_written}).
15
+ # `current_user` stands for — is UNUSABLE. (On PostgreSQL, which reads
16
+ # date and time text itself, such text is asked of it:
17
+ # PostgresqlTypes::ReadBack.)
17
18
  class Temporal
18
19
  DAY = /(?<year>\d{4})-(?<month>\d\d?)-(?<day>\d\d?)/
19
20
  CLOCK = /(?:[01]?\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?/
@@ -23,28 +24,13 @@ module PermissionForge
23
24
 
24
25
  # @param format [Regexp] what the text has to look like
25
26
  # @param type [ActiveModel::Type::Value] casts the text and serializes the result
26
- # @param as_written [Boolean] hand text of another notation over as it is
27
- def initialize(format, type, as_written: false)
27
+ def initialize(format, type)
28
28
  @format = format
29
29
  @type = type
30
- @as_written = as_written
31
30
  end
32
31
 
33
32
  def call(value)
34
- return UNUSABLE unless value.is_a?(String)
35
-
36
- recognised(value.strip) || (@as_written ? value : UNUSABLE)
37
- end
38
-
39
- # The same caster for a database that reads date and time text itself
40
- # and raises on what it cannot read (PostgreSQL). Text this caster does
41
- # not recognise is handed over as it is written, as it was before
42
- # values were typed: `today`, a date in a local notation. What is not
43
- # text stays UNUSABLE.
44
- #
45
- # @return [Temporal]
46
- def as_written
47
- self.class.new(@format, @type, as_written: true)
33
+ recognised(value) || UNUSABLE
48
34
  end
49
35
 
50
36
  DATE = new(DAY_AND_CLOCK, ActiveRecord::Type::Date.new)
@@ -53,7 +39,10 @@ module PermissionForge
53
39
 
54
40
  private
55
41
 
56
- def recognised(text)
42
+ def recognised(value)
43
+ return unless value.is_a?(String)
44
+
45
+ text = value.strip
57
46
  parts = @format.match(text)
58
47
  @type.serialize(@type.cast(text)) if parts && real_day?(parts)
59
48
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'value_casters/number'
4
4
  require_relative 'value_casters/temporal'
5
+ require_relative 'value_casters/postgresql_types'
5
6
 
6
7
  module PermissionForge
7
8
  module Operators
@@ -30,33 +31,61 @@ module PermissionForge
30
31
  # Compared as written; the id `current_user` stands for, as its text.
31
32
  TEXT = ->(value) { value.nil? ? UNUSABLE : value.to_s }
32
33
 
33
- # A column type without a caster (uuid, json, an array column, ...) or a
34
- # field name that names no known column: the value is left to the database.
34
+ # A column type without a caster (hstore, money, xml, ...) or a field
35
+ # name that names no known column: the value is left to the database.
35
36
  UNTYPED = ->(value) { value }
36
37
 
38
+ # A json, jsonb or array column: no rule value is one of its values.
39
+ # PostgreSQL raised on most texts compared with one, and the others
40
+ # (an array or JSON literal) compare a whole document; MySQL and SQLite
41
+ # compare text. A rule on such a column matches nothing under every
42
+ # built-in operator that takes a value — the null checks take none and
43
+ # work — and a custom operator gets the value as written.
44
+ OPAQUE = ->(_value) { UNUSABLE }
45
+
37
46
  BY_TYPE = {
38
47
  integer: NUMBER, float: NUMBER, decimal: NUMBER,
39
48
  boolean: BOOLEAN,
40
49
  string: TEXT, text: TEXT,
41
50
  date: Temporal::DATE, time: Temporal::TIME,
42
- datetime: Temporal::DATETIME, timestamp: Temporal::DATETIME, timestamptz: Temporal::DATETIME
51
+ datetime: Temporal::DATETIME, timestamp: Temporal::DATETIME, timestamptz: Temporal::DATETIME,
52
+ json: OPAQUE, jsonb: OPAQUE,
53
+ # Types only PostgreSQL has (PostgresqlTypes); no other adapter reports them.
54
+ uuid: PostgresqlTypes::UUID, inet: PostgresqlTypes::INET, cidr: PostgresqlTypes::CIDR,
55
+ enum: PostgresqlTypes::NativeEnum
43
56
  }.freeze
44
57
 
45
- # PostgreSQL is typed like the others, with two things left the way it
46
- # always did them, so that no rule that works there stops working:
58
+ # PostgreSQL is typed like the others, with three differences:
47
59
  #
48
60
  # - It reads date and time text itself and raises on what it cannot
49
- # read. Text the Temporal casters do not recognise is left to it
50
- # (`due_on less_than '31 Jan 2026'`, `due_on equal 'today'`); on SQLite
51
- # and MySQL, which compare or convert such text, it stays UNUSABLE.
61
+ # read. Text the Temporal casters do not recognise is asked of it
62
+ # (PostgresqlTypes::ReadBack): what it reads is handed over
63
+ # (`due_on less_than '31 Jan 2026'`, `due_on equal 'today'`), anything
64
+ # else is UNUSABLE. On SQLite and MySQL, which compare or convert such
65
+ # text, it is UNUSABLE.
66
+ # - A column type no caster knows (hstore, money, interval, ...) is
67
+ # asked of it the same way ({ADAPTER_RULES}); `citext` is text.
52
68
  # - A `real` column is compared in single precision (Number::SINGLE_PRECISION).
53
69
  # The SQL type is the key: its ActiveRecord type is `float` as well.
54
- POSTGRESQL = BY_TYPE.transform_values { |caster| caster.is_a?(Temporal) ? caster.as_written : caster }
55
- .merge('real' => Number::SINGLE_PRECISION).freeze
70
+ POSTGRESQL = BY_TYPE.transform_values { |caster| caster.is_a?(Temporal) ? PostgresqlTypes::ReadBack.wrapping(caster) : caster }
71
+ .merge('real' => Number::SINGLE_PRECISION, 'citext' => TEXT).freeze
56
72
 
57
73
  # Adapter name => the casters of that database, where they are not BY_TYPE.
58
74
  BY_ADAPTER = { 'PostgreSQL' => POSTGRESQL, 'PostGIS' => POSTGRESQL }.freeze
59
75
 
76
+ # What a database's casters decide apart from the column types:
77
+ # - `unknown_type`: the caster of a column type none of its casters
78
+ # knows. UNTYPED (the value is left to the database) where a value of
79
+ # another notation is compared or converted; read back where the
80
+ # database raises on one.
81
+ # - `patterns`: the casters a LIKE pattern can be matched against, where
82
+ # not every type has LIKE. PostgreSQL has it for text alone (an
83
+ # integer, a date, a boolean raise there) — and a column nothing is
84
+ # known about (an alias a join gives) is left to it.
85
+ ADAPTER_RULES = {}.compare_by_identity.merge!(
86
+ POSTGRESQL => { unknown_type: PostgresqlTypes::ReadBack.wrapping(nil), patterns: [TEXT, UNTYPED].freeze }.freeze
87
+ ).freeze
88
+
60
89
  # The casters that read the two bounds of `between` together, where the
61
90
  # others cast one bound at a time. With nothing known about the column
62
91
  # the pair is read as it was before values were typed: both integers,
@@ -68,12 +97,31 @@ module PermissionForge
68
97
 
69
98
  # @param column [ActiveRecord::ConnectionAdapters::Column, nil]
70
99
  # @param adapter_name [String, nil] the adapter of the column's model
100
+ # @param model [Class, nil] the model whose table the column is in (a
101
+ # native enum's labels are read through it)
71
102
  # @return [#call] the caster for the column
72
- def self.for(column, adapter_name = nil)
73
- return UNTYPED if column.nil? || column.try(:array?)
103
+ def self.for(column, adapter_name = nil, model = nil)
104
+ return UNTYPED if column.nil?
105
+ return OPAQUE if column.try(:array?)
74
106
 
75
107
  casters = BY_ADAPTER.fetch(adapter_name, BY_TYPE)
76
- casters.fetch(column.sql_type) { casters.fetch(column.type, UNTYPED) }
108
+ caster = casters.fetch(column.sql_type) { casters.fetch(column.type) { ADAPTER_RULES.dig(casters, :unknown_type) || UNTYPED } }
109
+ caster.respond_to?(:of) ? caster.of(column, model) : caster
110
+ end
111
+
112
+ # Whether a LIKE pattern can be matched against a column of the
113
+ # caster's type. Not a json or array column, nor one of the PostgreSQL
114
+ # types without LIKE (uuid, inet, cidr, a native enum), nor — on
115
+ # PostgreSQL — anything but text ({ADAPTER_RULES}): a rule with a LIKE
116
+ # operator on one matches nothing.
117
+ #
118
+ # @param caster [#call] a caster {.for} returned
119
+ # @param adapter_name [String, nil] the adapter of the column's model
120
+ def self.pattern?(caster, adapter_name = nil)
121
+ only = ADAPTER_RULES.dig(BY_ADAPTER[adapter_name], :patterns)
122
+ return only.any? { |allowed| allowed.equal?(caster) } if only
123
+
124
+ !caster.equal?(OPAQUE) && PostgresqlTypes.pattern?(caster)
77
125
  end
78
126
  end
79
127
  end
@@ -23,8 +23,16 @@ module PermissionForge
23
23
  # Only the built-in operators are typed, each by the shape of its value
24
24
  # ({SHAPES}). A custom operator's value is returned as it came, and a
25
25
  # column nothing is known about leaves the value to the database. A rule
26
- # whose field_name is not a name (RuleColumn#malformed?) has no column to
27
- # compare with: its value is UNUSABLE whatever the operator.
26
+ # with no column to compare with ({.no_column?}) is UNUSABLE: one whose
27
+ # field_name is not a name, whatever the operator, and one that names a
28
+ # column its table does not have, under a built-in operator. So is a
29
+ # value with a NUL byte, under any operator ({.unquotable?}): no SQL
30
+ # literal carries one — the database reads the statement cut short, and
31
+ # every holder's query failed instead of being denied. So is any value
32
+ # under a built-in operator that takes one, on a column that holds
33
+ # ciphertext ({.encrypted?}): the statement compares the stored
34
+ # ciphertext with the rule's text (Active Record's own query rewriting
35
+ # never runs on a raw comparison). Why a value is UNUSABLE is {.problem}.
28
36
  module ValueTyping
29
37
  UNUSABLE = ValueCasters::UNUSABLE
30
38
  CURRENT_USER = 'current_user'
@@ -50,12 +58,73 @@ module PermissionForge
50
58
  # @return [Object] what the operator's strategy binds: one value, an Array
51
59
  # for `in` / `not_in` / `between`, or {UNUSABLE}
52
60
  def call(operator, value, target)
53
- return UNUSABLE if target.malformed?
61
+ return UNUSABLE if no_column?(operator, target) || unquotable?(value) || encrypted?(operator, target)
54
62
 
55
63
  shape = SHAPES[operator]
56
64
  shape ? send(shape, value, target) : value
57
65
  end
58
66
 
67
+ # Whether the value cannot be written into a statement as a literal: a
68
+ # String with a NUL byte (a row written past the model; ParamShapes
69
+ # drops NUL bytes from request parameters).
70
+ #
71
+ # @param value [Object] the rule's value
72
+ def unquotable?(value)
73
+ value.is_a?(String) && value.include?("\0")
74
+ end
75
+
76
+ # Whether the rule has no column to compare with: its field_name is not a
77
+ # name (RuleColumn#malformed?), or — under a built-in operator — it names
78
+ # a column its table does not have (RuleColumn#missing?), which the
79
+ # database would reject. A custom operator's strategy decides what it
80
+ # does with the name, so a missing column is left to it.
81
+ #
82
+ # @param operator [String] the rule's operator
83
+ # @param target [RuleColumn] the column the rule names
84
+ def no_column?(operator, target)
85
+ target.malformed? || (target.missing? && Constants::OPERATORS.include?(operator))
86
+ end
87
+
88
+ # Whether a built-in operator that takes a value ({SHAPES}) compares a
89
+ # column that holds ciphertext (RuleColumn#encrypted?). The null checks
90
+ # take no value and work: the ciphertext is NULL exactly when the value
91
+ # is. A custom operator's strategy decides for itself (a `where(attr:
92
+ # value)` of its own goes through the attribute's type).
93
+ #
94
+ # @param operator [String] the rule's operator
95
+ # @param target [RuleColumn] the column the rule names
96
+ def encrypted?(operator, target)
97
+ SHAPES.key?(operator) && target.encrypted?
98
+ end
99
+
100
+ # Why the value is {UNUSABLE} for the column it is compared with, nil
101
+ # when it is not: the operator and the column alone decide the first
102
+ # two — :encrypted_column ({.encrypted?}), :pattern_on_non_text (a LIKE
103
+ # operator on a column no pattern can be matched against,
104
+ # RuleColumn#pattern?) —, else :unusable_value (a value the column
105
+ # cannot hold). For a column that was found (RuleProblem judges a
106
+ # missing one first).
107
+ #
108
+ # @param operator [String] the rule's operator
109
+ # @param value [Object] the rule's value, as {.call} takes it
110
+ # @param target [RuleColumn] the column it is compared with
111
+ # @return [Symbol, nil]
112
+ def problem(operator, value, target)
113
+ operator_problem(operator, target) || (:unusable_value if call(operator, value, target).equal?(UNUSABLE))
114
+ end
115
+
116
+ # The reason the operator cannot be used on the column whatever the
117
+ # value (see {.problem}), or nil.
118
+ #
119
+ # @param operator [String] the rule's operator
120
+ # @param target [RuleColumn] the column it is compared with
121
+ # @return [Symbol, nil] :encrypted_column or :pattern_on_non_text
122
+ def operator_problem(operator, target)
123
+ return :encrypted_column if encrypted?(operator, target)
124
+
125
+ :pattern_on_non_text if SHAPES[operator] == :pattern && !target.column.nil? && !target.pattern?
126
+ end
127
+
59
128
  # `current_user` stands for the user's id wherever one value is compared.
60
129
  # In a list it is an element like any other.
61
130
  def current_user?(operator, value)
@@ -78,9 +147,10 @@ module PermissionForge
78
147
  caster(target).call(value)
79
148
  end
80
149
 
81
- # A LIKE pattern is built from the text of the value, whatever the column.
82
- def pattern(value, _target)
83
- ValueCasters::TEXT.call(value)
150
+ # A LIKE pattern is built from the text of the value, on a column a
151
+ # pattern can be matched against (ValueCasters.pattern?).
152
+ def pattern(value, target)
153
+ target.pattern? ? ValueCasters::TEXT.call(value) : UNUSABLE
84
154
  end
85
155
 
86
156
  # Every element has to be a value of the column; a list of none is no list.
@@ -100,6 +170,12 @@ module PermissionForge
100
170
  together ? together.call(bounds) : all_typed(bounds, typed)
101
171
  end
102
172
 
173
+ # The elements of a list or a range as written: the text split at every
174
+ # comma, each stripped (a trailing comma adds none). The rule's
175
+ # sentence splits them the same way.
176
+ #
177
+ # @param value [Object]
178
+ # @return [Array<String>]
103
179
  def split(value)
104
180
  value.to_s.split(',').map(&:strip)
105
181
  end
@@ -109,7 +185,7 @@ module PermissionForge
109
185
  typed.include?(UNUSABLE) ? UNUSABLE : typed
110
186
  end
111
187
 
112
- private_class_method :caster, :single, :pattern, :list, :pair, :split, :all_typed
188
+ private_class_method :caster, :single, :pattern, :list, :pair, :all_typed
113
189
  end
114
190
  end
115
191
  end
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PermissionForge
4
- # Boot warnings for the three query-scoping levels (called by BootChecks).
4
+ # Boot warnings for the three query-scoping levels (called by BootChecks;
5
+ # each goes through Warnings.emit).
5
6
  #
6
7
  # @api private
7
8
  module QueryScopingWarnings
@@ -13,9 +14,10 @@ module PermissionForge
13
14
  return unless pf_config.unscoped_query_detection == :raise
14
15
  return unless pf_config.unscoped_query_detection_environments.include?('production')
15
16
 
16
- Rails.logger&.warn '[PermissionForge] WARNING: unscoped_query_detection = :raise is enabled ' \
17
- 'for production. A single unscoped query will raise UnscopedQueryError ' \
18
- 'in live traffic — prefer :log in production and :raise in test/CI.'
17
+ Warnings.emit(:unscoped_detection_raise_in_production,
18
+ 'WARNING: unscoped_query_detection = :raise is enabled for production. A single unscoped ' \
19
+ 'query will raise UnscopedQueryError in live traffic — prefer :log in production and ' \
20
+ ':raise in test/CI.')
19
21
  end
20
22
 
21
23
  # Level 1 applies filter-type rules only; join-type rules still require
@@ -23,13 +25,15 @@ module PermissionForge
23
25
  def warn_join_rules_not_auto_scoped(pf_config)
24
26
  return unless pf_config.auto_query_scoping_enabled?
25
27
 
26
- join_rules = PermissionForge::PermissionSetRule.join_type_rules
27
- return unless join_rules.exists?
28
+ # The join rules (PermissionSetRule#join_rule?, the one definition; the scope is the SQL pre-filter).
29
+ count = PermissionForge::PermissionSetRule.join_type_rules.pluck(:join_type)
30
+ .count { |join_type| !PermissionForge::PermissionSetRule.filter_join_type?(join_type) }
31
+ return if count.zero?
28
32
 
29
- Rails.logger&.warn '[PermissionForge] SECURITY warning: auto_query_scoping is enabled but ' \
30
- "#{join_rules.count} join-type rule(s) exist. Join rules are NOT applied " \
31
- 'by auto-scoping — queries relying on them must go through ' \
32
- 'Model.permission_scoped.'
33
+ Warnings.emit(:join_rules_not_auto_scoped,
34
+ "auto_query_scoping is enabled but #{count} join-type rule(s) exist. Join rules " \
35
+ 'are NOT applied by auto-scoping — queries relying on them must go through ' \
36
+ 'Model.permission_scoped.', severity: :security)
33
37
  rescue ActiveRecord::StatementInvalid
34
38
  # Tables don't exist yet (pre-migration)
35
39
  end
@@ -42,9 +46,10 @@ module PermissionForge
42
46
  return unless pf_config.database_row_level_security
43
47
 
44
48
  unless PermissionForge::RLS.postgres?
45
- Rails.logger&.warn '[PermissionForge] WARNING: database_row_level_security is enabled but ' \
46
- 'the database adapter is not PostgreSQL. RLS policies cannot be enforced — ' \
47
- 'row-level security falls back to application-level scoping only.'
49
+ Warnings.emit(:rls_not_postgresql,
50
+ 'database_row_level_security is enabled but the database adapter is not ' \
51
+ 'PostgreSQL. RLS policies cannot be enforced — row-level security falls back to ' \
52
+ 'application-level scoping only.', severity: :security)
48
53
  return
49
54
  end
50
55
 
@@ -57,10 +62,10 @@ module PermissionForge
57
62
  stale = PermissionForge::RLS.drift_report.reject { |report| report[:in_sync] }
58
63
  return if stale.empty?
59
64
 
60
- Rails.logger&.warn '[PermissionForge] SECURITY warning: RLS policies are missing or stale for ' \
61
- "table(s): #{stale.map { |report| report[:table] }.join(', ')}. " \
62
- 'Rule rows have changed since the last policy sync (or policies were never ' \
63
- 'installed) — run `rake permission_forge:rls:sync`.'
65
+ Warnings.emit(:rls_policy_drift,
66
+ "RLS policies are missing or stale for table(s): #{stale.map { |report| report[:table] }.join(', ')}. " \
67
+ 'Rule rows have changed since the last policy sync (or policies were never installed) — ' \
68
+ 'run `rake permission_forge:rls:sync`.', severity: :security)
64
69
  rescue ActiveRecord::StatementInvalid
65
70
  # Tables don't exist yet (pre-migration)
66
71
  end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'rake_output'
4
+
3
5
  module PermissionForge
4
6
  # What every rake task of the gem does about the audit log: names who ran it
5
7
  # (ACTOR=, stored as actor_label with actor_type 'system' and source 'rake'),
6
8
  # records `report.generated` for the read-only ones, and logs through
7
- # Rails.logger. The tasks' own output is untouched.
9
+ # Rails.logger. What the tasks print is RakeOutput's.
8
10
  #
9
11
  # PermissionForge::RakeAudit.changing('permission_forge:erasure') { ... } # warns without ACTOR
10
12
  # PermissionForge::RakeAudit.reporting('permission_forge:audit', counts: ->(r) { r.counts }) { report }
@@ -52,9 +54,8 @@ module PermissionForge
52
54
  def run(task, env, &)
53
55
  actor = label(task, env)
54
56
  log(task, "run by #{actor}")
55
- PermissionForge::Audit::Context.with(
56
- actor: PermissionForge::Audit::Actor.system(actor), source: 'rake', reason: env['REASON'].presence, &
57
- )
57
+ context = { actor: PermissionForge::Audit::Actor.system(actor), source: 'rake', reason: env['REASON'].presence }
58
+ PermissionForge::Audit::Context.with(**context, &)
58
59
  end
59
60
 
60
61
  def missing_actor_warning(task)
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # The one reading of DRY_RUN= for the gem's rake tasks that delete data
5
+ # (`permission_forge:erasure`, `permission_forge:audit_events:purge`). Only
6
+ # an explicit false (`false`, `0`, `no`, any case) runs for real; any other
7
+ # value (`true`, `1`, `yes`, a typo) is a dry run. Unset or empty, the task
8
+ # keeps its own default: the erasure is a dry run, the purge deletes (a
9
+ # scheduled purge must not silently stop).
10
+ #
11
+ # dry_run = PermissionForge::RakeDryRun.dry_run?(ENV.fetch('DRY_RUN', nil), default: true)
12
+ #
13
+ # The task reads the variable itself, so the rake lints see it read.
14
+ #
15
+ # @api private
16
+ module RakeDryRun
17
+ # The values that run a task for real.
18
+ EXECUTE = %w[false 0 no].freeze
19
+
20
+ module_function
21
+
22
+ # @param value [String, nil] what DRY_RUN= was set to
23
+ # @param default [Boolean] the answer when it is unset or empty
24
+ # @return [Boolean] whether the task only counts
25
+ def dry_run?(value, default:)
26
+ given = value.to_s.strip.downcase
27
+ return default if given.empty?
28
+
29
+ !EXECUTE.include?(given)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # What the gem's rake tasks print: one presenter for a run's result, so the
5
+ # terminal says what the summary audit event records — one line per key,
6
+ # named — the cache outcome (Cache::Outcome: confirmed, scope, unconfirmed)
7
+ # where the result carries one, and people named as the admin names them
8
+ # (Admin::PersonLabel), never "User 2". Rake output stays English.
9
+ #
10
+ # PermissionForge::RakeOutput.report("Erasure complete for #{RakeOutput.person(user)}:", result)
11
+ # PermissionForge::RakeOutput.people(ids) # => { "12" => "Rae Rake (id 12)" }
12
+ #
13
+ # @api private
14
+ module RakeOutput
15
+ # The label of each key a service answers or an event records — one
16
+ # table: a key whose label says a change was made carries its dry-run
17
+ # form as a second label ([done, would]), so a dry run never prints
18
+ # "Audit events deleted" (field test round 7, E#55). The event key and
19
+ # the result key of one fact share a label (set_names / permission_sets),
20
+ # so a reader finds every event key in the output. A key without one is
21
+ # printed as its words.
22
+ LABELS = {
23
+ 'assignments_removed' => ['Permission set assignments removed', 'Permission set assignments that would be removed'],
24
+ 'assignments_count' => 'Permission set assignments',
25
+ 'group_assignments_removed' => ['Group memberships removed', 'Group memberships that would be removed'],
26
+ 'group_assignments_count' => 'Group memberships',
27
+ 'assignments_remaining_elsewhere' => 'Assignments remaining in other tenants',
28
+ 'group_assignments_remaining_elsewhere' => 'Group memberships remaining in other tenants',
29
+ 'permission_sets' => 'Permission sets', 'set_names' => 'Permission sets',
30
+ 'groups' => 'Groups', 'group_names' => 'Groups',
31
+ 'actor_labels_cleared' => ['Audit log name labels cleared as actor', 'Audit log name labels that would be cleared as actor'],
32
+ 'record_labels_cleared' => ['Audit log name labels cleared as subject', 'Audit log name labels that would be cleared as subject'],
33
+ 'actor_labels_to_clear' => 'Audit log name labels to clear as actor',
34
+ 'record_labels_to_clear' => 'Audit log name labels to clear as subject',
35
+ 'events_scrubbed' => ['Audit events with a name label cleared', 'Audit events whose name label would be cleared'],
36
+ 'callback_invoked' => 'on_erasure callback',
37
+ 'removed' => ['Expired assignments removed', 'Expired assignments that would be removed'],
38
+ 'expired_count' => 'Expired assignments',
39
+ 'invalidated' => ['People whose cached permissions were invalidated', 'People whose cached permissions would be invalidated'],
40
+ 'affected_user_ids' => 'People affected',
41
+ 'strategy' => 'Strategy', 'users' => ['User visits invalidated', 'User visits that would be invalidated'], 'tenants' => 'Tenants',
42
+ 'purged' => ['Audit events deleted', 'Audit events that would be deleted'],
43
+ 'count' => ['Audit events deleted', 'Audit events that would be deleted'],
44
+ 'cutoff' => ['Deleted when older than', 'Would delete the events older than'],
45
+ 'period' => 'Retention period', 'period_seconds' => 'Retention period',
46
+ 'retention_configured' => 'Retention configured',
47
+ 'cache_confirmed' => ['Cached permissions removed', 'Cached permissions that would be removed'],
48
+ 'process_local' => 'Cache scope', 'in_request' => 'Ran in',
49
+ 'unconfirmed' => 'People the cache store did not confirm'
50
+ }.freeze
51
+
52
+ # Keys of a result the cache lines or the title say already.
53
+ SILENT = %w[cache_cleared dry_run].freeze
54
+ # Keys printed as people.
55
+ PEOPLE_KEYS = %w[affected_user_ids].freeze
56
+ PEOPLE_SHOWN = 50
57
+ STRATEGIES = { 'pattern' => 'pattern (the store removed the keys by pattern; users not counted)',
58
+ 'enumerated' => 'enumerated (every user invalidated, in slices)' }.freeze
59
+ # "Cached permissions removed", by confirmed and by the store's scope: one
60
+ # outcome reads as one message, never as a success line contradicted by
61
+ # the scope line.
62
+ # :none — an operation that did no cache work (Cache::Outcome#none?: a dry run).
63
+ CONFIRMED = { true => 'yes (the cache store confirmed it)',
64
+ false => 'NO — the cache store did not confirm it; the old permissions may be served until cache_ttl',
65
+ nil => 'not known yet — the invalidation follows the commit of the surrounding transaction',
66
+ none: 'none — no cache work was done (a dry run changes nothing)' }.freeze
67
+ CONFIRMED_HERE = 'yes, in this process (its own store confirmed it; see Cache scope)'
68
+ CALLBACK = { true => 'ran', false => 'none configured', nil => 'runs when the surrounding transaction commits' }.freeze
69
+ # The "Cache scope" line of a process-local store (Cache::Outcome#process_local?) and of a shared one.
70
+ PROCESS_LOCAL_NOTE = 'this process only — an in-memory store (cache_backend :memory, or :memory_store) lives in each ' \
71
+ 'process, so the web and job processes keep their cached permissions until they restart or the ' \
72
+ 'entries expire (cache_ttl). Invalidate the users from inside those processes, or restart them.'
73
+ SHARED_NOTE = 'a shared store — every process sees the change; each user rebuilds on their next request.'
74
+ RAN_IN = { true => 'a request served by a web process', false => 'a process of its own (rake, runner, console or job)' }.freeze
75
+ NOT_IN_LIST = 'not in the people list'
76
+
77
+ module_function
78
+
79
+ # Prints the title and one line per key of `data` (a service's result,
80
+ # symbol or string keys); `cache:` (a Cache::Outcome or its Hash) prints
81
+ # as the cache lines. A dry run (the result's `dry_run: true`) prints
82
+ # each label in its "would" form.
83
+ #
84
+ # @param title [String]
85
+ # @param data [Hash]
86
+ # @param io [IO]
87
+ # @return [void]
88
+ def report(title, data, io: $stdout)
89
+ io.puts title
90
+ lines(data).each { |line| io.puts " #{line}" }
91
+ end
92
+
93
+ # @return [Array<String>] "Label: value" per key
94
+ def lines(data)
95
+ data = data.transform_keys(&:to_s)
96
+ dry_run = data['dry_run'] == true
97
+ data.except(*SILENT).flat_map do |key, value|
98
+ key == 'cache' ? cache_lines(value, data, dry_run: dry_run) : ["#{label(key, dry_run: dry_run)}: #{format_value(key, value)}"]
99
+ end
100
+ end
101
+
102
+ # @param key [String, Symbol]
103
+ # @param dry_run [Boolean] the label's "would" form, when it has one
104
+ # @return [String]
105
+ def label(key, dry_run: false)
106
+ labels = Array(LABELS.fetch(key.to_s) { key.to_s.tr('_', ' ').then { |words| words[0].to_s.upcase + words[1..].to_s } })
107
+ dry_run ? labels.last : labels.first
108
+ end
109
+
110
+ # @param user [Object, String, Integer] a host user, or an id
111
+ # @return [String] "Rae Rake (id 12)"
112
+ def person(user)
113
+ return people([user]).values.first if user.is_a?(String) || user.is_a?(Integer)
114
+
115
+ "#{person_name(user)} (id #{user.id})"
116
+ end
117
+
118
+ # The people `ids` name, within config.user_list_resolver
119
+ # (Admin::PeopleDirectory, as the admin looks people up).
120
+ #
121
+ # @param ids [Array]
122
+ # @return [Hash{String => String}] id → "Name (id N)", or "id N (not in the people list)"
123
+ def people(ids)
124
+ found = Admin::PeopleDirectory.new.find_all(ids).index_by { |user| user.id.to_s }
125
+ ids.map(&:to_s).uniq.to_h do |id|
126
+ user = found[id]
127
+ [id, user ? person(user) : "id #{id} (#{NOT_IN_LIST})"]
128
+ end
129
+ end
130
+
131
+ def person_name(user)
132
+ label = Admin::PersonLabel.new(user)
133
+ name = label.name
134
+ name == I18n.t('permission_forge.shared.user_fallback', id: user.id) ? 'no name or email on record' : name
135
+ end
136
+
137
+ # The outcome's lines: what the store confirmed (in this process, when
138
+ # the store is process-local), the store's scope, where it ran, and the
139
+ # unconfirmed count unless the result prints its own — or, for an
140
+ # operation that did no cache work (Outcome#none?), that one line.
141
+ def cache_lines(value, data, dry_run: false)
142
+ outcome = Cache::Outcome.wrap(value)
143
+ removed = label('cache_confirmed', dry_run: dry_run)
144
+ return ["#{removed}: #{CONFIRMED.fetch(:none)}"] if outcome.none?
145
+
146
+ lines = ["#{removed}: #{confirmed_text(outcome)}",
147
+ "#{label('process_local')}: #{outcome.process_local? ? PROCESS_LOCAL_NOTE : SHARED_NOTE}",
148
+ "#{label('in_request')}: #{RAN_IN.fetch(outcome.in_request?)}"]
149
+ lines << "#{label('unconfirmed')}: #{plain(outcome.unconfirmed)}" unless data.key?('unconfirmed')
150
+ lines
151
+ end
152
+
153
+ # What the store confirmed: in this process when the store is process-local.
154
+ def confirmed_text(outcome)
155
+ outcome.confirmed? && outcome.process_local? ? CONFIRMED_HERE : CONFIRMED.fetch(outcome.confirmed)
156
+ end
157
+
158
+ # One table of value shapes.
159
+ def format_value(key, value)
160
+ case key
161
+ when 'callback_invoked' then CALLBACK.fetch(value)
162
+ when 'strategy' then STRATEGIES.fetch(value.to_s, value.to_s)
163
+ when 'users' then value.nil? ? 'not counted (removed by key pattern)' : value.to_s
164
+ when 'period', 'period_seconds' then "#{value} seconds (#{(value.to_i / 86_400.0).round(1)} days)"
165
+ when *PEOPLE_KEYS then people_list(Array(value))
166
+ else plain(value)
167
+ end
168
+ end
169
+
170
+ def people_list(ids)
171
+ return 'none' if ids.empty?
172
+
173
+ names = people(ids.first(PEOPLE_SHOWN)).values
174
+ names << "and #{ids.size - PEOPLE_SHOWN} more" if ids.size > PEOPLE_SHOWN
175
+ "#{ids.size} — #{names.join(', ')}"
176
+ end
177
+
178
+ def plain(value)
179
+ case value
180
+ when true then 'yes'
181
+ when false then 'no'
182
+ when nil then 'not known'
183
+ when Array then value.empty? ? 'none' : value.join(', ')
184
+ when Time, DateTime, ActiveSupport::TimeWithZone then value.iso8601
185
+ else value.to_s
186
+ end
187
+ end
188
+ end
189
+ end