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,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # For the "Review and save" step of a batched edit (Admin::ChangeSet): what
5
+ # to say when there is nothing to save, and once the edit is applied.
6
+ #
7
+ # @api private
8
+ module ChangeReview
9
+ extend ActiveSupport::Concern
10
+
11
+ private
12
+
13
+ # A plain notice, or — when the request turned on flags their
14
+ # dependencies keep off (ChangeSet#refused: Edit with Read off, ...) — an
15
+ # alert that says which and why, instead of a bare "No changes to save."
16
+ #
17
+ # @return [Hash] for redirect_to
18
+ def pf_nothing_to_save_flash(change_set)
19
+ refused = change_set.refused
20
+ return { notice: pf_no_changes_text } if refused.empty?
21
+
22
+ reasons = refused.map { |refusal| helpers.pf_refusal_description(refusal) }
23
+ { alert: t('permission_forge.flash.permission_sets.nothing_applied', reasons: reasons.join('; ')) }
24
+ end
25
+
26
+ # The flash of an applied batched edit: how many changes, for whom, and
27
+ # what the cache store confirmed (CacheConfirmation) — or, when it
28
+ # applied none, what the review step says (#pf_nothing_to_save_flash:
29
+ # "No changes to save.", or why each requested change was refused).
30
+ #
31
+ # @param change_set [PermissionForge::Admin::ChangeSet] after #apply!
32
+ # @param outcome [PermissionForge::Cache::Outcome]
33
+ # @return [Hash] for redirect_to
34
+ def pf_applied_flash(change_set, outcome)
35
+ changes = change_set.changes
36
+ return pf_nothing_to_save_flash(change_set) if changes.empty?
37
+
38
+ people = helpers.pf_people_text(pf_people_count(@permission_set))
39
+ cache_flash_text(outcome, t('permission_forge.flash.permission_sets.saved', count: changes.size, people: people))
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # What the admin says when a copy (Duplicate, "Start from") is refused:
5
+ # the row the validations refused and why, instead of a 500. A copy keeps
6
+ # its original's stored values (Concerns::KeepsCopiedValues), so this is
7
+ # left for a row no copy can hold (Modify All without View All, say); the
8
+ # copy is made whole or not at all (one transaction).
9
+ #
10
+ # @api private
11
+ module CopyRefusal
12
+ # The record kinds a copy is made of, by class.
13
+ KINDS = {
14
+ 'PermissionForge::PermissionSet' => :permission_set, 'PermissionForge::PermissionSetGroup' => :permission_set_group,
15
+ 'PermissionForge::Resource' => :resource, 'PermissionForge::ResourceField' => :resource_field,
16
+ 'PermissionForge::ResourceAction' => :resource_action, 'PermissionForge::PermissionSetRule' => :permission_set_rule
17
+ }.freeze
18
+
19
+ private
20
+
21
+ # @param error [ActiveRecord::RecordInvalid]
22
+ # @param name [String] the name of what was being copied
23
+ # @return [String]
24
+ def pf_copy_refused_message(error, name)
25
+ record = error.record
26
+ t('permission_forge.flash.copy_refused.message', name: name, record: pf_copy_refused_record(record),
27
+ errors: pf_copy_refused_errors(record.errors))
28
+ end
29
+
30
+ # "Resource “Customer”"
31
+ def pf_copy_refused_record(record)
32
+ t("permission_forge.flash.copy_refused.kinds.#{KINDS.fetch(record.class.name, :record)}", name: record.try(:name).to_s)
33
+ end
34
+
35
+ def pf_copy_refused_errors(errors)
36
+ PermissionForge::Admin::ListSentence.call(PermissionForge::Admin::ErrorSentence.all(errors))
37
+ end
38
+ end
39
+ end
@@ -18,6 +18,107 @@ module PermissionForge
18
18
  value.is_a?(String) ? value.delete("\0").strip : ''
19
19
  end
20
20
 
