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
@@ -44,7 +44,7 @@ module PermissionForge
44
44
  example: 'Rails.application.credentials.dig(:permission_forge, :password)'),
45
45
  d(:base_controller_class, 'admin_access', :class, example: "'::AdminController'"),
46
46
  d(:admin_assignment_authorization, 'admin_access', :hook,
47
- example: '->(controller, target, user_ids, action) { controller.current_user&.admin? }'),
47
+ example: '->(controller, target, user_ids, action) { PermissionForge.current_user&.admin? }'),
48
48
 
49
49
  d(:user_class, 'people', :class, example: "'User'"),
50
50
  d(:current_user_resolver, 'people', :hook, example: '-> { ::Current.user if defined?(::Current) && ::Current.respond_to?(:user) }'),
@@ -69,18 +69,21 @@ module PermissionForge
69
69
 
70
70
  d(:auto_apply_masked_attributes, 'masked_attributes', :enum, options: %i[auto list off]),
71
71
  d(:masked_write_protection, 'masked_attributes', :enum, options: %i[skip raise off]),
72
- d(:masked_attributes_nil_user_strategy, 'masked_attributes', :enum, options: %i[bypass hide]),
72
+ d(:masked_attributes_nil_user_strategy, 'masked_attributes', :enum,
73
+ options: Configuration::StrategyValues::ALLOWED[:masked_attributes_nil_user_strategy]),
73
74
  d(:hidden_attribute_value, 'masked_attributes', :str, nil_label: :nil),
74
75
  d(:omit_hidden_in_attributes_hash, 'masked_attributes', :bool),
75
76
  d(:mask_virtual_attributes, 'masked_attributes', :enum, options: %i[off auto list]),
76
77
 
77
78
  d(:nil_user_strategy, 'record_rules', :enum, options: %i[bypass deny raise]),
79
+ d(:no_read_access_strategy, 'record_rules', :enum, options: Configuration::StrategyValues::ALLOWED[:no_read_access_strategy]),
78
80
  d(:on_rule_bypass, 'record_rules', :hook, example: '->(user:, resource:, reason:) { Rails.logger.info(...) }'),
79
81
  d(:auto_query_scoping, 'record_rules', :enum, options: %i[off filter_only], ladder: 'enforce'),
80
82
  d(:unscoped_query_detection, 'record_rules', :enum, options: %i[off log raise], ladder: 'detect'),
81
83
  d(:unscoped_query_detection_environments, 'record_rules', :list),
82
84
  d(:database_row_level_security, 'record_rules', :bool, ladder: 'database'),
83
85
  d(:rls_session_variable, 'record_rules', :str),
86
+ d(:rule_secret_column_patterns, 'record_rules', :list),
84
87
 
85
88
  d(:controller_action_discovery, 'controller_actions', :bool),
86
89
  d(:controller_action_resolver, 'controller_actions', :hook,
@@ -111,7 +114,8 @@ module PermissionForge
111
114
  d(:audit_user_class, 'audit_trail', :class, example: "'User'"),
112
115
  d(:audit_events, 'audit_trail', :enum, options: %i[auto true false]),
113
116
  d(:audit_actor_resolver, 'audit_trail', :hook, nil_label: :default,
114
- example: '->(controller) { controller.current_user }'),
117
+ example: '->(controller) { Session.find_by(id: ' \
118
+ 'controller.request.cookie_jar.signed[:session_id])&.user }'),
115
119
  d(:audit_request_context, 'audit_trail', :hook, nil_label: :default,
116
120
  example: '->(controller) { { request_id: controller.request.request_id, ' \
117
121
  'ip: controller.request.remote_ip } }'),
@@ -121,20 +125,21 @@ module PermissionForge
121
125
  d(:audit_log_subscriber, 'audit_trail', :bool),
122
126
  d(:audit_retention_period, 'audit_trail', :num, unit: :seconds, nil_label: :nil),
123
127
  d(:audit_log_authorization, 'audit_trail', :hook, nil_label: :default,
124
- example: '->(controller) { controller.current_user&.auditor? }'),
128
+ example: '->(controller) { PermissionForge.current_user&.auditor? == true }'),
125
129
 
126
130
  d(:tenant_model, 'multi_tenancy', :class, nil_label: :disabled, example: "'Account'"),
127
131
  d(:tenant_column, 'multi_tenancy', :str, nil_label: :disabled),
128
132
 
129
133
  d(:api_enabled, 'admin_api', :bool),
