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
data/README.md CHANGED
@@ -1,13 +1,14 @@
1
1
  <p align="center">
2
- <picture>
3
- <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/fetijashari/permission_forge/main/docs/assets/logo-horizontal-dark.svg">
4
- <img alt="PermissionForge" src="https://raw.githubusercontent.com/fetijashari/permission_forge/main/docs/assets/logo-horizontal-light.svg" width="420">
5
- </picture>
2
+ <img alt="PermissionForge" src="https://raw.githubusercontent.com/fetijashari/permission_forge/main/docs/assets/logo-horizontal-light.svg#gh-light-mode-only" width="420">
3
+ <img alt="PermissionForge" src="https://raw.githubusercontent.com/fetijashari/permission_forge/main/docs/assets/logo-horizontal-dark.svg#gh-dark-mode-only" width="420">
6
4
  </p>
7
5
 
8
6
  <p align="center">
9
7
  <a href="https://github.com/fetijashari/permission_forge/actions/workflows/ci.yml"><img src="https://github.com/fetijashari/permission_forge/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
10
8
  <a href="https://rubygems.org/gems/permission_forge"><img src="https://img.shields.io/gem/v/permission_forge" alt="Gem Version"></a>
9
+ <a href="https://scorecard.dev/viewer/?uri=github.com/fetijashari/permission_forge"><img src="https://api.scorecard.dev/projects/github.com/fetijashari/permission_forge/badge" alt="OpenSSF Scorecard"></a>
10
+ <a href="https://www.bestpractices.dev/projects/15499"><img src="https://www.bestpractices.dev/projects/15499/badge" alt="OpenSSF Best Practices"></a>
11
+ <a href="https://rubydoc.info/gems/permission_forge"><img src="https://img.shields.io/badge/docs-rubydoc.info-blue.svg" alt="API documentation"></a>
11
12
  <a href="https://www.ruby-lang.org"><img src="https://img.shields.io/badge/ruby-%3E%3D%203.2-ruby.svg" alt="Ruby"></a>
12
13
  <a href="https://rubyonrails.org"><img src="https://img.shields.io/badge/rails-7.1%20to%208.1-red.svg" alt="Rails"></a>
13
14
  <a href="https://github.com/fetijashari/permission_forge/blob/main/LICENSE.txt"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