21
+ # A request value that cannot be read, naming the parameter: a 400 in
22
+ # the admin, a 422 in the API keyed by the parameter like a validation
23
+ # error (`{ "expires_at": ["is not an ISO 8601 date or time"] }`).
24
+ #
25
+ # @api private
26
+ class InvalidParam < ActionController::BadRequest
27
+ # @return [String] the parameter, e.g. "expires_at"
28
+ attr_reader :param
29
+ # @return [String] what is wrong with it, without its name
30
+ attr_reader :detail
31
+
32
+ def initialize(param, detail)
33
+ @param = param.to_s
34
+ @detail = detail
35
+ super("#{@param} #{detail}")
36
+ end
37
+
38
+ # The field-keyed messages of the API's 422 (`{ "expires_at" => [...] }`).
39
+ #
40
+ # @return [Hash{String => Array<String>}]
41
+ def messages = { param => [detail] }
42
+ end
43
+
44
+ # A flag that is not true or false (ParamShapes.boolean): the API
45
+ # answers it like any InvalidParam (422 keyed by it); the admin, whose
46
+ # forms post "0" and "1", goes back with a flash and changes nothing
47
+ # (ApplicationController).
48
+ #
49
+ # @api private
50
+ class InvalidFlag < InvalidParam
51
+ # @param param [String, Symbol] the flag, e.g. "is_active"
52
+ def initialize(param)
53
+ super(param, I18n.t('permission_forge.api.errors.boolean_invalid'))
54
+ end
55
+ end
56
+
57
+ # What a boolean parameter is read as: JSON's true and false, their
58
+ # strings, and 1 / 0 as a number or a string (see .boolean).
59
+ BOOLEANS = { true => true, 'true' => true, 1 => true, '1' => true,
60
+ false => false, 'false' => false, 0 => false, '0' => false }.freeze
61
+
62
+ # The words a boolean is also sent as, in any case: what a form posts
63
+ # (`on` for a checkbox without a value; `off`) and the letters
64
+ # PostgreSQL and ActiveModel write (`t`, `f`).
65
+ BOOLEAN_WORDS = { 'true' => true, 't' => true, 'on' => true, '1' => true,
66
+ 'false' => false, 'f' => false, 'off' => false, '0' => false }.freeze
67
+
68
+ # The one reading of a boolean the admin and the API take: BOOLEANS,
69
+ # or a String that is one of BOOLEAN_WORDS in any case. Anything else
70
+ # ("yes", "nope", "", 2, 1.5, a list) is no boolean: a reader refuses
71
+ # it (InvalidParam), never takes it for true as ActiveModel's cast does.
72
+ #
73
+ # @return [Boolean, nil] nil when the value is no boolean
74
+ def self.boolean(value)
75
+ return BOOLEANS[value] if BOOLEANS.key?(value)
76
+
77
+ BOOLEAN_WORDS[value.downcase] if value.is_a?(String)
78
+ end
79
+
80
+ # A flag `key` as true or false (.boolean).
81
+ #
82
+ # @raise [InvalidFlag] for anything else
83
+ # @return [Boolean]
84
+ def self.flag(key, value)
85
+ read = boolean(value)
86
+ read.nil? ? raise(InvalidFlag, key) : read
87
+ end
88
+
89
+ # `attrs` with each of `keys` it has read as a flag (.flag), in place.
90
+ #
91
+ # @raise [InvalidFlag] keyed by the first flag that is no boolean
92
+ # @return [Hash, ActionController::Parameters] attrs
93
+ def self.flags(attrs, keys)
94
+ keys.map(&:to_s).select { |key| attrs.key?(key) }.each { |key| attrs[key] = flag(key, attrs[key]) }
95
+ attrs
96
+ end
97
+
98
+ # The time an ISO 8601 String names, in Time.zone: see IsoTime.
99
+ #
100
+ # @return [ActiveSupport::TimeWithZone, nil] nil for anything else
101
+ def self.time(value) = IsoTime.parse(value)
102
+
103
+ # What a whole-number parameter is read from: digits with an optional
104
+ # minus sign, as a String or a JSON number. "2abc", "1.5", "+7" and ""
105
+ # are not one.
106
+ WHOLE_NUMBER = /\A-?\d+\z/
107
+
108
+ # The whole number `value` holds when it is inside `range`, else
109
+ # `default`: see #pf_integer_param.
110
+ #
111
+ # @param range [Range] of Integers, endless for an upper bound the
112
+ # caller clamps itself (`1..`)
113
+ # @return [Integer, Object] the number, or `default`
114
+ def self.integer(value, default:, range:)
115
+ text = value.to_s.strip if value.is_a?(String) || value.is_a?(Integer)
116
+ return default unless text&.match?(WHOLE_NUMBER)
117
+
118
+ number = Integer(text, 10)
119
+ range.cover?(number) ? number : default
120
+ end
121
+
21
122
  # `value` with NUL bytes removed from every String in it, however deep.