130
- d(:api_auth, 'admin_api', :hook, example: '->(controller) { controller.current_user&.admin? }'),
134
+ d(:api_auth, 'admin_api', :hook, example: '->(controller) { PermissionForge.current_user&.admin? }'),
131
135
  d(:api_base_controller_class, 'admin_api', :class, example: "'::Api::BaseController'"),
132
136
  d(:api_action_authorization, 'admin_api', :hook,
133
- example: '->(controller, action, record) { controller.current_user&.admin? }'),
137
+ example: '->(controller, action, record) { PermissionForge.current_user&.admin? }'),
134
138
  d(:api_assignment_authorization, 'admin_api', :hook,
135
- example: '->(controller, permission_set, user_ids) { controller.current_user&.admin? }'),
139
+ example: '->(controller, permission_set, user_ids) { PermissionForge.current_user&.admin? }'),
136
140
  d(:api_require_authorization, 'admin_api', :bool),
137
141
  d(:api_stats_require_admin, 'admin_api', :bool),
142
+ d(:api_stats_authorization, 'admin_api', :hook, example: '->(controller) { PermissionForge.current_user&.admin? }'),
138
143
  d(:api_record_identifier, 'admin_api', :enum, options: %i[uuid id]),
139
144
  d(:api_default_per_page, 'admin_api', :num, unit: :records),
140
145
  d(:api_max_per_page, 'admin_api', :num, unit: :records),
@@ -179,23 +184,42 @@ module PermissionForge
179
184
 
180
185
  :warn
181
186
  },
182
- 'unconfigured_field_strategy' => ->(config) { config.unconfigured_field_strategy.to_s == 'pass_through' ? :warn : nil },
187
+ # :warn lets an unconfigured field through as :pass_through does (it only logs it): the audit's
188
+ # unconfigured_fields_pass and Home's privacy card count both.
189
+ 'unconfigured_field_strategy' => ->(config) { %w[pass_through warn].include?(config.unconfigured_field_strategy.to_s) ? :warn : nil },
183
190
  'masked_write_protection' => ->(config) { config.masked_write_protection.to_s == 'off' ? :warn : :ok },
184
191
  'audit_trail_enabled' => ->(config) { config.audit_trail_enabled ? :ok : :warn },
185
192
  'audit_events' => ->(config) { AuditLog.recording_problem(config) ? :warn : :ok },
186
193
  'gdpr_defaults' => ->(config) { config.gdpr_defaults ? :ok : nil },
187
194
  'nil_user_strategy' => ->(config) { config.nil_user_strategy.to_s == 'bypass' ? nil : :ok },
188
- 'masked_attributes_nil_user_strategy' => ->(config) { config.hide_masked_attributes_without_user? ? :ok : nil },
195
+ 'no_read_access_strategy' => ->(config) { config.deny_record_reads_without_read_access? ? :ok : nil },
196
+ 'masked_attributes_nil_user_strategy' => ->(config) { config.hide_masked_attributes_without_user_in_requests? ? :ok : nil },
189
197
  'extra_fields_default_access' => ->(config) { config.grant_extra_fields_by_default? ? nil : :ok },
190
- 'unconfigured_action_strategy' => ->(config) { config.allow_unconfigured_actions? ? nil : :ok },
198
+ # :allow is the opt-out from the :block default: the audit's unconfigured_actions_allowed.
199
+ 'unconfigured_action_strategy' => ->(config) { config.allow_unconfigured_actions? ? :warn : :ok },
191
200
  'action_guard_nil_user_strategy' => ->(config) { config.action_guard_nil_user_bypass? ? nil : :ok }
192
201
  }.freeze
193
202
 