@@ -69,13 +70,13 @@ sit next to one (for example Pundit for controller decisions, PermissionForge fo
69
70
 
70
71
  Add to your `Gemfile`:
71
72
 
72
- ```ruby
73
+ ```ruby fragment
73
74
  gem 'permission_forge'
74
75
  ```
75
76
 
76
77
  Then run:
77
78
 
78
- ```bash
79
+ ```bash run
79
80
  bundle install
80
81
  bin/rails generate permission_forge:install
81
82
  bin/rails db:migrate
@@ -84,6 +85,23 @@ bin/rails db:migrate
84
85
  This creates `config/initializers/permission_forge.rb` and a migration with 9 tables: the 8 permission tables and the
85
86
  audit log, prefixed with `permission_forge_` by default.
86
87
 
88
+ Then mount the admin UI and give it credentials. It is behind HTTP Basic auth and answers 401 until both are set:
89
+
90
+ ```ruby fragment
91
+ # config/routes.rb
92
+ mount PermissionForge::Engine, at: "/permissions"
93
+ ```
94
+
95
+ ```ruby fragment
96
+ # config/initializers/permission_forge.rb: uncomment Option A ("Admin screens")
97
+ config.http_basic_auth_user = ENV['PF_ADMIN_USER']
98
+ config.http_basic_auth_password = ENV['PF_ADMIN_PASSWORD']
99
+ ```
100
+
101
+ Set `PF_ADMIN_USER` and `PF_ADMIN_PASSWORD` in the environment and open `/permissions`. To sign admins in with your
102
+ own authentication instead, see [Admin UI](https://github.com/fetijashari/permission_forge/blob/main/docs/admin-ui.md#disabling-http-basic-auth)
103
+ (`base_controller_class`). `bin/rails permission_forge:audit` lists what is still open before production.
104
+
87
105
  Generator options:
88
106
  - `--skip-migration`: no migration (for apps with existing tables)
89
107
  - `--table-prefix=PREFIX`: table name prefix (default `permission_forge_`; `''` for none)
@@ -106,7 +124,7 @@ Upgrading? Read the [CHANGELOG](https://github.com/fetijashari/permission_forge/
106
124
  The generated initializer reads `Current.user` when your app has a `Current` class that answers `user` (as Rails 8's
107
125
  authentication generator creates). Otherwise point it at your own lookup:
108
126
 
109
- ```ruby
127
+ ```ruby fragment
110
128
  # config/initializers/permission_forge.rb
111
129
  PermissionForge.configure do |config|
112
130
  config.user_class = 'User'
@@ -130,7 +148,7 @@ module, add a callback), use `Rails.application.config.to_prepare` or
130
148
 
131
149
  ### 2. Include the concern in your User model
132
150
 
133
- ```ruby
151
+ ```ruby fragment
134
152
  class User < ApplicationRecord
135
153
  include PermissionForge::Concerns::PermissionedUser
136
154
  end
@@ -144,7 +162,7 @@ those, not the private helpers.
144
162
 
145
163
  ### 3. Protect models with row-level rules (optional)
146
164
 
147
- ```ruby
165
+ ```ruby fragment
148
166
  class Customer < ApplicationRecord
149
167
  include PermissionForge::Concerns::PermissionScopedFindable
150
168
  end
@@ -158,7 +176,11 @@ rules for a model gets every row.
158
176
 
159
177
  ### 4. Create a Permission Set and assign it
160
178
 
161
- ```ruby
179
+ Run these lines in order, in one console. If a `create!` raises (a Permission Set name is unique: one made in the
180
+ admin already holds "Sales Team"), fix it and run it again before going on: the lines after it use its record and,
181
+ without it, fail with a database `NOT NULL` error that hides the first one.
182
+
183
+ ```ruby run
162
184
  pset = PermissionForge::PermissionSet.create!(name: 'Sales Team', is_active: true)
163
185
 
164
186
  resource = PermissionForge::Resource.create!(
@@ -173,16 +195,31 @@ PermissionForge::ResourceField.create!(
173
195
  read_access: true, edit_access: true
174
196
  )
175
197
 
198
+ user = User.find_by!(email_address: 'bob@example.com') # the person who gets the set (`email:` on a Devise app)
176
199
  PermissionForge::PermissionSetUser.create!(permission_set: pset, user: user)
177
200
  ```
178
201
 
202
+ Only `name` has a field row here. A column with no row is refused by the default
203
+ `unconfigured_field_strategy = :block`: its getter returns `nil` and `as_json` keeps the key as `null` (a Hidden row,
204
+ read off, leaves the key out). `PermissionForge::Services::ResourceFieldGenerator.call(resource: resource)` gives every
205
+ column (and declared virtual field) without a row one: Hidden, `created_at` / `updated_at` included — also the columns
206
+ that look like personal data, which get their recommended mask strategy on the row. With `grant_all: true` every row is
207
+ readable and editable (Read-only on a Resource without Edit), unmasked, and the timestamps read-only. The admin's "Sync fields with model" adds the rows at a level you pick (under
208
+ the default `gdpr_defaults`, "… as Read-only" starts the personal-data columns Masked). Then open the ones the set should read.
209
+
179
210
  Each change invalidates the cached permissions of the users it affects; the next read rebuilds them. The admin UI