22
123
  #
23
124
  # @return [Object] a new Parameters / Array, or the value itself
@@ -64,24 +165,123 @@ module PermissionForge
64
165
  tab if tabs.include?(tab)
65
166
  end
66
167
 
168
+ # params[key] when it is a whole number (digits, or a JSON number)
169
+ # inside `range`, else `default`. For a page, a page size, a count: a
170
+ # value that cannot be read is the default (`per_page=abc` lists the
171
+ # default page size), never 0, 1 or a 500 — a typo in a paging
172
+ # parameter must not refuse the page. A parameter whose misreading must
173
+ # refuse the request is read by a strict reader (pf_boolean_param,
174
+ # pf_time_param: InvalidParam).
175
+ #
176
+ # pf_integer_param(:per_page, default: 25, range: 1..)
177
+ # pf_integer_param(:hour, default: nil, range: 0..23)
178
+ def pf_integer_param(key, default:, range:)
179
+ ParamShapes.integer(params[key], default: default, range: range)
180
+ end
181
+
67
182
  # The Integer ids in params[key] (one value or a list); anything that is
68
183
  # not digits is dropped.
69
184
  def pf_id_list(key)
70
185
  Array.wrap(params[key]).filter_map { |value| PermissionForge::Admin::RecordId.cast(value) }
71
186
  end
72
187
 
73
- # The time params[key] names, nil when it is blank.
188
+ # params[key] as true or false (ParamShapes.boolean); false when it is
189
+ # absent or null.
190
+ #
191
+ # @raise [InvalidFlag] for anything else ("yes", "", 2, a list): a value
192
+ # that cannot be read must never be taken for false
193
+ def pf_boolean_param(key)
194
+ value = params[key]
195
+ return false if value.nil?
196
+
197
+ ParamShapes.flag(key, value)
198
+ end
199
+
200
+ # `attrs` (an attribute hash of a form) with each of `keys` it has read
201
+ # as true or false (ParamShapes.flags): the admin's flags. ActiveModel
202
+ # would read "nope" as true.
74
203
  #
75
- # @raise [ActionController::BadRequest] for a value that is no time:
76
- # a date that cannot be read must never become "no end date"
204
+ # @raise [InvalidFlag] keyed by the attribute for anything else (the
205
+ # admin answers it with a flash: nothing is changed)
206
+ def pf_boolean_attributes(attrs, *keys)
207
+ ParamShapes.flags(attrs, keys)
208
+ end
209
+
210
+ # The time params[key] names (an ISO 8601 date or date-time, IsoTime),
211
+ # nil when it is blank.
212
+ #
213
+ # @raise [InvalidParam] for anything else ("tuesday", "2026-13-45"): a
214
+ # date that cannot be read must never become "no end date", nor a time
215
+ # somebody did not mean
77
216
  def pf_time_param(key)
78
217
  value = params[key]
79
218
  return nil if value.blank?
80
219
 
