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
@@ -15,9 +15,10 @@ module PermissionForge
15
15
  module StrategyValues
16
16
  ALLOWED = {
17
17
  nil_user_strategy: %i[bypass raise deny],
18
+ no_read_access_strategy: %i[rules_only deny],
18
19
  response_filter_nil_user_strategy: %i[bypass block],
19
20
  masked_write_protection: %i[skip raise off],
20
- masked_attributes_nil_user_strategy: %i[bypass hide],
21
+ masked_attributes_nil_user_strategy: %i[bypass hide_in_requests hide],
21
22
  action_guard_nil_user_strategy: %i[bypass deny raise],
22
23
  admin_tenantless_access: %i[deny allow],
23
24
  unconfigured_action_strategy: %i[allow block],
@@ -27,8 +27,8 @@ module PermissionForge
27
27
  # virtual attribute or a Ruby method (e.g., `"#{first_name} #{last_name}"`)
28
28
  # rather than a real column, so a SQL ORDER BY would fail.
29
29
  #
30
- # The User class must respond to `:name` and `:email` (as method or column)
31
- # for display labels. Override to scope or sort however you like:
30
+ # The User class must respond to `:name` and/or `:email` (or Rails 8's
31
+ # `:email_address`), as method or column, for display labels. Override to scope or sort however you like:
32
32
  # config.user_list_resolver = -> { User.active.order(:last_name, :first_name) }
33
33
  attr_accessor :user_list_resolver
34
34
 
@@ -45,8 +45,8 @@ module PermissionForge
45
45
  # Receives the user, returns a String (the human-readable part).
46
46
  # The combobox automatically appends ` #<id>` so the JS can extract the id.
47
47
  #
48
- # Default: tries name, full_name, display_name (in that order), then email,
49
- # then a localized fallback. Override for full control:
48
+ # Default: tries name, full_name, display_name (in that order), then email
49
+ # (or email_address), then a localized fallback. Override for full control:
50
50
  # config.user_label_formatter = ->(u) { "#{u.last_name}, #{u.first_name} <#{u.email}>" }
51
51
  attr_accessor :user_label_formatter
52
52
 
@@ -160,7 +160,8 @@ module PermissionForge
160
160
 
161
161
  # Lambda that receives the controller instance and returns true/false for auth.
162
162
  # When nil and API enabled, all requests return 401 (safe default).
163
- # Example: ->(controller) { controller.current_user&.has_role?(:admin) }
163
+ # Example (PermissionForge.current_user is current_user_resolver's user, set when
164
+ # api_base_controller_class signs the caller in): ->(_controller) { PermissionForge.current_user&.admin? }
164
165
  attr_accessor :api_auth
165
166
 
166
167
  # Base controller class for API controllers (default: "::ActionController::Base").
@@ -179,10 +180,18 @@ module PermissionForge
179
180
  # Default records per page when per_page param is not provided.
180
181
  attr_accessor :api_default_per_page
181
182
 
182
- # When true, the /api/stats endpoint requires admin access.
183
- # The api_auth lambda must set @admin = true on the controller.
183
+ # When true (default), GET /api/stats answers only callers
184
+ # api_stats_authorization allows (403 for everyone else). false: every
185
+ # caller api_auth admits.
184
186
  attr_accessor :api_stats_require_admin
185
187
 
188
+ # Lambda that receives the controller and decides who may read GET
189
+ # /api/stats while api_stats_require_admin is on; a truthy answer allows.
190
+ # When nil, stats are refused (until 0.16 an api_auth that sets @admin =
191
+ # true on the controller still allows, with a deprecation warning).
192
+ # Example: ->(controller) { PermissionForge.current_user&.admin? }
193
+ attr_accessor :api_stats_authorization
194
+
186
195
  # Record identifier field for API lookups (default: :uuid).
187
196
  # Change to :id if your app uses integer IDs in URLs.
188
197
  attr_accessor :api_record_identifier
@@ -208,8 +217,8 @@ module PermissionForge
208
217
 
209
218
  # Lambda for action-level authorization on API mutation endpoints (clone, etc.).
210
219
  # Receives (controller, action_name, record) and should return true/false.
211
- # When nil, no extra authorization is performed (backward-compatible).
212
- # Example: ->(controller, action, record) { controller.current_user&.admin? }
220
+ # When nil, every mutation is refused while api_require_authorization is true (the default).
221
+ # Example: ->(controller, action, record) { PermissionForge.current_user&.admin? }
213
222
  attr_accessor :api_action_authorization
214
223
 
215
224
  # Lambda for authorization checks on user-to-permission-set assignments
@@ -299,8 +308,12 @@ module PermissionForge
299
308
  # Hash of regex => strategy string for GDPR field pattern matching
300
309
  attr_reader :gdpr_field_patterns
301
310
 
311
+ # Not created_at / updated_at (before 0.15 they were here): generated
312
+ # fields give them a row, at most read-only on a granting set
313
+ # (TimestampFields), so the default unconfigured_field_strategy (:block)
314
+ # no longer hides them from a reader who is granted them.
302
315
  DEFAULT_IGNORED_FIELDS = %w[
303
- id created_at updated_at token_digest token_last_digits
316
+ id token_digest token_last_digits
304
317
  created_by updated_by token password encrypted_password
305
318
  deleted_at uuid deactivated
306
319
  ].freeze
@@ -312,7 +325,7 @@ module PermissionForge
312
325
 
313
326
  # Column names that are personal data, with the mask for each. One
314
327
  # definition for masking detection (ResourceFieldGenerator,
315
- # GdprFieldAuditor, Admin::SensitiveColumns, the security audit) and for
328
+ # GdprFieldAuditor, SensitiveColumns, the security audit) and for
316
329
  # Rails' filter_parameters (PermissionForge.gdpr_filter_parameters), which
317
330
  # Rails also applies to ActiveRecord::Base.filter_attributes — so a match
318
331
  # prints as [FILTERED] in #inspect. A word matches anywhere in the name,
@@ -323,9 +336,11 @@ module PermissionForge
323
336
  # `home_tel`, `tel2`, `telNumber`, `cityName`, `zipcode` yes — `hotel_id`,
324
337
  # `capacity`, `telemetry_enabled`, `automobile_id`, `gzip_enabled` no.
325
338
  # Every alternative stands alone (no group with an inner `|`):
326
- # Admin::SensitiveColumns names the one that matched.
339
+ # SensitiveColumns names the one that matched. The first pattern
340
+ # that matches decides the strategy: Rails 8's `email_address` is an
341
+ # email (it also matches `address`, below).
327
342
  DEFAULT_GDPR_FIELD_PATTERNS = {
328
- /\Aemail\z|_email\z/ => 'email',
343
+ /\Aemail\z|_email\z|\Aemail_address\z|_email_address\z/ => 'email',
329
344
  /phone|telefon|\Amobile|_mobile|\Afax|_fax|\Atel\z|\Atel_|\Atel[A-Z0-9]|_tel\z|_tel_|_tel[A-Z0-9]/ => 'last4',
330
345
  /\Assn\z|social_security|national_id/ => 'last4',
331
346
  /iban|bank_account|credit_card|card_number/ => 'last4',
@@ -390,9 +405,10 @@ module PermissionForge
390
405
  end
391
406
 
392
407
  # Field-name variants produced by the configured mappings (e.g. `user_id`
393
- # -> `user_uuid`, `price_cents` -> `price`). Shared by the granting and
394
- # muting phases of PermissionHashBuilder so they always cover the same
395
- # names.
408
+ # -> `user_uuid`, `price_cents` -> `price`), whatever the model. What a
409
+ # field row applies to is PermissionForge::FieldVariants: these, kept to
410
+ # the names the Resource's model has (shared by the granting and muting
411
+ # phases of PermissionHashBuilder and the admin).
396
412
  #
397
413
  # @param field_name [String, Symbol]
398
414
  # @return [Array<String>]
@@ -572,6 +588,7 @@ module PermissionForge
572
588
  @api_auth = nil
573
589
  @api_base_controller_class = '::ActionController::Base'
574
590
  @api_stats_require_admin = true
591
+ @api_stats_authorization = nil
575
592
  @api_pagination_handler = nil
576
593
  @api_max_per_page = 100
577
594
  @api_default_per_page = 15
@@ -591,13 +608,19 @@ module PermissionForge
591
608
  ->(user) { user.respond_to?(:active_for_authentication?) ? user.active_for_authentication? : true }
592
609
  end
593
610
 
611
+ # The profile is cached with the permission hash (the cache store:
612
+ # Redis, Solid Cache, ...), so an attribute the user model encrypts is
613
+ # left out (nil): neither its ciphertext nor its plaintext goes there.
594
614
  def default_profile_builder
595
615
  lambda { |user|
616
+ label = Admin::PersonLabel.new(user, decrypt: false)
596
617
  {
597
618
  id: user.id,
598
619
  uuid: user.try(:uuid),
599
- name: user.try(:name),
600
- email: user.try(:email)
620
+ # Raw-column-first (Admin::PersonLabel#raw_value): a masked User model never garbles the profile
621
+ name: label.raw_value(:name),
622
+ # The first present of `email`, Rails 8's `email_address` (Admin::PersonLabel::EMAIL_ATTRIBUTES)
623
+ email: label.email
601
624
  }
602
625
  }
603
626
  end
@@ -16,9 +16,10 @@ module PermissionForge
16
16
  # in PermissionForge.cache, so every process sees an invalidation
17
17
  # (InvalidatesManagedActions drops it when a defining record changes).
18
18
  #
19
- # A stale "not managed" lets a request through under the default
20
- # strategy, so the index is invalidated and rebuilt the way a user's
21
- # permission hash is — the same code, one definition:
19
+ # A stale "not managed" lets a request through under the :allow strategy
20
+ # (under :block, the default, both answers refuse), so the index is
21
+ # invalidated and rebuilt the way a user's permission hash is — the same
22
+ # code, one definition:
22
23
  # - every invalidation leaves a fresh stamp (STAMP_KEY) before it removes
23
24
  # the keys, and a build only leaves the index in the store when the
24
25
  # stamp it read before reading the rows is still there after storing it
@@ -197,11 +198,11 @@ module PermissionForge
197
198
  PermissionForge::ResourceAction.arel_table[:name])
198
199
  end
199
200
 
200
- # Mirrors PermissionCacheAccessor#permission_cache_key.
201
+ # Mirrors PermissionCacheAccessor#permission_cache_key: the ambient
202
+ # tenant's key (PermissionForge.ambient_tenant_id, the one reader).
201
203
  def cache_key
202
- config = PermissionForge.configuration
203
- tenant = ActsAsTenant.current_tenant if config.tenant_enabled? && defined?(ActsAsTenant)
204
- tenant ? tenant_key(tenant.id) : CACHE_KEY
204
+ tenant_id = PermissionForge.ambient_tenant_id
205
+ tenant_id ? tenant_key(tenant_id) : CACHE_KEY
205
206
  end
206
207
 
207
208
  def tenant_key(tenant_id)
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # Whether this process can use its database, asked where the answer may be
5
+ # "no" without anything being wrong with the app: at boot (BootChecks),
6
+ # when auto-applied masking reads the Resource table (MaskedModels,
7
+ # MaskingSync), and wherever the audit table is looked for.
8
+ #
9
+ # A process may have no database to use: a Docker build precompiles assets
10
+ # before any database exists, a fresh `rails new --skip-kamal` app comments
11
+ # its production database paths out (the deploy supplies DATABASE_URL at
12
+ # runtime), a worker starts while the database fails over. The same app
13
+ # without the gem boots in each case, so nothing the gem does at boot may
14
+ # abort it then.
15
+ #
16
+ # Connecting can fail in Rails' own code in more ways than
17
+ # ActiveRecord::ActiveRecordError: the sqlite3 adapter refuses a
18
+ # configuration without a database file with an ArgumentError. It is
19
+ # rescued only around a call that runs Rails' connection code alone (a
20
+ # ping, `table_exists?`), never around the gem's own code: an
21
+ # ArgumentError there is a bug and still raises. (An adapter whose driver
22
+ # gem is missing fails while Active Record loads, before any of this runs,
23
+ # with or without the gem.)
24
+ #
25
+ # Every place that asks whether a table is there and may answer "no"
26
+ # quietly asks table_exists? here. Its "no" is never kept: Active Record's
27
+ # schema cache remembers a missing table until the process restarts, so a
28
+ # process that booted before the migration (run from another process)
29
+ # would never see the table; each caller decides how often it asks again
30
+ # (MaskingSync: each run until it syncs; Audit: MISSING_TABLE_RECHECK).
31
+ # The audit table check of the record path passes the narrower
32
+ # UNREACHABLE_ERRORS: a configuration error there still raises out of
33
+ # Audit.record, so the change it records rolls back, as it always did.
34
+ # Services::HostKeyGuard lets a failed read propagate to its own rescue
35
+ # (READ_ERRORS), so that a pass is never remembered from a check that
36
+ # could not look.
37
+ #
38
+ # @api private
39
+ module DatabaseReach
40
+ # A configured database that does not answer (refused, timed out, lost)
41
+ # or does not exist. Never a configuration error: where a quiet "no"
42
+ # lets a change commit unrecorded (the audit table check of
43
+ # Audit.record), only these count as "cannot be asked".
44
+ UNREACHABLE_ERRORS = [ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError].freeze
45
+
46
+ # What a ping or a table check that may answer "no" quietly rescues: the
47
+ # database cannot be used at all — not reachable, not created, not
48
+ # configured (no adapter, an adapter that refuses its configuration:
49
+ # sqlite3's "No database file specified") — or Active Record raised for
50
+ # the query.
51
+ CONNECT_ERRORS = [ActiveRecord::ActiveRecordError, ArgumentError].freeze
52
+
53
+ # What a read raises when the database went away after it answered, or
54
+ # the table is not there yet (not migrated): the errors a boot step or a
55
+ # read of the Resource rows is skipped on.
56
+ READ_ERRORS = [ActiveRecord::ConnectionNotEstablished, ActiveRecord::StatementInvalid].freeze
57
+
58
+ class << self
59
+ # Connects and pings. `connection_pool.with_connection` alone is not
60
+ # enough: since Rails 7.2 it hands back a connection that has not
61
+ # connected yet, so an unreachable database looked available.
62
+ #
63
+ # @param model [Class, nil] an ActiveRecord model whose database is
64
+ # asked; nil for the gem's tables (referenced inside the rescue: the
65
+ # first reference loads the model, and Active Record with it)
66
+ # @return [Exception, nil] what connecting raised (CONNECT_ERRORS), nil
67
+ # when the database answered
68
+ def problem(model = nil)
69
+ (model || PermissionForge::PermissionSet).connection_pool.with_connection(&:verify!)
70
+ nil
71
+ rescue *CONNECT_ERRORS => e
72
+ e
73
+ end
74
+
75
+ # @param model [Class] an ActiveRecord model
76
+ # @param errors [Array<Class>] what counts as "cannot be asked"
77
+ # (CONNECT_ERRORS, or the narrower UNREACHABLE_ERRORS)
78
+ # @yieldparam error [Exception] what the check raised, when it did
79
+ # @return [Boolean] whether the model's table is there; false when the
80
+ # check raised one of `errors`. A "no" is dropped from the schema
81
+ # cache (`reset_column_information`): the next call asks the database.
82
+ def table_exists?(model, errors: CONNECT_ERRORS)
83
+ return true if model.table_exists?
84
+
85
+ model.reset_column_information
86
+ false
87
+ rescue *errors => e
88
+ yield e if block_given?
89
+ false
90
+ end
91
+ end
92
+ end
93
+ end
@@ -30,7 +30,7 @@ module PermissionForge
30
30
  module Deprecation
31
31
  # The version that removes what is deprecated now. A test fails once
32
32
  # VERSION reaches it: remove the deprecated code, then move the horizon.
33
- HORIZON = '0.15'
33
+ HORIZON = '0.16'
34
34
  GEM_NAME = 'PermissionForge'
35
35
 
36
36
  DEPRECATOR = ActiveSupport::Deprecation.new(HORIZON, GEM_NAME)
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'i18n'
5
+ require 'active_support/json'
6
+ require 'active_support/core_ext/object/json'
7
+ require 'active_support/core_ext/string/inflections'
8
+
9
+ module PermissionForge
10
+ module Docs
11
+ # The ```json sample=<name> blocks of docs/admin-api.md, rendered from
12
+ # Api::Schema and the serializers (`bundle exec rake docs:api`;
13
+ # docs/api_samples_test.rb fails while the document is stale). A success
14
+ # sample is a serializer's own output for an example record (EXAMPLES,
15
+ # else a value of the key's type), encoded as the API encodes it, inside
16
+ # the envelope the schema declares; an error sample is the schema's error
17
+ # body with the gem's en message. Only the body of a marked block is
18
+ # rewritten: the prose around it is kept as it stands.
19
+ #
20
+ # @api private
21
+ class ApiSamples
22
+ LOCALE_FILE = File.expand_path('../../../config/locales/permission_forge.en.yml', __dir__)
23
+ CREATED = Time.utc(2026, 3, 10, 12, 0, 0)
24
+ UPDATED = Time.utc(2026, 3, 12, 9, 30, 0)
25
+
26
+ # A value for a key no EXAMPLES entry gives, by its type.
27
+ TYPE_EXAMPLES = {
28
+ integer: 1, string: 'text', boolean: true, uuid: '00000000-0000-4000-8000-000000000001', time: CREATED,
29
+ foreign_identifier: 1, foreign_uuid: '00000000-0000-4000-8000-000000000002', host_key: 42, host_key_list: [],
30
+ string_list: [], object: {}
31
+ }.freeze
32
+
33
+ # The example record of each payload a sample shows.
34
+ EXAMPLES = {
35
+ permission_set: {
36
+ id: 12, uuid: 'd2b4c7e1-5a3f-4b8e-9c6d-1f2a3b4c5d6e', name: 'Sales Team', description: 'Read and edit customers',
37
+ is_active: true, is_editable: true, is_muting: false, requires_session_flag: nil,
38
+ allowed_ips: '203.0.113.0/24', allowed_ip_list: ['203.0.113.0/24'], allowed_hours_start: nil, allowed_hours_end: nil,
39
+ permission_set_group_id: 3, permission_set_group_uuid: '8a6f2c1d-3e4b-4f5a-8b7c-9d0e1f2a3b4c',
40
+ created_at: CREATED, updated_at: UPDATED
41
+ },
42
+ permission_set_group: {
43
+ id: 3, uuid: '8a6f2c1d-3e4b-4f5a-8b7c-9d0e1f2a3b4c', name: 'Sales', description: nil,
44
+ is_active: true, is_editable: true, created_at: CREATED, updated_at: CREATED
45
+ },
46
+ audit_event: {
47
+ id: 481, uuid: '5c1e9a7b-2d4f-4e6a-9b8c-0d1e2f3a4b5c', occurred_at: Time.utc(2026, 9, 14, 8, 12, 44.120391r),
48
+ event: 'assignment.revoked', category: 'change', actor_type: 'user', actor_id: '7', actor_label: 'Ada Admin',
49
+ source: 'admin_ui', record_type: 'PermissionForge::PermissionSetUser', record_id: '93',
50
+ record_uuid: 'a77f3b2c-1d4e-4f5a-8b6c-7d8e9f0a1b2c', record_label: 'Bo Brandt',
51
+ permission_set_id: 12, permission_set_uuid: 'd2b4c7e1-5a3f-4b8e-9c6d-1f2a3b4c5d6e', subject_user_id: '31',
52
+ data: { 'snapshot' => { 'user_id' => 31, 'expires_at' => nil } }, reason: 'left the team',
53
+ request_id: '6f1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d', ip: '203.0.113.9',
54
+ correlation_id: '0b8c7d6e-5f4a-4b3c-9d2e-1f0a9b8c7d6e', created_at: Time.utc(2026, 9, 14, 8, 12, 44.121002r)
55
+ }
56
+ }.freeze
57
+
58
+ # The 422s an input error answers, one per line of `input_errors`:
59
+ # [field, en key].
60
+ INPUT_ERRORS = [
61
+ %w[expires_at api.errors.not_iso8601_time], %w[permission_set api.errors.section_missing],
62
+ %w[bulk_action api.errors.bulk_action_invalid], %w[user_ids api.errors.user_ids_missing],
63
+ %w[user_id api.errors.user_not_found], %w[grant_all api.errors.boolean_invalid],
64
+ %w[is_active api.errors.boolean_invalid], %w[name api.errors.text_shape],
65
+ %w[field_value api.errors.field_value_shape],
66
+ %w[base api.errors.field_generation_failed]
67
+ ].freeze
68
+
69
+ # One sample: how it is laid out (`:pretty`, `:inline` on one line,
70
+ # `:lines` one inline body per line), the schema it follows
71
+ # ([:single | :collection | :list, payload] or [:error, status]) and how
72
+ # it is built (called with the ApiSamples).
73
+ Sample = Data.define(:style, :schema, :build) do
74
+ def error? = schema.first == :error
75
+ end
76
+
77
+ SAMPLES = {
78
+ 'permission_set' => Sample.new(:pretty, %i[single permission_set], ->(_) { Example.envelope(:single, :permission_set) }),
79
+ 'permission_set_group_collection' => Sample.new(:pretty, %i[collection permission_set_group],
80
+ ->(_) { Example.envelope(:collection, :permission_set_group) }),
81
+ 'audit_event_collection' => Sample.new(:pretty, %i[collection audit_event],
82
+ ->(_) { Example.envelope(:collection, :audit_event, per_page: 100) }),
83
+ 'validation_error' => Sample.new(:pretty, [:error, 422], ->(_) { Text.error(422, 'name' => ['validations.name_blank']) }),
84
+ 'not_found' => Sample.new(:pretty, [:error, 404], ->(_) { Text.error(404, 'api.errors.not_found') }),
85
+ 'route_not_found' => Sample.new(:inline, [:error, 404], ->(_) { Text.error(404, 'api.errors.route_not_found') }),
86
+ 'input_errors' => Sample.new(:lines, [:error, 422],
87
+ ->(_) { INPUT_ERRORS.map { |field, key| Text.error(422, field => [key]) } }),
88
+ 'body_unreadable' => Sample.new(:inline, [:error, 400], ->(_) { Text.error(400, 'api.errors.body_unreadable') }),
89
+ 'action_name_invalid' => Sample.new(:inline, [:error, 422], ->(_) { Text.error(422, 'name' => ['errors.admin.action_format']) })
90
+ }.freeze
91
+
92
+ # The gem's en messages, in the error body the schema declares.
93
+ module Text
94
+ module_function
95
+
96
+ # An error body whose messages are the en texts of `messages`: a
97
+ # key, or a field mapped to keys.
98
+ def error(status, messages)
99
+ texts = messages.is_a?(Hash) ? messages.transform_values { |keys| keys.map { |key| t(key) } } : t(messages)
100
+ Api::Schema.error_body(status, texts)
101
+ end
102
+
103
+ # The gem's en text of `permission_forge.<key>`.
104
+ def t(key)
105
+ I18n.load_path |= [LOCALE_FILE] unless I18n.load_path.any? { |path| path.to_s == LOCALE_FILE }
106
+ I18n.t("permission_forge.#{key}", locale: :en, raise: true)
107
+ end
108
+ end
109
+
110
+ # A serializer's own output for an example record, in its envelope.
111
+ module Example
112
+ module_function
113
+
114
+ # A success envelope around the example record of `payload`, as the
115
+ # API encodes it (`render json:`: times in ISO 8601).
116
+ def envelope(kind, payload, per_page: Configuration.new.api_default_per_page)
117
+ data = JSON.parse(ActiveSupport::JSON.encode(serialized(payload)))
118
+ case kind
119
+ when :single then { 'data' => data }
120
+ when :list then { 'data' => [data] }
121
+ when :collection then { 'data' => [data], 'meta' => { 'pagination' => { 'page' => 1, 'per_page' => per_page, 'total_count' => 1 } } }
122
+ end
123
+ end
124
+
125
+ # @raise [ArgumentError] for a serializer key Api::Schema does not declare
126
+ def serialized(payload)
127
+ serializer = Api::Serializers.const_get("#{payload.to_s.camelize}Serializer")
128
+ fields = Api::Schema.payload(payload)
129
+ declared!(serializer, fields.keys)
130
+ example = record(payload, fields)
131
+ serializer.new(example).with_reference_uuids(reference_uuids(serializer, example)).as_json
132
+ end
133
+
134
+ # The example's own uuid for each reference (it has no database to
135
+ # look them up in): association => { id => uuid }.
136
+ def reference_uuids(serializer, example)
137
+ serializer.references.to_h do |association|
138
+ id_key, uuid_key = Api::Serializers::BaseSerializer.reference_keys(association)
139
+ [association, { example[id_key] => example[uuid_key] }]
140
+ end
141
+ end
142
+
143
+ def declared!(serializer, keys)
144
+ undeclared = serializer.attribute_names - keys
145
+ return if undeclared.empty?
146
+
147
+ raise ArgumentError, "#{serializer.name.demodulize} answers #{undeclared.join(', ')}, which Api::Schema does not declare"
148
+ end
149
+
150
+ # EXAMPLES' values for `payload`, else a value of each key's type.
151
+ def record(payload, fields)
152
+ examples = EXAMPLES.fetch(payload, {})
153
+ values = fields.to_h { |key, type| [key, examples.fetch(key) { TYPE_EXAMPLES.fetch(type.to_s.delete_suffix('?').to_sym) }] }
154
+ Struct.new(*values.keys, keyword_init: true).new(**values)
155
+ end
156
+ end
157
+
158
+ # How a block lays out its JSON.
159
+ module Layout
160
+ module_function
161
+
162
+ # Indented two spaces; a list of scalars on one line.
163
+ def pretty(value, indent = 0)
164
+ case value
165
+ when Hash
166
+ entries = value.map { |key, inner| "#{JSON.generate(key.to_s)}: #{pretty(inner, indent + 2)}" }
167
+ value.empty? ? '{}' : nested('{}', entries, indent)
168
+ when Array then scalars?(value) ? inline(value) : nested('[]', value.map { |item| pretty(item, indent + 2) }, indent)
169
+ else JSON.generate(value)
170
+ end
171
+ end
172
+
173
+ # `brackets` is the pair ("{}" or "[]") around the entries, one per line.
174
+ def nested(brackets, entries, indent)
175
+ pad = ' ' * indent
176
+ "#{brackets[0]}\n#{entries.map { |entry| "#{pad} #{entry}" }.join(",\n")}\n#{pad}#{brackets[1]}"
177
+ end
178
+
179
+ def scalars?(list) = list.none? { |item| item.is_a?(Hash) || item.is_a?(Array) }
180
+
181
+ # One line: `{ "key": value }`, `[a, b]`.
182
+ def inline(value)
183
+ case value
184
+ when Hash then value.empty? ? '{}' : "{ #{value.map { |key, inner| "#{JSON.generate(key.to_s)}: #{inline(inner)}" }.join(', ')} }"
185
+ when Array then "[#{value.map { |item| inline(item) }.join(', ')}]"
186
+ else JSON.generate(value)
187
+ end
188
+ end
189
+ end
190
+
191
+ # @param document [String] docs/admin-api.md as it stands
192
+ # @param blocks [Array] its fenced blocks, as test/support/doc_blocks.rb parses them
193
+ # (each with #file, #line, #body and #sample)
194
+ def initialize(document:, blocks:)
195
+ @document = document
196
+ @blocks = blocks.select(&:sample)
197
+ end
198
+
199
+ # The document with the body of every sample block regenerated.
200
+ #
201
+ # @raise [ArgumentError] for a sample= naming no sample, or a serializer
202
+ # key Api::Schema does not declare
203
+ def render
204
+ lines = @document.lines
205
+ @blocks.sort_by(&:line).reverse_each do |block|
206
+ lines[block.line, block.body.lines.size] = text(sample_name(block)).lines
207
+ end
208
+ lines.join
209
+ end
210
+
211
+ def stale? = render != @document
212
+
213
+ # The JSON value of a sample (an Array of bodies for a `:lines` sample).
214
+ def value(name)
215
+ SAMPLES.fetch(name).build.call(self)
216
+ end
217
+
218
+ # The sample laid out as the block holds it, ending in a newline.
219
+ def text(name)
220
+ built = value(name)
221
+ body = case SAMPLES.fetch(name).style
222
+ when :pretty then Layout.pretty(built)
223
+ when :inline then Layout.inline(built)
224
+ when :lines then built.map { |item| Layout.inline(item) }.join("\n")
225
+ end
226
+ "#{body}\n"
227
+ end
228
+
229
+ # What is wrong with a sample against Api::Schema.
230
+ #
231
+ # @return [Array<String>]
232
+ def problems(name)
233
+ sample = SAMPLES.fetch(name)
234
+ kind, detail = sample.schema
235
+ spec = kind == :error ? Api::Schema.error_envelope(detail) : Api::Schema.envelope(kind, detail)
236
+ items = sample.style == :lines ? value(name) : [value(name)]
237
+ items.flat_map { |item| Api::Schema.problems(item, spec) }
238
+ end
239
+
240
+ private
241
+
242
+ def sample_name(block)
243
+ name = block.sample
244
+ return name if SAMPLES.key?(name)
245
+
246
+ raise ArgumentError, "#{block.file}:#{block.line}: sample=#{name} names no sample (#{self.class}::SAMPLES)"
247
+ end
248
+ end
249
+ end
250
+ end