180
- (mounted as described in [Admin UI](https://github.com/fetijashari/permission_forge/blob/main/docs/admin-ui.md)) does
181
- the same through forms.
211
+ (mounted at `/permissions` in [Installation](#installation)) does the same through forms.
212
+
213
+ Every change is also an audit event. Made in a console, a seed or a script, it is recorded with actor `unknown`;
214
+ wrap it to say who and why: `PermissionForge.with_audit_actor(user: admin, reason: 'onboarding') { ... }` (see
215
+ [Audit Log](https://github.com/fetijashari/permission_forge/blob/main/docs/audit-log.md)).
182
216
 
183
217
  ### 5. Check permissions at runtime
184
218
 
185
- ```ruby
219
+ Look up the same person step 4 assigned (`User.first` may be someone else, who holds no set: `false` and `{}`):
220
+
221
+ ```ruby run
222
+ user = User.find_by!(email_address: 'bob@example.com') # the person you assigned in step 4
186
223
  user.can_read?('Customer') # => true
187
224
  user.can_delete?('Customer') # => false
188
225
  user.permissions_for(:Customer)
@@ -200,10 +237,11 @@ where you call `can_*?`, or with `ControllerActionGuard`. Field access is decide
200
237
  Several defaults fail open so that installing the gem changes nothing until you configure it. Before production, go
201
238
  through this list. `bin/rails permission_forge:audit` reports most of these settings.
202
239
 
203
- `config.preset = :strict` closes every row below that a setting can close, except masking with nobody signed in (an
204
- explicit choice: see its row); a setting you make yourself always wins over the preset. New installs get the safe
205
- part (`action_guard_nil_user_strategy = :deny`, `conditional_sets_strict_mode = true`, `cache_backend = :rails_cache`)
206
- and the preset as a commented line with what it costs. The rows that need your code (`base_controller_class`,
240
+ `config.preset = :strict` closes every row below that a setting can close (masking with nobody signed in as
241
+ `:hide_in_requests`: hidden in web requests, stored values in jobs and the console); a setting you make yourself always
242
+ wins over the preset. New installs get the safe part (`action_guard_nil_user_strategy = :deny`,
243
+ `conditional_sets_strict_mode = true`, `cache_backend = :rails_cache`, `masked_attributes_nil_user_strategy =
244
+ :hide_in_requests`) and the preset as a commented line with what it costs. The rows that need your code (`base_controller_class`,
207
245
  `admin_assignment_authorization`, `audit_log_authorization`, `user_active_check`) are not in a preset.
208
246
  `bin/rails permission_forge:audit` prints the preset and, under `:strict`, every option set looser. See
209
247
  [Presets](https://github.com/fetijashari/permission_forge/blob/main/docs/configuration.md#presets).
@@ -211,10 +249,10 @@ and the preset as a commented line with what it costs. The rows that need your c
211
249
  | Default | What it means | To close it |
212
250
  |---|---|---|
213
251
  | `nil_user_strategy = :bypass` | With no current user (jobs, console, a resolver that returns nil), `find_by`, `permission_scoped`, auto-scoping and the RLS policy apply no rules; so does the controller guard unless `action_guard_nil_user_strategy` is set | `:deny` (not found) or `:raise`. Do not include `PermissionScopedFindable` in the user model: under `:deny` sign-in lookups return nil (`permission_forge:audit` reports `user_model_scoped`) |
214
- | `masked_attributes_nil_user_strategy = :bypass` | Attribute readers, `attributes` and `as_json` of a masked model return the stored values when nobody is signed in, whatever `nil_user_strategy` says | `:hide` (every guarded attribute reads as hidden with no user; the primary key and the raw paths are untouched), and set the current user in the jobs and mailers that read masked models. Not part of `config.preset = :strict`. The user model (`user_class` and its subclasses) stays readable for sign-in; validations read stored values |
252
+ | `masked_attributes_nil_user_strategy = :bypass` | Attribute readers, `attributes` and `as_json` of a masked model return the stored values when nobody is signed in, whatever `nil_user_strategy` says: in jobs and the console, and in a web request nobody is signed in to (a public page, a webhook, a controller that forgot authentication). Such a read in a web request is logged (once per model and process) and published as `field.unmasked_without_user` | `:hide_in_requests` (every guarded attribute reads as hidden in a web request with nobody signed in; jobs, rake and the console keep the stored values; new installs get it from the generator), or `:hide` (hidden everywhere: set the current user in the jobs and mailers that read masked models). The primary key and the raw paths are untouched; the user model (`user_class` and its subclasses) stays readable for sign-in; validations read stored values |
215
253
  | `response_filter_nil_user_strategy = :bypass` | `FiltersResponseAttributes` returns every attribute when there is no user | `:block` |
216
254
  | `auto_query_scoping = :off` | Only class-level `find_by` / `find_by!` and `permission_scoped` apply row rules | `:filter_only`, and use `permission_scoped` for join rules; consider RLS on PostgreSQL |
217
- | `unconfigured_action_strategy = :allow` | A controller action no Permission Set manages is allowed; unmapped actions are always allowed | `:block` |
255
+ | `no_read_access_strategy = :rules_only` | A signed-in user whose Permission Sets do not grant Read on a model (no `Resource` for it at all included) has no rules there: `find_by`, `permission_scoped` and auto-scoping return every row. Only `can_read?` (or the controller guard) refuses them | `:deny` (no rows; `find_by` raises `NotAuthorizedError` for an existing record) |
218
256
  | `action_guard_nil_user_strategy = nil` | The guard follows `nil_user_strategy` (`:bypass`) for signed-out requests to mapped actions | `:deny` (new installs get it from the generator) |
219
257
  | `conditional_sets_strict_mode = false` | With an empty request context (no resolver configured), every conditional Permission Set applies, MFA- or IP-gated grants included | A `request_context_resolver` that returns the request's IP and session flags, and `true`: without a request context, conditional granting sets are then dropped and conditional muting sets still apply |
220
258
  | `mask_virtual_attributes = false` | Virtual readers (methods, store accessors, attribute-API attributes) are not masked on the model; only `PermitsAttributes` and `FiltersResponseAttributes` apply their field rows | `:auto` (or a list of models), after checking that the models' own callbacks do not need the raw values |
@@ -226,12 +264,22 @@ and the preset as a commented line with what it costs. The rows that need your c
226
264
  | `base_controller_class` / `api_base_controller_class` | With tenancy on, a request with no current tenant is refused (403) | Controllers that authenticate and set the tenant |
227
265
  | `user_active_check` | Only reported by the security audit; an inactive user keeps every grant | Revoke the assignments of users you deactivate |
228
266
 
229
- Upgrading from 0.13: once the last 0.13 process is gone, run `bin/rails permission_forge:cache:clear` (see the
230
- [CHANGELOG](https://github.com/fetijashari/permission_forge/blob/main/CHANGELOG.md)).
267
+ Upgrading to 0.15: a controller action that maps to a Resource (in a controller that includes
268
+ `ControllerActionGuard`) and that no Permission Set manages is now refused with 403; before 0.15 it ran.
269
+ `bin/rails permission_forge:audit` lists those actions (`unmanaged_actions_refused`; with tenancy, each with the
270
+ tenants that do not manage it) and the controller actions it could not check (`actions_not_checked`). Manage each one
271
+ in a Permission Set, or opt out with `config.unconfigured_action_strategy = :allow`, which lets every signed-in user
272
+ run them (the audit reports the opt-out as `unconfigured_actions_allowed`, medium). Actions that map to no Resource are
273
+ never checked.
274
+ Run `bin/rails permission_forge:audit` before you deploy (see the
275
+ [Migration Guide](https://github.com/fetijashari/permission_forge/blob/main/docs/migration-guide.md#upgrading-to-015)).
276
+ From 0.13: follow [From 0.13](https://github.com/fetijashari/permission_forge/blob/main/docs/migration-guide.md#from-013) — a revocation made on 0.15 does not reach what 0.13 processes have cached.
231
277
 
232
278
  Already closed by default, keep them so: `api_require_authorization = true` (API changes need
233
279
  `api_action_authorization` and `api_assignment_authorization`), `admin_tenantless_access = :deny`,
234
- `nested_attributes_parent_fallback = false`, `unconfigured_field_strategy = :block`, and the API is off
280
+ `nested_attributes_parent_fallback = false`, `unconfigured_field_strategy = :block`,
281
+ `unconfigured_action_strategy = :block` (since 0.15: a mapped controller action no Permission Set manages is refused
282
+ with 403; `:allow`, the explicit opt-out, lets every signed-in user run it), and the API is off
235
283
  (`api_enabled = false`). If you use PostgreSQL RLS, run `bin/rails permission_forge:rls:sync` after each upgrade.
236
284
 
237
285
  See [Configuration](https://github.com/fetijashari/permission_forge/blob/main/docs/configuration.md) for every option.
@@ -265,11 +313,14 @@ Every create, update and destroy, made through the models, of a Permission Set,
265
313
  assignment or membership is written to the `audit_events` table inside the change's own transaction. Under the default
266
314
  `audit_change_failure_strategy = :raise`, a change whose event cannot be written rolls back. Each event stores the
267
315
  actor, source, request id, IP, an optional reason, a correlation id, and the changes or a snapshot of the deleted row.
268
- Name the person who acts, otherwise HTTP Basic admins share one name:
316
+ Behind your own admin controller (`base_controller_class`) the actor is the user `current_user_resolver` returns.
317
+ Under HTTP Basic auth your app's sign-in does not run in the admin, so admins share one login name unless
318
+ `audit_actor_resolver` names them:
269
319
 
270
- ```ruby
320
+ ```ruby fragment
271
321
  PermissionForge.configure do |config|
272
- config.audit_actor_resolver = ->(controller) { controller.current_admin }
322
+ # Rails 8's authentication generator (with Devise: controller.request.env['warden']&.user(:user))
323
+ config.audit_actor_resolver = ->(controller) { Session.find_by(id: controller.request.cookie_jar.signed[:session_id])&.user }
273
324
  config.audit_retention_period = 2.years # purge: bin/rails permission_forge:audit_events:purge
274
325
  end
275
326
  ```
@@ -16,7 +16,7 @@ module PermissionForge
16
16
  # def collection_scope = @resource.resource_fields
17
17
  # def current_record = @field
18
18
  # def serializer_type = :resource_field
19
- # def record_params = pf_require_hash(:resource_field).permit(:name, :label)
19
+ # def record_params = pf_record_attributes(:resource_field, PermissionForge::ResourceField, :name, :label)
20
20
  # end
21
21
  module CrudActions
22
22
  extend ActiveSupport::Concern
@@ -71,7 +71,8 @@ module PermissionForge
71
71
  # @return [Symbol] key for serializer_for()
72
72
  def serializer_type = raise(NotImplementedError, "#{self.class}#serializer_type")
73
73
 
74
- # @return [ActionController::Parameters] permitted params
74
+ # @return [ActiveSupport::HashWithIndifferentAccess] the attributes, in
75
+ # their shapes (BaseController#pf_record_attributes)
75
76
  def record_params = raise(NotImplementedError, "#{self.class}#record_params")
76
77
 
77
78
  # @return [ActiveRecord::Base] new record built from collection_scope and params
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Api
5
+ # The one shape table of the JSON API's attribute sections
6
+ # (`{ "permission_set": { ... } }`): what each attribute is taken in,
7
+ # read through BaseController#pf_record_attributes by every endpoint
8
+ # that creates or updates a record. A value of another shape is a 422
9
+ # keyed by the attribute, every wrong attribute of the request named
10
+ # (`{ "is_active": ["must be true or false"] }`): never dropped, never
11
+ # cast into something else, never a 500. Through 0.15.0-rc strong
12
+ # parameters dropped a list or an object sent for a scalar with a log
13
+ # line, and ActiveModel cast what remained: "nope" was true, a JSON
14
+ # `true` for a name stored "t" (field test round 7, d-api #54 / #59).
15
+ #
16
+ # An attribute's shape is its column type's (BY_TYPE: what the model
17
+ # casts it to, `type_for_attribute`), unless DECLARED names one:
18
+ #
19
+ # - boolean: ParamShapes.boolean (true, false, 1, 0, and the words
20
+ # true/false/t/f/on/off/1/0 in any case), stored as true or false;
21
+ # - text: a String;
22
+ # - integer: an Integer or a String of digits;
23
+ # - the activation conditions: `allowed_ips` a String or a list of
24
+ # Strings (stored with commas; a list with no entry is refused),
25
+ # an hour an integer or digits, a session flag a String — `""` clears
26
+ # each;
27
+ # - a rule's `field_value`: a String, or a JSON number or boolean (stored
28
+ # as its text: `5` → "5", `true` → "true"); a list of them, none with a
29
+ # comma, only for an operator that takes several values (`in`,
30
+ # `not_in`, `between`: Operators::ValueTyping::SEVERAL), stored joined
31
+ # with commas;
32
+ # - a reference (`permission_set_group_id`): taken as sent, then read by
33
+ # BaseController#with_reference_id (the id or uuid of a record; null or
34
+ # "" clears it; anything else a 422 keyed by it).
35
+ #
36
+ # `null` is passed to the model for every attribute: a nullable column
37
+ # is cleared, a NOT NULL one answers its own validation (422 keyed by
38
+ # the attribute). An attribute whose type has no shape here (none of the
39
+ # gem's: the input_shapes test checks) is passed on as sent.
40
+ #
41
+ # @api private
42
+ module InputShapes
43
+ # How one shape reads a value: the value to store, or a Refusal.
44
+ Refusal = Data.define(:message)
45
+
46
+ # The 422 of a section with values of the wrong shape: every attribute
47
+ # and its message.
48
+ #
49
+ # @api private
50
+ class Refused < ParamShapes::InvalidParam
51
+ # @return [Hash{String => Array<String>}]
52
+ attr_reader :messages
53
+
54
+ # @param messages [Hash{String => Array<String>}] at least one
55
+ def initialize(messages)
56
+ @messages = messages
57
+ param, (detail, *) = messages.first
58
+ super(param, detail)
59
+ end
60
+ end
61
+
62
+ # What a rule needs to read its value: the operator it is compared
63
+ # with (the request's, else the stored rule's, else the column default).
64
+ Context = Data.define(:operator)
65
+
66
+ # The shape of each column type the API writes.
67
+ BY_TYPE = { boolean: :boolean, string: :text, text: :text, integer: :integer }.freeze
68
+
69
+ # The attributes whose shape is not their column type's.
70
+ DECLARED = {
71
+ 'allowed_ips' => :ip_list, 'requires_session_flag' => :flag,
72
+ 'allowed_hours_start' => :hour, 'allowed_hours_end' => :hour,
73
+ 'field_value' => :rule_value, 'permission_set_group_id' => :reference
74
+ }.freeze
75
+
76
+ # The shapes whose blank String ("") is no value (null): a condition
77
+ # or a reference is cleared with it.
78
+ BLANK_CLEARS = %i[ip_list flag hour reference].freeze
79
+
80
+ # Each shape's reader, (value, Context) → the value to store, or a
81
+ # Refusal (its message key under permission_forge.api.errors).
82
+ SHAPES = {
83
+ boolean: ->(value, _) { ParamShapes.boolean(value).then { |read| read.nil? ? Refusal.new('boolean_invalid') : read } },
84
+ text: ->(value, _) { value.is_a?(String) ? value : Refusal.new('text_shape') },
85
+ integer: ->(value, _) { whole_number?(value) ? value : Refusal.new('integer_shape') },
86
+ hour: ->(value, _) { whole_number?(value) ? value : Refusal.new('hour_shape') },
87
+ flag: ->(value, _) { value.is_a?(String) ? value : Refusal.new('flag_shape') },
88
+ ip_list: ->(value, _) { ip_list(value) },
89
+ rule_value: ->(value, context) { rule_value(value, context) },
90
+ reference: ->(value, _) { value },
91
+ as_is: ->(value, _) { value }
92
+ }.freeze
93
+
94
+ # A whole number as an hour or a count is sent: an Integer, or digits
95
+ # with an optional minus sign and surrounding white space.
96
+ WHOLE_NUMBER = /\A\s*-?\d+\s*\z/
97
+
98
+ module_function
99
+
100
+ # The `keys` of `attrs` it has, each read in its shape.
101
+ #
102
+ # @param attrs [ActionController::Parameters] the section (NUL bytes dropped)
103
+ # @param model [Class] the model the section writes
104
+ # @param keys [Array<Symbol, String>] the attributes the endpoint takes
105
+ # @param record [ActiveRecord::Base, nil] the record an update changes
106
+ # (a rule's stored operator reads its value)
107
+ # @return [ActiveSupport::HashWithIndifferentAccess]
108
+ # @raise [Refused] naming every attribute of the wrong shape
109
+ def call(attrs, model, keys, record: nil)
110
+ context = Context.new(operator: operator_of(attrs, model, record))
111
+ read = keys.map(&:to_s).select { |key| attrs.key?(key) }.to_h { |key| [key, read_value(attrs[key], shape_of(model, key), context)] }
112
+ refused = read.select { |_, value| value.is_a?(Refusal) }
113
+ raise Refused, messages(refused) if refused.any?
114
+
115
+ read.with_indifferent_access
116
+ end
117
+
118
+ # One parameter outside a section (a copy's `name`) in `shape`; nil
119
+ # is no value.
120
+ #
121
+ # @raise [Refused] keyed by `key` for another shape
122
+ # @return [void]
123
+ def check!(key, value, shape)
124
+ read = value.nil? ? nil : SHAPES.fetch(shape).call(value, nil)
125
+ raise Refused, messages(key.to_s => read) if read.is_a?(Refusal)
126
+ end
127
+
128
+ # The shape `key` of `model` is read in: DECLARED, else its type's
129
+ # (BY_TYPE), else :as_is.
130
+ #
131
+ # @return [Symbol] a SHAPES key
132
+ def shape_of(model, key)
133
+ DECLARED.fetch(key.to_s) { BY_TYPE.fetch(model.type_for_attribute(key.to_s).type, :as_is) }
134
+ end
135
+
136
+ # The field-keyed messages of the Refusals.
137
+ def messages(refusals)
138
+ refusals.transform_values { |refusal| [I18n.t("permission_forge.api.errors.#{refusal.message}")] }
139
+ end
140
+
141
+ def read_value(value, shape, context)
142
+ value = nil if value == '' && BLANK_CLEARS.include?(shape)
143
+ return value if value.nil?
144
+
145
+ SHAPES.fetch(shape).call(value, context)
146
+ end
147
+
148
+ def operator_of(attrs, model, record)
149
+ operator = attrs['operator'] if attrs.key?('operator')
150
+ return operator if operator.is_a?(String)
151
+ return record.operator if record.respond_to?(:operator)
152
+
153
+ model.column_defaults['operator']
154
+ end
155
+
156
+ def whole_number?(value)
157
+ value.is_a?(Integer) || (value.is_a?(String) && value.match?(WHOLE_NUMBER))
158
+ end
159
+
160
+ # A String (commas, newlines or spaces between the entries) or a list
161
+ # of Strings, the entries joined with commas by the one parser
162
+ # (ActivationConditions). A list with no entry (`[]`, `[""]`, `" , "`)
163
+ # is refused rather than read as "no condition": null (or "") clears.
164
+ def ip_list(value)
165
+ return Refusal.new('ip_list_shape') unless value.is_a?(String) || (value.is_a?(Array) && value.all?(String))
166
+
167
+ PermissionForge::ActivationConditions.entries(value).join(',').presence || Refusal.new('ip_list_empty')
168
+ end
169
+
170
+ # A rule's value as the text the rule stores (see the module).
171
+ def rule_value(value, context)
172
+ return list_text(value, context.operator) if value.is_a?(Array)
173
+
174
+ scalar_text(value) || Refusal.new('field_value_shape')
175
+ end
176
+
177
+ # A list joined with commas, for an operator that takes several values;
178
+ # each element a scalar without a comma (the stored text is split at
179
+ # them: Operators::ValueTyping.split).
180
+ def list_text(value, operator)
181
+ typing = PermissionForge::Operators::ValueTyping
182
+ texts = value.map { |element| scalar_text(element) }
183
+ joinable = typing::SEVERAL.include?(typing::SHAPES[operator]) && texts.any? && texts.none? { |text| text.nil? || text.include?(',') }
184
+ joinable ? texts.join(',') : Refusal.new('field_value_shape')
185
+ end
186
+
187
+ # The text of a JSON scalar: a String as sent, a number as written
188
+ # (a fraction without an exponent: 1.5 → "1.5"), true / false as
189
+ # "true" / "false"; nil for anything else.
190
+ def scalar_text(value)
191
+ case value
192
+ when String then value
193
+ when Integer, true, false then value.to_s
194
+ when Float then BigDecimal(value.to_s).to_s('F') if value.finite?
195
+ end
196
+ end
197
+ private_class_method :messages, :read_value, :operator_of, :whole_number?, :ip_list, :rule_value, :list_text, :scalar_text
198
+ end
199
+ end
200
+ end
@@ -6,6 +6,10 @@ module PermissionForge
6
6
  # Beyond this the offset overflows the database's integer; the page is
7
7
  # empty long before.
8
8
  MAX_PAGE = (2**31) - 1
9
+ # What a page or a page size is: a positive whole number
10
+ # (ParamShapes#pf_integer_param reads it; "abc", "0", "-1", "2abc",
11
+ # 1.5 and a list are not one).
12
+ POSITIVE = (1..)
9
13
 
10
14
  private
11
15
 
@@ -34,16 +38,19 @@ module PermissionForge
34
38
  }
35
39
  end
36
40
 
37
- # Anything that is not a number (a Hash, an Array) reads as page 1.
41
+ # params[:page] when it is a positive whole number (at most MAX_PAGE),
42
+ # else the first page.
38
43
  def current_page
39
- params[:page].to_s.to_i.clamp(1, MAX_PAGE)
44
+ pf_integer_param(:page, default: 1, range: POSITIVE).clamp(1, MAX_PAGE)
40
45
  end
41
46
 
47
+ # params[:per_page] when it is a positive whole number, else
48
+ # config.api_default_per_page; never above config.api_max_per_page.
49
+ # A typo (`per_page=abc`), 0 or a negative number is the default page
50
+ # size, not a page of one row (through 0.14.1 they read as 1).
42
51
  def clamped_per_page
43
52
  config = PermissionForge.configuration
44
- requested = params[:per_page]
45
- requested = requested.nil? ? config.api_default_per_page : requested.to_s.to_i
46
- requested.clamp(1, config.api_max_per_page)
53
+ pf_integer_param(:per_page, default: config.api_default_per_page, range: POSITIVE).clamp(1, config.api_max_per_page)
47
54
  end
48
55
  end
49
56
  end
@@ -21,6 +21,20 @@ module PermissionForge
21
21
  end
22
22
  end
23
23
 
24
+ # One record and the cache outcome of the change that made or removed
25
+ # it (Cache::Outcome, as a bulk assignment answers it):
26
+ # `{ "data": { ... }, "meta": { "cache": { ... } } }`, the record by
27
+ # the serializer of its type (`permission_set_user`).
28
+ def render_record_with_cache(record, outcome, status:)
29
+ serializer_class = serializer_for(record.model_name.element.to_sym)
30
+ meta = { cache: outcome.to_h }
31
+ if ams_serializer?(serializer_class)
32
+ render json: record, serializer: serializer_class, root: :data, meta: meta, status: status
33
+ else
34
+ render json: { data: serializer_class.new(record).as_json, meta: meta }, status: status
35
+ end
36
+ end
37
+
24
38
  def render_collection(scope, serializer_class)
25
39
  paginator = PermissionForge.configuration.api_pagination_handler
26
40
  records = paginator ? paginate_scope(scope, paginator) : default_paginate_scope(scope)
@@ -35,7 +49,29 @@ module PermissionForge
35
49
  end
36
50
 
37
51
  def render_validation_errors(record)
38
- render json: { error: { status: 422, messages: record.errors.messages } }, status: :unprocessable_entity
52
+ render_unprocessable(record.errors.messages)
53
+ end
54
+
55
+ # The one 422 shape: `messages` maps a field (a parameter, an attribute,
56
+ # or "base" when the error names none) to its messages, for validation
57
+ # errors and unreadable input alike.
58
+ def render_unprocessable(messages)
59
+ render json: { error: { status: 422, messages: messages } }, status: :unprocessable_entity
60
+ end
61
+
62
+ # An input error (ParamShapes::InvalidParam, a missing or malformed
63
+ # body section, Rails' own BadRequest) as field-keyed messages.
64
+ def render_input_error(error)
65
+ render_unprocessable(input_error_messages(error))
66
+ end
67
+
68
+ def input_error_messages(error)
69
+ case error
70
+ when PermissionForge::ParamShapes::InvalidParam then error.messages
71
+ when ActionController::ParameterMissing
72
+ { error.param.to_s => [I18n.t('permission_forge.api.errors.section_missing')] }
73
+ else { 'base' => [error.message] }
74
+ end
39
75
  end
40
76
 
41
77
  # Saves a record and renders the created resource or its validation errors.
@@ -20,6 +20,13 @@ module PermissionForge
20
20
 
21
21
  # A refused request records api.auth_failed: who the host knows (the
22
22
  # audit actor resolver, the signed-in user), else unknown.
23
+ #
24
+ # api_auth answers whether the caller is authenticated: a falsy answer
25
+ # is a 401, so a client that refreshes its credentials on 401 does so.
26
+ # What an authenticated caller may change is the authorization lambdas'
27
+ # decision (403). The gem cannot know the host's scheme, so it sends no
28
+ # WWW-Authenticate of its own: one the lambda set on
29
+ # `controller.response.headers` is kept.
23
30
  def authenticate_api_request!
24
31
  auth_check = PermissionForge.configuration.api_auth
25
32
  if auth_check.nil?
@@ -28,8 +35,12 @@ module PermissionForge
28
35
  end
29
36
  return if auth_check.call(self)
30
37
 
31
- audit_api_auth_failure('api_auth_denied', 403)
32
- render json: { error: { status: 403, messages: I18n.t('permission_forge.api.errors.forbidden') } }, status: :forbidden
38
+ audit_api_auth_failure('api_auth_denied', 401)
39
+ # Runs before the request's locale is assigned (set_api_locale is a
40
+ # later callback): the 401 says it in the request's language too
41
+ # (through 0.14.1 always in the default locale).
42
+ set_api_locale
43
+ render json: { error: { status: 401, messages: I18n.t('permission_forge.api.errors.unauthorized') } }, status: :unauthorized
33
44
  end
34
45
 
35
46
  def audit_api_auth_failure(reason, status)
@@ -2,7 +2,10 @@
2
2
 
3
3
  module PermissionForge
4
4
  # For admin actions that clear somebody's cached permissions: says what the
5
- # cache store confirmed, instead of announcing success whatever happened.
5
+ # cache store confirmed, and when the store lives in this process only,
6
+ # instead of announcing success whatever happened. One Cache::Outcome
7
+ # (Cache::Report) is the datum, as in the rake tasks, the API and the
8
+ # audit events.
6
9
  #
7
10
  # @api private
8
11
  module CacheConfirmation
@@ -10,23 +13,51 @@ module PermissionForge
10
13
 
11
14
  private
12
15
 
13
- # Whether the cache store confirmed every invalidation made in the block.
14
- # The gem's own failure bookkeeping is asked, not the value of
16
+ # Measures the invalidations made in the block (Cache::Report.of). The
17
+ # gem's own failure bookkeeping is asked, not the value of
15
18
  # invalidate_permissions: host apps override that method.
16
- def cache_confirmed?(&)
17
- PermissionForge::Cache::Failure.none?(&)
19
+ #
20
+ # @return [PermissionForge::Cache::Outcome]
21
+ def cache_outcome(&)
22
+ PermissionForge::Cache::Report.of(&)
18
23
  end
19
24
 
20
25
  # The notice — or, when the cache store did not confirm that the cached
21
26
  # permissions are gone, the alert, which says the old ones may still be
22
- # served. `keys` names both, under permission_forge.flash.people:
23
- # `{ notice: :erased, alert: :erased_cache_kept }`.
27
+ # served (a confirmation not known yet, inside a transaction of the
28
+ # host's, is not a failure). `keys` names both, under
29
+ # permission_forge.flash.<controller_name>: `{ notice: :erased, alert: :erased_cache_kept }`.
30
+ # When the store lives in this process only (Outcome#process_local?) the
31
+ # text ends with flash.cache_process_local: other processes kept theirs.
24
32
  #
33
+ # @param outcome [PermissionForge::Cache::Outcome, Hash] an Outcome, or a service's `cache:`
25
34
  # @return [Hash] for redirect_to
26
- def cache_flash(confirmed, keys, **)
27
- kind = confirmed ? :notice : :alert
28
- key = keys.fetch(kind)
29
- { kind => t("permission_forge.flash.people.#{key}", default: key.to_s.humanize, **) }
35
+ def cache_flash(outcome, keys, **)
36
+ outcome = PermissionForge::Cache::Outcome.wrap(outcome)
37
+ kind = outcome.flash_kind
38
+ cache_scoped(outcome, kind => t("permission_forge.flash.#{controller_name}.#{keys.fetch(kind)}", **))
39
+ end
40
+
41
+ # The flash for a change whose message has no alert of its own (one
42
+ # assignment, a batch of edits): `text` as the notice, or — when the
43
+ # store did not confirm — as the alert, followed by
44
+ # flash.cache_not_confirmed; flash.cache_process_local as in #cache_flash.
45
+ #
46
+ # @param outcome [PermissionForge::Cache::Outcome]
47
+ # @param text [String] the translated notice
48
+ # @return [Hash] for redirect_to / pf_redirect
49
+ def cache_flash_text(outcome, text)
50
+ kind = outcome.flash_kind
51
+ text = "#{text} #{t('permission_forge.flash.cache_not_confirmed')}" if kind == :alert
52
+ cache_scoped(outcome, kind => text)
53
+ end
54
+
55
+ # `flash` with flash.cache_process_local appended when the store lives
56
+ # in this process only.
57
+ def cache_scoped(outcome, flash)
58
+ return flash unless outcome.process_local?
59
+
60
+ flash.transform_values { |text| "#{text} #{t('permission_forge.flash.cache_process_local')}" }
30
61
  end
31
62
  end
32
63
  end