81
- time = Time.zone.parse(value.to_s) if value.is_a?(String)
82
- time || raise(ActionController::BadRequest, "#{key} is not a time")
83
- rescue ArgumentError
84
- raise ActionController::BadRequest, "#{key} is not a time"
220
+ ParamShapes.time(value) || raise(InvalidParam.new(key, I18n.t('permission_forge.api.errors.not_iso8601_time')))
221
+ end
222
+
223
+ # The ISO 8601 forms a time parameter is read in, and nothing else
224
+ # (Time.zone.parse reads "tuesday" as today 00:00):
225
+ #
226
+ # - a date, `2026-10-22`: the end of that day in Time.zone
227
+ # (23:59:59.999999999), as the admin's end-date presets: an assignment
228
+ # that ends on a date lasts through it;
229
+ # - a date and a time of day, `2026-10-22T10:00`, with optional seconds
230
+ # and fraction (`10:00:00`, `10:00:00.5`), `T` or one space between
231
+ # them, and an optional offset, `Z` or `+02:00`; without one the time
232
+ # is read in Time.zone.
233
+ #
234
+ # Every field must exist on the calendar and the clock (no 2026-02-30,
235
+ # no 24:00, no leap second).
236
+ #
237
+ # @api private
238
+ module IsoTime
239
+ FORMAT = /\A(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})
240
+ (?:[T\x20](?<hour>\d{2}):(?<minute>\d{2})(?::(?<second>\d{2})(?:\.(?<fraction>\d{1,9}))?)?
241
+ (?<offset>Z|[+-]\d{2}:\d{2})?)?\z/x
242
+
243
+ # @return [ActiveSupport::TimeWithZone, nil] nil for a value that is not
244
+ # one of the forms above
245
+ def self.parse(value)
246
+ match = FORMAT.match(value.strip) if value.is_a?(String)
247
+ fields = match && fields_of(match)
248
+ offset = match && match[:offset]
249
+ return unless fields && valid_offset?(offset)
250
+
251
+ match[:hour] ? at(fields, offset) : Time.zone.local(*fields.first(3)).end_of_day
252
+ end
253
+
254
+ # [year, month, day, hour, minute, seconds with fraction], nil when a
255
+ # field is off the calendar or the clock.
256
+ def self.fields_of(match)
257
+ date = match.values_at(:year, :month, :day).map(&:to_i)
258
+ clock = match.values_at(:hour, :minute, :second).map(&:to_i)
259
+ return unless Date.valid_date?(*date) && valid_clock?(*clock)
260
+
261
+ [*date, *clock.first(2), clock.last + fraction_of(match[:fraction])]
262
+ end
263
+
264
+ def self.valid_clock?(hour, minute, second)
265
+ hour < 24 && minute < 60 && second < 60
266
+ end
267
+
268
+ # nil (no offset) and Z are valid; ±HH:MM within a day.
269
+ def self.valid_offset?(offset)
270
+ return true if offset.nil? || offset == 'Z'
271
+
272
+ offset[1, 2].to_i < 24 && offset[4, 2].to_i < 60
273
+ end
274
+
275
+ def self.fraction_of(digits)
276
+ digits ? Rational(digits.to_i, 10**digits.size) : 0
277
+ end
278
+
279
+ def self.at(fields, offset)
280
+ return Time.zone.local(*fields) unless offset
281
+
282
+ Time.new(*fields, offset == 'Z' ? '+00:00' : offset).in_time_zone
283
+ end
284
+ private_class_method :fields_of, :valid_clock?, :valid_offset?, :fraction_of, :at
85
285
  end
86
286
  end