203
+ # Risk rules that also read the data: key => ->(config, attention) { ... },
204
+ # `attention` being the page's AttentionItems. auto_query_scoping off is a
205
+ # risk only while record rules are in force that it leaves to find_by /
206
+ # permission_scoped: the rules the Home "Record rules" card lists
207
+ # (AttentionItems#unenforced_rules), so both pages warn together.
208
+ DATA_RISKS = {
209
+ 'auto_query_scoping' => lambda { |config, attention|
210
+ next :ok if config.auto_query_scoping_enabled?
211
+
212
+ attention.unenforced_rules.any? ? :warn : nil
213
+ }
214
+ }.freeze
215
+
194
216
  RECOMMENDED = {
195
217
  'conditional_sets_strict_mode' => 'true',
218
+ 'auto_query_scoping' => ':filter_only',
196
219
  'api_require_authorization' => 'true',
197
220
  'admin_tenantless_access' => ':deny',
198
221
  'unconfigured_field_strategy' => ':block',
222
+ 'unconfigured_action_strategy' => ':block',
199
223
  'masked_write_protection' => ':skip',
200
224
  'audit_trail_enabled' => 'true',
201
225
  'audit_events' => ':auto',
@@ -206,10 +230,13 @@ module PermissionForge
206
230
  name.to_s.delete_prefix('::') == 'ActionController::Base'
207
231
  end
208
232
 
209
- def initialize(configuration: PermissionForge.configuration, values: SettingsValues.new)
233
+ # @param attention [AttentionItems, nil] the page's attention items
234
+ # (shared with the Home badge); built on first use when nil
235
+ def initialize(configuration: PermissionForge.configuration, values: SettingsValues.new, attention: nil)
210
236
  @configuration = configuration
211
237
  @defaults = Configuration.new
212
238
  @values = values
239
+ @attention = attention
213
240
  end
214
241
 
215
242
  def rows
@@ -233,6 +260,20 @@ module PermissionForge
233
260
  rows.count(&:ok?)
234
261
  end
235
262
 
263
+ # What Home lists as needing attention (AttentionItems#total, the
264
+ # page's own items): the health summary says it, so Settings never
265
+ # calls the setup good while Home warns (R1 #93).
266
+ #
267
+ # @return [Integer]
268
+ def attention_count
269
+ attention.total
270
+ end
271
+
272
+ # Nothing to fix: no risky setting and nothing on Home.
273
+ def healthy?
274
+ risky_rows.empty? && attention_count.zero?
275
+ end
276
+
236
277
  def recommended_value(key)
237
278
  row(key)&.recommended
238
279
  end
@@ -250,7 +291,18 @@ module PermissionForge
250
291
  key = definition.key
251
292
  value = @values.normalize(definition, read(@configuration, key))
252
293
  Row.new(definition: definition, value: value, default: @values.normalize(definition, read(@defaults, key)),
253
- hook_state: (value if definition.type == :hook), risk: RISKS[key]&.call(@configuration))
294
+ hook_state: (value if definition.type == :hook), risk: risk_of(key))
295
+ end
296
+
297
+ def risk_of(key)
298
+ data_risk = DATA_RISKS[key]
299
+ return data_risk.call(@configuration, attention) if data_risk
300
+
301
+ RISKS[key]&.call(@configuration)
302
+ end
303
+
304
+ def attention
305
+ @attention ||= AttentionItems.new(configuration: @configuration)
254
306
  end
255
307
 