87
287
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # An admin save that changes nothing: not written, and said so. One
5
+ # definition for every form that saves a record (the field drawer, the
6
+ # action form, the rule builder, the group page, the Conditions tab, an
7
+ # end date, the set and Resource pages):
8
+ #
9
+ # - a record is saved only when it holds a change an audit event records
10
+ # (#pf_save_changed, RecordsAuditEvents#audited_changes_pending?: a blank
11
+ # posted over generated text is no change either, Admin::Prefill.posted;
12
+ # a stamp of updated_by alone is none) — an unchanged record is not
13
+ # saved, so no commit callback invalidates its holders' cached
14
+ # permissions. A batched edit with no change (Admin::ChangeSet) saves
15
+ # nothing either;
16
+ # - such a save says flash.no_changes (#pf_save_flash) — never the form's
17
+ # "Saved …", and with no cache note: no cache work was done.
18
+ #
19
+ # Field test round 8 (B#70).
20
+ #
21
+ # @api private
22
+ module SaveNotice
23
+ extend ActiveSupport::Concern
24
+
25
+ # @param saved [ActiveRecord::Base, Boolean] the record just saved (or
26
+ # left unsaved by #pf_save_changed), or whether a batched edit applied
27
+ # any change
28
+ # @return [Boolean] whether the save changed anything
29
+ def self.saved_anything?(saved)
30
+ return saved if [true, false].include?(saved)
31
+
32
+ saved.saved_audited_changes?
33
+ end
34
+
35
+ private
36
+
37
+ # Assigns `attrs` to `record`, then runs the block (the save: save,
38
+ # save!) only when the record holds a change an audit event records.
39
+ #
40
+ # @param record [ActiveRecord::Base] one of the gem's models
41
+ # @param attrs [Hash] the attributes the form posted
42
+ # @yieldparam record [ActiveRecord::Base]
43
+ # @return the block's value, or true when there was nothing to save
44
+ def pf_save_changed(record, attrs = {})
45
+ record.assign_attributes(attrs)
46
+ return true unless record.audited_changes_pending?
47
+
48
+ yield record
49
+ end
50
+
51
+ # The flash of a save: { notice: "No changes to save." } when it changed
52
+ # nothing (SaveNotice.saved_anything?), else what the block answers — a
53
+ # flash Hash (CacheConfirmation#cache_flash_text), or a String, the notice.
54
+ #
55
+ # @param saved [ActiveRecord::Base, Boolean]
56
+ # @return [Hash] for redirect_to
57
+ def pf_save_flash(saved)
58
+ return { notice: pf_no_changes_text } unless SaveNotice.saved_anything?(saved)
59
+
60
+ flash = yield
61
+ flash.is_a?(Hash) ? flash : { notice: flash }
62
+ end
63
+
64
+ # "No changes to save." in the page's language.
65
+ def pf_no_changes_text
66
+ t('permission_forge.flash.no_changes')
67
+ end
68
+ end
69
+ end
@@ -9,7 +9,7 @@ module PermissionForge
9
9
  before_action :authorize_audit_log!
10
10
 
11
11
  rescue_from PermissionForge::Audit::EventQuery::Invalid do |error|
12
- render json: { error: { status: 422, messages: error.errors } }, status: :unprocessable_entity
12
+ render_unprocessable(error.errors)
13
13
  end
14
14
 
15
15
  def index
@@ -30,6 +30,11 @@ module PermissionForge
30
30
  AUDIT_SOURCE = 'api'
31
31
  # Largest id a bigint column holds; past it PostgreSQL raises instead of matching nothing.
32
32
  MAX_RECORD_ID = (2**63) - 1
33
+ # Body attributes that refer to another record (#with_reference_id),
34
+ # with the message of a reference that names none.
35
+ REFERENCES = {
36
+ permission_set_group_id: 'permission_forge.validations.permission_set.group_missing'
37
+ }.freeze
33
38
 
34
39
  before_action :set_api_locale
35
40
  before_action :pf_require_tenant
@@ -41,15 +46,68 @@ module PermissionForge
41
46
  end
42
47
 
43
48
  rescue_from ActiveRecord::RecordInvalid do |e|
44
- render json: { error: { status: 422, messages: e.record.errors.messages } }, status: :unprocessable_entity
49
+ render_validation_errors(e.record)
45
50
  end
46
51
 
52
+ # The uniqueness race: two requests passed the uniqueness validation
53
+ # together and the unique index refused this one's INSERT. The
54
+ # validation's own 422, keyed by the attribute, from the index the
55
+ # error names (UniqueConflict); `base` when it names none. Through
56
+ # 0.14.1 a 500.
57
+ rescue_from ActiveRecord::RecordNotUnique do |e|
58
+ render_unprocessable(PermissionForge::UniqueConflict.messages(e) ||
59
+ { 'base' => [I18n.t('permission_forge.api.errors.record_not_unique')] })
60
+ end
61
+
62
+ # A NOT NULL column no validation foresaw (ValidatesRequiredColumns
63
+ # refuses nil in the gem's own columns, keyed by the attribute): a
64
+ # host column, a trigger. The 422 envelope, never a 500.
65
+ rescue_from ActiveRecord::NotNullViolation do
66
+ render_unprocessable({ 'base' => [I18n.t('permission_forge.api.errors.not_null')] })
67
+ end
68
+
69
+ # Field-keyed like a validation error (ResponseRenderer#render_input_error).
47
70
  rescue_from ActionController::ParameterMissing, ActionController::BadRequest do |e|
48
- render json: { error: { status: 422, messages: e.message } }, status: :unprocessable_entity
71
+ render_input_error(e)
72
+ end
73
+
74
+ # A body Rails cannot parse (invalid JSON sent as application/json)
75
+ # raises when params are first read — in the forgery check, before
76
+ # api_auth — so the 400 is answered in the API's envelope instead of
77
+ # Rails' own. A disabled API stays a 404. params[:locale] cannot be
78
+ # read: the header decides.
79
+ rescue_from ActionDispatch::Http::Parameters::ParseError do
80
+ render_body_unreadable
81
+ end
82
+
83
+ # A form or multipart body Rack cannot parse (`a[=1&%%%`, a multipart
84
+ # body without its end) raises ActionController::BadRequest while
85
+ # Rails reads the parameters for its instrumentation, before
86
+ # rescue_from applies: answered here like an unreadable JSON body, the
87
+ # 400 envelope (through 0.15.0-rc Rails' own 400 page).
88
+ # AbstractController documents process_action as the method to
89
+ # override around an action.
90
+ def process_action(...)
91
+ super
92
+ rescue ActionController::BadRequest => e
93
+ raise if performed? || e.is_a?(ParamShapes::InvalidParam)
94
+
95
+ render_body_unreadable
49
96
  end
50
97
 
51
98
  private
52
99
 
100
+ # The 400 envelope of a body that cannot be read; a disabled API stays
101
+ # a 404. params[:locale] cannot be read: the header decides.
102
+ def render_body_unreadable
103
+ verify_api_enabled!
104
+ return if performed?
105
+
106
+ assign_api_locale(extract_locale_from_accept_language)
107
+ render json: { error: { status: 400, messages: I18n.t('permission_forge.api.errors.body_unreadable') } },
108
+ status: :bad_request
109
+ end
110
+
53
111
  # Who acts (Audit::ActorResolver: audit_actor_resolver, the signed-in
54
112
  # user, else an anonymous API client), request id, IP, the `reason`
55
113
  # body key and one correlation id per mutating request.
@@ -63,9 +121,12 @@ module PermissionForge
63
121
  end
64
122
 
65
123
  def set_api_locale
66
- locale = params[:locale] || extract_locale_from_accept_language
67
- # Always assign a locale (default when none/invalid) so a locale set by
68
- # one request can never leak into the next on a reused thread.
124
+ assign_api_locale(params[:locale] || extract_locale_from_accept_language)
125
+ end
126
+
127
+ # Always assign a locale (default when none/invalid) so a locale set by
128
+ # one request can never leak into the next on a reused thread.
129
+ def assign_api_locale(locale)
69
130
  I18n.locale = if locale.present? && api_available_locale?(locale)
70
131
  locale
71
132
  else