256
308
  def read(config, key)
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # The one registry of the words and glyphs of every state the admin shows:
6
+ # a field's configured level, an effective outcome, a CRUD cell, an action
7
+ # state, a lock and an actor kind. A state is a [kind, state] pair; its
8
+ # word is the locale key `ui.states.<kind>.<state>` and its glyph is
9
+ # here, so a page never pairs a glyph with a word of its own (R4 #52:
10
+ # "⊘ Hidden by muting" in one place, "∅ Hidden" for the same field in the
11
+ # next). The helpers that draw a state (pf_state_badge, pf_state_word,
12
+ # pf_level_badge, pf_state_cell, pf_lock_badge, ...) read it; no template
13
+ # looks a state key up itself (release_readiness/state_key_guard_test.rb),
14
+ # and two keys with the same English word in the label namespaces fail
15
+ # i18n_test.rb.
16
+ #
17
+ # Two kinds keep their words where they were until the 0.16 merge
18
+ # (NAMESPACES): the CRUD cell (shared.crud_cell) and the action states
19
+ # (resource_actions.states). A state worded by another kind's key says so
20
+ # in SHARED (one word, one key).
21
+ #
22
+ # StateLabel.for(:field_level, 'masked').to_s # => "•• Masked"
23
+ # StateLabel.word(:actor, :unknown) # => "Unknown"
24
+ #
25
+ # @api private
26
+ module StateLabel
27
+ # kind => { state => glyph }. A kind whose states have no glyph of their
28
+ # own (a lock draws an icon, an actor is a name) maps them to nil.
29
+ GLYPHS = {
30
+ field_level: { 'hidden' => '∅', 'masked' => '••', 'read' => '◉', 'edit' => '✎',
31
+ 'leave' => '–', 'mask' => '••', 'hide' => '⊘', 'none' => '–' },
32
+ effective: { 'granted' => '✓', 'blocked' => '⊘', 'none' => '–', 'hidden_by_muting' => '⊘' },
33
+ crud: { 'allowed' => '✓', 'no' => '–', 'muted' => '⊘', 'unchanged' => '–', 'needs_read' => '–' },
34
+ action: { 'permitted' => '✓', 'not_permitted' => '–', 'blocked' => '⊘', 'unchanged' => '–', 'none' => '–' },
35
+ lock: { 'locked' => nil },
36
+ actor: { 'api' => nil, 'basic_auth' => nil, 'system' => nil, 'unknown' => nil, 'user' => nil }
37
+ }.freeze
38
+
39
+ # Kinds whose words stay in their own namespace until 0.16 (allow-listed
40
+ # in i18n_test.rb's label lint).
41
+ NAMESPACES = { crud: 'permission_forge.shared.crud_cell', action: 'permission_forge.resource_actions.states' }.freeze
42
+ # Locale keys under a namespace that are not the state's own name.
43
+ KEY_NAMES = { [:crud, 'needs_read'] => 'needs_read_short' }.freeze
44
+ # A state worded by another kind's key: an action no set configures is
45
+ # "Not configured", the field level's word.
46
+ SHARED = { [:action, 'none'] => [:field_level, 'none'] }.freeze
47
+
48
+ # One state: its glyph (nil when the kind has none) and its word.
49
+ Label = Data.define(:kind, :state, :glyph, :word) do
50
+ # "glyph word", or the word alone.
51
+ def to_s = glyph ? "#{glyph} #{word}" : word
52
+ end
53
+
54
+ module_function
55
+
56
+ # @param kind [Symbol] one of GLYPHS' kinds
57
+ # @param state [String, Symbol]
58
+ # @param default [String, nil] the word for a state the registry does
59
+ # not have (an actor type outside the catalogue is shown as stored);
60
+ # without it such a state raises
61
+ # @return [Label]
62
+ # @raise [ArgumentError] an unknown kind, or an unknown state without `default`
63
+ def for(kind, state, default: nil)
64
+ kind = kind.to_sym
65
+ state = state.to_s
66
+ glyphs = GLYPHS.fetch(kind) { raise ArgumentError, "Unknown state kind: #{kind.inspect}" }
67
+ unless glyphs.key?(state)
68
+ raise ArgumentError, "Unknown #{kind} state: #{state.inspect}" if default.nil?
69
+
70
+ return Label.new(kind: kind, state: state, glyph: nil, word: default.to_s)
71
+ end
72
+
73
+ Label.new(kind: kind, state: state, glyph: glyphs[state], word: I18n.t(key(kind, state)))
74
+ end
75
+
76
+ # @return [String] the state's word, in the current locale
77
+ def word(kind, state, default: nil) = self.for(kind, state, default: default).word
78
+
79
+ # @return [String, nil] the state's glyph
80
+ def glyph(kind, state) = GLYPHS.fetch(kind.to_sym).fetch(state.to_s)
81
+
82
+ # @return [String] the locale key of the state's word
83
+ def key(kind, state)
84
+ kind = kind.to_sym
85
+ state = state.to_s
86
+ shared = SHARED[[kind, state]]
87
+ return key(*shared) if shared
88
+
89
+ namespace = NAMESPACES.fetch(kind) { "permission_forge.ui.states.#{kind}" }
90
+ "#{namespace}.#{KEY_NAMES.fetch([kind, state], state)}"
91
+ end
92
+
93
+ # @return [Array<Array(Symbol, String)>] every [kind, state] of the registry
94
+ def states = GLYPHS.flat_map { |kind, glyphs| glyphs.keys.map { |state| [kind, state] } }
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack/method_override'
4
+
5
+ module PermissionForge
6
+ # Rack::MethodOverride for the admin screens, in the engine's own
7
+ # middleware stack (PermissionForge::Engine: it runs only for requests the
8
+ # host's router hands to the mounted engine). The admin's edit and delete
9
+ # forms POST with a hidden `_method` (patch, delete), which this turns into
10
+ # the verb the route wants. A full-stack host already has
11
+ # Rack::MethodOverride, and a second pass is a no-op (it only rewrites a
12
+ # POST); an API-only host (`rails new --api`) has none, and every such form
13
+ # answered 404 — the engine brings its own, so the host adds nothing for it
14
+ # (docs/admin-ui.md#api-only-applications names the three session
15
+ # middlewares only).
16
+ #
17
+ # The JSON API's paths (`<mount>/api…`) are left alone by this override:
18
+ # a JSON client says the verb it means. In a full-stack host the
19
+ # application's own Rack::MethodOverride runs before the router for every
20
+ # path, the API's included, as Rails has it (a `_method` field or an
21
+ # `X-HTTP-Method-Override` header on a POST then changes its verb there);
22
+ # api_auth and the API's guards apply either way.
23
+ #
24
+ # It keeps the app it wraps under its own name: Rack::MethodOverride's
25
+ # instance variable is not part of Rack's API.
26
+ #
27
+ # @api private
28
+ class AdminMethodOverride < Rack::MethodOverride
29
+ # The engine's JSON API, below the mount point (PATH_INFO inside the engine).
30
+ API_PATH = %r{\A/api(?:/|\.|\z)}
31
+
32
+ # @param app [#call] the engine's next middleware
33
+ def initialize(app)
34
+ super
35
+ @pf_app = app
36
+ end
37
+
38
+ def call(env)
39
+ return @pf_app.call(env) if API_PATH.match?(env[Rack::PATH_INFO].to_s)
40
+
41
+ super
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module PermissionForge
6
+ module Api
7
+ # The one declaration of what the Admin JSON API answers: every envelope
8
+ # (a record, a page of records, the rows one request created, an error),
9
+ # the messages each error status carries, which answers have no body,
10
+ # every serializer's payload (its keys and their types), the two results
11
+ # a controller builds itself (stats, a bulk assignment), and what each
12
+ # endpoint answers when it succeeds.
13
+ #
14
+ # { "data": { ... } } single
15
+ # { "data": { ... }, "meta": { "cache": { ... } } } single_with_cache (one assignment)
16
+ # { "data": [ ... ], "meta": { "pagination": { ... } } } collection
17
+ # { "data": [ ... ] } list (generate_fields)
18
+ # { "error": { "status": 404, "messages": "..." } } error
19
+ #
20
+ # A type is a Symbol from TYPES; a trailing `?` allows null
21
+ # (`:string?`). A Hash is an object with exactly those keys, a one-element
22
+ # Array a list of that element. `:foreign_identifier` marks the numeric id
23
+ # of another PermissionForge record, which the API's URLs do not take
24
+ # under `api_record_identifier = :uuid`: its value is unchanged (the
25
+ # record's id), and the key after it, `:foreign_uuid`, names the same
26
+ # record by its uuid (`permission_set_id`, `permission_set_uuid`:
27
+ # Serializers::BaseSerializer.reference) — answer-only: no endpoint
28
+ # takes it, the `_id` key changes a reference.
29
+ #
30
+ # test/permission_forge/api/envelope_schema_test.rb asserts every route,
31
+ # in every error class, against this module and every serializer's keys
32
+ # against PAYLOADS; `rake docs:api` renders the json samples of
33
+ # docs/admin-api.md from it (Docs::ApiSamples).
34
+ #
35
+ # @api private
36
+ module Schema
37
+ UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
38
+ # What a value of each type is, as the JSON answer carries it.
39
+ TYPES = {
40
+ integer: ->(value) { value.is_a?(Integer) },
41
+ string: ->(value) { value.is_a?(String) },
42
+ boolean: ->(value) { [true, false].include?(value) },
43
+ uuid: ->(value) { value.is_a?(String) && value.match?(UUID) },
44
+ time: ->(value) { value.is_a?(String) && Check.iso8601?(value) },
45
+ foreign_identifier: ->(value) { value.is_a?(Integer) },
46
+ foreign_uuid: ->(value) { value.is_a?(String) && value.match?(UUID) },
47
+ host_key: ->(value) { value.is_a?(Integer) || value.is_a?(String) },
48
+ host_key_list: ->(value) { value.is_a?(Array) && value.all? { |item| item.is_a?(Integer) || item.is_a?(String) } },
49
+ string_list: ->(value) { value.is_a?(Array) && value.all?(String) },
50
+ object: ->(value) { value.is_a?(Hash) },
51
+ # `messages` of a 422: a field (a parameter, an attribute, `base`)
52
+ # mapped to its messages.
53
+ field_messages: lambda { |value|
54
+ value.is_a?(Hash) && !value.empty? &&
55
+ value.all? { |field, list| field.is_a?(String) && list.is_a?(Array) && !list.empty? && list.all?(String) }
56
+ }
57
+ }.freeze
58
+
59
+ # One serializer's payload per entry, named after the serializer
60
+ # (`permission_set` = Serializers::PermissionSetSerializer), keys in
61
+ # the serializer's order.
62
+ PAYLOADS = {
63
+ permission_set: {
64
+ id: :integer, uuid: :uuid, name: :string, description: :string?,
65
+ is_active: :boolean, is_editable: :boolean, is_muting: :boolean,
66
+ requires_session_flag: :string?, allowed_ips: :string?, allowed_ip_list: :string_list,
67
+ allowed_hours_start: :integer?, allowed_hours_end: :integer?,
68
+ permission_set_group_id: :foreign_identifier?, permission_set_group_uuid: :foreign_uuid?, created_at: :time, updated_at: :time
69
+ },
70
+ resource: {
71
+ id: :integer, uuid: :uuid, permission_set_id: :foreign_identifier, permission_set_uuid: :foreign_uuid,
72
+ name: :string, description: :string?, is_active: :boolean, is_editable: :boolean,
73
+ read_access: :boolean, create_access: :boolean, edit_access: :boolean, delete_access: :boolean,
74
+ view_all: :boolean, modify_all: :boolean,
75
+ created_at: :time, updated_at: :time
76
+ },
77
+ resource_field: {
78
+ id: :integer, uuid: :uuid, resource_id: :foreign_identifier, resource_uuid: :foreign_uuid,
79
+ name: :string, label: :string, description: :string?, is_active: :boolean, is_editable: :boolean,
80
+ read_access: :boolean, edit_access: :boolean, mask_access: :boolean, mask_strategy: :string,
81
+ created_at: :time, updated_at: :time
82
+ },
83
+ resource_action: {
84
+ id: :integer, uuid: :uuid, resource_id: :foreign_identifier, resource_uuid: :foreign_uuid,
85
+ name: :string, label: :string?, description: :string?, is_permitted: :boolean, is_active: :boolean,
86
+ created_at: :time, updated_at: :time
87
+ },
88
+ permission_set_rule: {
89
+ id: :integer, uuid: :uuid, permission_set_id: :foreign_identifier, permission_set_uuid: :foreign_uuid,
90
+ resource_id: :foreign_identifier, resource_uuid: :foreign_uuid,
91
+ name: :string, failure_message: :string?, join_type: :string?,
92
+ field_name: :string, field_value: :string, field_accessor_path: :string?,
93
+ operator: :string, include_nulls: :boolean, is_active: :boolean, is_editable: :boolean,
94
+ created_at: :time, updated_at: :time
95
+ },
96
+ permission_set_user: {
97
+ id: :integer, uuid: :uuid, permission_set_id: :foreign_identifier, permission_set_uuid: :foreign_uuid,
98
+ user_id: :host_key, expires_at: :time?, created_at: :time, updated_at: :time
99
+ },
100
+ audit_event: {
101
+ id: :integer, uuid: :uuid, occurred_at: :time, event: :string, category: :string,
102
+ actor_type: :string, actor_id: :string?, actor_label: :string?, source: :string,
103
+ record_type: :string?, record_id: :string?, record_uuid: :string?, record_label: :string?,
104
+ permission_set_id: :foreign_identifier?, permission_set_uuid: :string?, subject_user_id: :string?,
105
+ data: :object, reason: :string?, request_id: :string?, ip: :string?, correlation_id: :string?, created_at: :time
106
+ },
107
+ permission_set_group: {
108
+ id: :integer, uuid: :uuid, name: :string, description: :string?, is_active: :boolean, is_editable: :boolean,
109
+ created_at: :time, updated_at: :time
110
+ },
111
+ permission_set_group_user: {
112
+ id: :integer, uuid: :uuid, permission_set_group_id: :foreign_identifier, permission_set_group_uuid: :foreign_uuid,
113
+ user_id: :host_key, created_at: :time, updated_at: :time
114
+ }
115
+ }.freeze
116
+
117
+ # Cache::Outcome as the API answers it: what one change did to the
118
+ # cached permissions (a bulk assignment, one assignment).
119
+ CACHE = { confirmed: :boolean?, process_local: :boolean, unconfirmed: :integer?, in_request: :boolean }.freeze
120
+
121
+ # The payloads a controller builds itself (no serializer).
122
+ RESULTS = {
123
+ stats: {
124
+ permission_sets_count: :integer, active_sets_count: :integer, resources_count: :integer,
125
+ rules_count: :integer, groups_count: :integer, users_count: :integer
126
+ },
127
+ # Services::BulkPermissionSetAssignment's counts, its Cache::Outcome
128
+ # and the ids that named nobody. `invalidated` is null inside a
129
+ # caller's transaction.
130
+ bulk_assignment: {
131
+ assigned: :integer, revoked: :integer, invalidated: :integer?,
132
+ cache: CACHE,
133
+ skipped: :host_key_list
134
+ }
135
+ }.freeze
136
+
137
+ PAGINATION = { page: :integer, per_page: :integer, total_count: :integer }.freeze
138
+
139
+ # The success envelopes, each around a payload spec.
140
+ ENVELOPES = {
141
+ single: ->(payload) { { data: payload } },
142
+ # One assignment made or revoked, and its Cache::Outcome.
143
+ single_with_cache: ->(payload) { { data: payload, meta: { cache: CACHE } } },
144
+ collection: ->(payload) { { data: [payload], meta: { pagination: PAGINATION } } },
145
+ list: ->(payload) { { data: [payload] } }
146
+ }.freeze
147
+
148
+ # What `messages` is under each error status. A 422 maps fields to
149
+ # their messages; the others carry one sentence.
150
+ ERRORS = { 400 => :string, 401 => :string, 403 => :string, 404 => :string, 422 => :field_messages }.freeze
151
+
152
+ # The answers without a body, and when: a delete (204), the 401 while
153
+ # config.api_auth is not set, the 404 while config.api_enabled is off
154
+ # (it does not reveal that an API exists under the mount).
155
+ BODILESS = { 204 => :deleted, 401 => :api_auth_not_set, 404 => :api_disabled }.freeze
156
+
157
+ # What an endpoint answers when it succeeds: its status, envelope and
158
+ # payload (PAYLOADS or RESULTS); the catch-all is always its 404.
159
+ Endpoint = Data.define(:status, :envelope, :payload)
160
+
161
+ crud = lambda do |name|
162
+ { 'index' => Endpoint.new(200, :collection, name), 'show' => Endpoint.new(200, :single, name),
163
+ 'create' => Endpoint.new(201, :single, name), 'update' => Endpoint.new(200, :single, name),
164
+ 'destroy' => Endpoint.new(204, nil, nil) }
165
+ end
166
+ prefixed = ->(controller, actions) { actions.transform_keys { |action| "#{controller}##{action}" } }
167
+
168
+ # Keyed by "controller#action", the controller under permission_forge/api/.
169
+ ENDPOINTS = {
170
+ 'dashboard#show' => Endpoint.new(200, :single, :stats),
171
+ 'audit_events#index' => Endpoint.new(200, :collection, :audit_event),
172
+ 'audit_events#show' => Endpoint.new(200, :single, :audit_event),
173
+ **prefixed.call('permission_sets', crud.call(:permission_set).merge('clone' => Endpoint.new(201, :single, :permission_set))),
174
+ **prefixed.call('resources', crud.call(:resource).merge('generate_fields' => Endpoint.new(201, :list, :resource_field))),
175
+ **prefixed.call('resource_fields', crud.call(:resource_field)),
176
+ **prefixed.call('resource_actions', crud.call(:resource_action)),
177
+ **prefixed.call('permission_set_rules', crud.call(:permission_set_rule)),
178
+ **prefixed.call('permission_set_users', crud.call(:permission_set_user).slice('index')
179
+ .merge('create' => Endpoint.new(201, :single_with_cache, :permission_set_user),
180
+ 'destroy' => Endpoint.new(200, :single_with_cache, :permission_set_user),
181
+ 'bulk' => Endpoint.new(200, :single, :bulk_assignment))),
182
+ **prefixed.call('permission_set_groups', crud.call(:permission_set_group)
183
+ .merge('clone' => Endpoint.new(201, :single, :permission_set_group))),
184
+ **prefixed.call('permission_set_group_users', crud.call(:permission_set_group_user).slice('index', 'create', 'destroy')),
185
+ 'unmatched_routes#show' => Endpoint.new(404, :error, nil)
186
+ }.freeze
187
+
188
+ module_function
189
+
190
+ # @param name [Symbol] a PAYLOADS or RESULTS entry
191
+ # @return [Hash{Symbol => Symbol, Hash}] the keys and their types
192
+ def payload(name)
193
+ PAYLOADS.fetch(name) { RESULTS.fetch(name) }
194
+ end
195
+
196
+ # @return [Array<Symbol>] the keys of a payload, in order
197
+ def payload_keys(name) = payload(name).keys
198
+
199
+ # @return [Array<Symbol>] the keys of a payload that hold another record's numeric id
200
+ # (each followed by its `:foreign_uuid` key)
201
+ def foreign_identifiers(name)
202
+ payload(name).select { |_key, type| type.is_a?(Symbol) && base_type(type) == :foreign_identifier }.keys
203
+ end
204
+
205
+ # The spec of a success envelope around a payload.
206
+ def envelope(kind, payload_name)
207
+ ENVELOPES.fetch(kind).call(payload(payload_name))
208
+ end
209
+
210
+ # The spec of an error answer under `status`.
211
+ def error_envelope(status)
212
+ { error: { status: [:exactly, status], messages: ERRORS.fetch(status) } }
213
+ end
214
+
215
+ # An error answer: the body the API renders for `status`.
216
+ #
217
+ # @return [Hash]
218
+ def error_body(status, messages)
219
+ { 'error' => { 'status' => status, 'messages' => messages } }
220
+ end
221
+
222
+ # What is wrong with a parsed JSON answer of an endpoint's success.
223
+ #
224
+ # @param endpoint [Endpoint]
225
+ # @param body [Hash, nil] the parsed body (nil when there is none)
226
+ # @return [Array<String>] empty when it matches
227
+ def success_problems(endpoint, body)
228
+ return error_problems(endpoint.status, body) if endpoint.envelope == :error
229
+ return body.nil? ? [] : ["#{endpoint.status}: a body where none is declared"] if endpoint.envelope.nil?
230
+
231
+ problems(body, envelope(endpoint.envelope, endpoint.payload))
232
+ end
233
+
234
+ # What is wrong with a parsed JSON error answer under `status`; nil
235
+ # (no body) only where BODILESS declares it.
236
+ #
237
+ # @return [Array<String>]
238
+ def error_problems(status, body)
239
+ return BODILESS.key?(status) ? [] : ["#{status}: no body, and BODILESS does not declare one"] if body.nil?
240
+ return ["#{status}: not an error status of the schema (#{ERRORS.keys.join(', ')})"] unless ERRORS.key?(status)
241
+
242
+ problems(body, error_envelope(status))
243
+ end
244
+
245
+ # What is wrong with `value` against `spec`, one message per problem,
246
+ # each with its JSON path (Check).
247
+ #
248
+ # @return [Array<String>]
249
+ def problems(value, spec, path = '$') = Check.problems(value, spec, path)
250
+
251
+ def base_type(type) = type.to_s.delete_suffix('?').to_sym
252
+
253
+ # Walks a parsed answer along a spec (a Hash, a one-element Array, a
254
+ # type Symbol, or [:exactly, value]).
255
+ #
256
+ # @api private
257
+ module Check
258
+ # A value of the answer and its JSON path.
259
+ Node = Data.define(:value, :path) do
260
+ def refused(expected) = ["#{path}: expected #{expected}, got #{value.inspect[0, 60]}"]
261
+ def at(key) = "#{path}.#{key}"
262
+ end
263
+
264
+ module_function
265
+
266
+ def problems(value, spec, path)
267
+ node = Node.new(value, path)
268
+ case spec
269
+ when Hash then object_problems(node, spec)
270
+ when Array then spec.first == :exactly ? exact_problems(node, spec.last) : list_problems(node, spec.first)
271
+ else type_problems(node, spec)
272
+ end
273
+ end
274
+
275
+ def object_problems(node, spec)
276
+ value = node.value
277
+ return node.refused('an object') unless value.is_a?(Hash)
278
+
279
+ keys = value.keys.map(&:to_s)
280
+ key_problems(node, keys, spec.keys.map(&:to_s)) + value_problems(node, spec)
281
+ end
282
+
283
+ # The values of the declared keys the answer has, each against its spec.
284
+ def value_problems(node, spec)
285
+ value = node.value
286
+ spec.flat_map { |key, inner| value.key?(key.to_s) ? problems(value[key.to_s], inner, node.at(key)) : [] }
287
+ end
288
+
289
+ # Keys the answer has and the spec does not, and the reverse.
290
+ def key_problems(node, keys, expected)
291
+ (keys - expected).map { |key| "#{node.at(key)}: not declared in Api::Schema" } +
292
+ (expected - keys).map { |key| "#{node.at(key)}: missing" }
293
+ end
294
+
295
+ def list_problems(node, spec)
296
+ value = node.value
297
+ return node.refused('a list') unless value.is_a?(Array)
298
+
299
+ value.each_with_index.flat_map { |item, index| problems(item, spec, "#{node.path}[#{index}]") }
300
+ end
301
+
302
+ def exact_problems(node, expected)
303
+ node.value == expected ? [] : node.refused(expected.inspect)
304
+ end
305
+
306
+ def type_problems(node, type)
307
+ value = node.value
308
+ return [] if value.nil? && type.to_s.end_with?('?')
309
+
310
+ TYPES.fetch(Schema.base_type(type)).call(value) ? [] : node.refused(type)
311
+ end
312
+
313
+ def iso8601?(text)
314
+ Time.iso8601(text)
315
+ true
316
+ rescue ArgumentError
317
+ false
318
+ end
319
+ end
320
+ end
321
+ end
322
+ end