@@ -104,6 +165,64 @@ module PermissionForge
104
165
  scope.find_by!(identifier => value)
105
166
  end
106
167
 
168
+ # The attributes of the body section `section` an endpoint writes to
169
+ # `model`: those of `keys` it has, each in its shape (Api::InputShapes,
170
+ # the one shape table). The other keys of the section go through
171
+ # strong parameters' own handling of keys an action does not take
172
+ # (logged, or raised under action_on_unpermitted_parameters = :raise).
173
+ #
174
+ # @param record [ActiveRecord::Base, nil] the record an update changes
175
+ # @return [ActiveSupport::HashWithIndifferentAccess]
176
+ # @raise [ActionController::ParameterMissing] the section is missing,
177
+ # empty or not an object (a 422 keyed by it)
178
+ # @raise [InputShapes::Refused] a value of the wrong shape (a 422 keyed
179
+ # by each such attribute)
180
+ def pf_record_attributes(section, model, *keys, record: nil)
181
+ attrs = pf_require_hash(section)
182
+ attrs.except(*keys.map(&:to_s)).permit
183
+ InputShapes.call(attrs, model, keys, record: record)
184
+ end
185
+
186
+ # The `name` of a copy: text without NUL bytes and surrounding white
187
+ # space, '' when absent or null (the caller's default name).
188
+ #
189
+ # @raise [InputShapes::Refused] for anything else (true, a number, a
190
+ # list, an object), keyed by name: never silently the default name
191
+ def pf_copy_name
192
+ InputShapes.check!(:name, params[:name], :text)
193
+ pf_text_param(:name)
194
+ end
195
+
196
+ # `attrs` with the reference `key` (e.g. permission_set_group_id)
197
+ # replaced by the id of the record it names in `scope`, looked up like
198
+ # every identifier (tenant-scoped): its numeric id, or — under
199
+ # api_record_identifier = :uuid — its uuid, the identifier the API
200
+ # answers with. A blank value (null, "") is no reference (nil). Anything
201
+ # else that names no record of the scope is a 422 keyed by `key`; a
202
+ # uuid that starts with digits is never read as an id (the integer
203
+ # column would cast "12ab…" to 12).
204
+ #
205
+ # @raise [ParamShapes::InvalidParam]
206
+ def with_reference_id(attrs, key, scope)
207
+ return attrs unless attrs.key?(key)
208
+
209
+ value = attrs[key]
210
+ return attrs.merge(key => nil) if value.nil? || value == ''
211
+
212
+ attrs.merge(key => referenced_record(scope, value)&.id ||
213
+ raise(ParamShapes::InvalidParam.new(key, I18n.t(REFERENCES.fetch(key)))))
214
+ end
215
+
216
+ def referenced_record(scope, value)
217
+ text = value.to_s if value.is_a?(String) || value.is_a?(Integer)
218
+ return unless text
219
+
220
+ if identifier_value?('id', text) then scope.find_by(id: text.to_i)
221
+ elsif PermissionForge.configuration.api_record_identifier.to_s == 'uuid' && identifier_value?('uuid', text)
222
+ scope.find_by(uuid: text)
223
+ end
224
+ end
225
+
107
226
  def identifier_value?(identifier, value)
108
227
  return false if value.empty? || value.include?("\0")
109
228
  return true unless identifier.to_s == 'id'
@@ -121,11 +240,43 @@ module PermissionForge
121
240
  user
122
241
  end
123
242
 
243
+ # The person params[key] names, for a new assignment or membership,
244
+ # within config.user_list_resolver (Admin::PeopleDirectory#find). Every
245
+ # refusal is a 422 keyed by the parameter, so it never reads as the
246
+ # Permission Set or the Group in the path being wrong (a 404):
247
+ #
248
+ # - missing, null or blank: "is missing";
249
+ # - not one id (a list, an object, a number with a fraction, text that
250
+ # is not a key of the user model: "abc" for an integer key): "is not
251
+ # a user id";
252
+ # - an id that names nobody the resolver lists: "names nobody who can
253
+ # be assigned". An unknown id and someone the resolver leaves out read
254
+ # the same, as in a bulk request's `skipped`.
255
+ #
256
+ # @raise [ParamShapes::InvalidParam]
257
+ def requested_user(key = :user_id)
258
+ value = params[key]
259
+ problem = user_id_problem(value)
260
+ user = PermissionForge::Admin::PeopleDirectory.new.find(value) unless problem
261
+ problem ||= I18n.t('permission_forge.api.errors.user_not_found') unless user
262
+ raise ParamShapes::InvalidParam.new(key, problem) if problem
263
+
264
+ user
265
+ end
266
+
267
+ # What is wrong with a user_id before anybody is looked up, nil when
268
+ # it is one id of the user model's key type.
269
+ def user_id_problem(value)
270
+ return I18n.t('permission_forge.api.errors.user_id_missing') if value.blank?
271
+ return if (value.is_a?(String) || value.is_a?(Integer)) && PermissionForge::Admin::PeopleDirectory::Ids.cast(value).any?
272
+
273
+ I18n.t('permission_forge.api.errors.user_id_invalid')
274
+ end
275
+
124
276
  def authorize_action!(action, record)
125
277
  authorizer = PermissionForge.configuration.api_action_authorization
126
- message = I18n.t('permission_forge.api.errors.action_not_authorized')
127
278
  unless authorizer
128
- return deny_when_authorization_required(message) do
279
+ return deny_when_authorization_required(I18n.t('permission_forge.api.errors.action_authorization_missing')) do
129
280
  audit_action_refused(action, record, hook: 'api_require_authorization')
130
281
  end
131
282
  end
@@ -133,7 +284,7 @@ module PermissionForge
133
284
  return if authorizer.call(self, action, record)
134
285
 
135
286
  audit_action_refused(action, record, hook: 'api_action_authorization')
136
- render_not_authorized(message)
287
+ render_not_authorized(I18n.t('permission_forge.api.errors.action_not_authorized'))
137
288
  end
138
289
 
139
290
  # Runs the mutation authorization hook and the is_editable lock for a
@@ -174,9 +325,8 @@ module PermissionForge
174
325
  # A refusal records the ids as requested (bounded, Audit::Refusals).
175
326
  def authorize_assignment!(permission_set, user_ids)
176
327
  authorizer = PermissionForge.configuration.api_assignment_authorization
177
- message = I18n.t('permission_forge.api.errors.assignment_not_authorized')
178
328
  unless authorizer
179
- return deny_when_authorization_required(message) do
329
+ return deny_when_authorization_required(I18n.t('permission_forge.api.errors.assignment_authorization_missing')) do
180
330
  audit_assignment_refused(permission_set, user_ids, hook: 'api_require_authorization')
181
331
  end
182
332
  end
@@ -184,14 +334,15 @@ module PermissionForge
184
334
  return if authorizer.call(self, permission_set, PermissionForge::Admin::PeopleDirectory::Ids.cast(user_ids))
185
335
 
186
336
  audit_assignment_refused(permission_set, user_ids, hook: 'api_assignment_authorization')
187
- render_not_authorized(message)
337
+ render_not_authorized(I18n.t('permission_forge.api.errors.assignment_not_authorized'))
188
338
  end
189
339
 
190
340
  # When config.api_require_authorization is on, a missing authorization lambda
191
341
  # means DENY (403) rather than the backward-compatible silent allow. This
192
342
  # prevents "authenticated == full admin" when the host wired api_auth but not
193
343
  # the per-action / assignment authorization hooks. The block records the
194
- # refusal.
344
+ # refusal. The message names the missing option (the integrator's fix),
345
+ # unlike a lambda's denial; it carries nothing from the request.
195
346
  def deny_when_authorization_required(message)
196
347
  return unless PermissionForge.configuration.api_require_authorization
197
348