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
@@ -22,9 +22,11 @@ module PermissionForge
22
22
  new(permission_set, attrs, unlock: unlock).call
23
23
  end
24
24
 
25
+ # Without a name the copy is named "<name> (copy)", numbered when taken
26
+ # (names are unique per tenant: PermissionSet.copy_name).
25
27
  def initialize(permission_set, attrs, unlock: false)
26
28
  @permission_set = permission_set
27
- @attrs = attrs
29
+ @attrs = attrs.key?(:name) || attrs.key?('name') ? attrs : attrs.merge(name: PermissionForge::PermissionSet.copy_name(permission_set.name))
28
30
  @unlock = unlock
29
31
  end
30
32
 
@@ -42,29 +44,51 @@ module PermissionForge
42
44
 
43
45
  private
44
46
 
47
+ # The rows in the order the hash builder merges them
48
+ # (PermissionHashBuilder.sort_rows, the one definition of row order).
45
49
  def clone_resources(target_set)
46
- @permission_set.resources.includes(:resource_fields, :resource_actions, :permission_set_rules).find_each do |resource|
47
- clone_resource(target_set, resource)
48
- end
50
+ @skipped = []
51
+ @permission_set.resources.includes(:resource_fields, :resource_actions, :permission_set_rules).load
52
+ PermissionForge::Services::PermissionHashBuilder.sort_rows(@permission_set)
53
+ @permission_set.resources.each { |resource| clone_resource(target_set, resource) }
54
+ log_skipped
49
55
  end
50
56
 
57
+ # A field or action row with a blank name (written before the names
58
+ # were checked) cannot be created again: the copy leaves it out —
59
+ # such a row names no column and no action — and the log says so,
60
+ # once per copy, instead of refusing the whole copy.
51
61
  def clone_resource(target_set, resource)
52
62
  cloned_resource = copy(resource, permission_set: target_set)
53
63
 
54
- resource.resource_fields.each { |field| copy(field, resource: cloned_resource) }
64
+ copyable(resource.resource_fields).each { |field| copy(field, resource: cloned_resource) }
55
65
  clone_actions(resource, cloned_resource)
56
66
  resource.permission_set_rules.each { |rule| copy(rule, permission_set: target_set, resource: cloned_resource) }
57
67
  end
58
68
 
59
69
  # One copy per action name. Rows that share a name (possible only on a
60
- # table without the unique index) are OR-ed when permissions are built,
61
- # so the copy is the single row they amount to.
70
+ # table without the unique index) are OR-ed when permissions are built
71
+ # (PermissionHashBuilder#merge_resource_actions: the active rows'
72
+ # is_permitted, ||=), so the copy is the single row they amount to.
62
73
  def clone_actions(resource, cloned_resource)
63
- resource.resource_actions.group_by(&:name).each_value do |rows|
74
+ copyable(resource.resource_actions).group_by(&:name).each_value do |rows|
64
75
  copy(rows.first, resource: cloned_resource, **combined_action_flags(rows))
65
76
  end
66
77
  end
67
78
 
79
+ def copyable(rows)
80
+ kept, skipped = rows.partition { |row| !refused_for_blank_name?(row) }
81
+ @skipped.concat(skipped)
82
+ kept
83
+ end
84
+
85
+ def log_skipped
86
+ return if @skipped.empty?
87
+
88
+ kinds = @skipped.map { |row| row.class.name.demodulize }.tally.map { |kind, count| "#{count} #{kind}" }.join(', ')
89
+ Rails.logger&.warn("[PermissionForge] copying #{@permission_set.name}: skipped #{@skipped.size} row(s) with a blank name (#{kinds})")
90
+ end
91
+
68
92
  def combined_action_flags(rows)
69
93
  return {} if rows.one?
70
94
 
@@ -6,6 +6,12 @@ module PermissionForge
6
6
  # Returns true if the set is active for the given context (all conditions pass).
7
7
  # A nil/blank condition always passes (unconditional).
8
8
  #
9
+ # The conditions are read through PermissionForge::ActivationConditions
10
+ # (the one parser of the IP list and the hour window). A condition nobody
11
+ # can judge — an IP entry that is not an address, a list of separators,
12
+ # one hour of the two, the same hour twice, an hour outside 0..23 — never
13
+ # passes (.broken?); ConditionalSets applies a muting set with one.
14
+ #
9
15
  # @example
10
16
  # PermissionSetConditionEvaluator.call(
11
17
  # permission_set: ps,
@@ -22,22 +28,35 @@ module PermissionForge
22
28
  new(permission_set, context).call
23
29
  end
24
30
 
25
- # Whether a condition of the set cannot be judged in `context`: it
31
+ # Whether a condition of the set cannot be judged in `context`: the
32
+ # set holds a broken condition (.broken?, whatever the context), it
26
33
  # restricts the IP and the context has none, or it requires a session
27
34
  # flag and the context says nothing about flags (no :session_flags at
28
35
  # all — an empty list is an answer). The hours are always judged (the
29
- # time defaults to now). Under config.conditional_sets_strict_mode a
30
- # conditional muting set applies then (ConditionalSets#passing).
36
+ # time defaults to now). A conditional muting set applies then under
37
+ # config.conditional_sets_strict_mode — and with a broken condition in
38
+ # every mode (ConditionalSets::Member#applies?).
31
39
  #
32
40
  # @return [Boolean]
33
41
  def self.unjudgeable?(permission_set:, context:)
34
42
  context ||= {}
35
- (permission_set.allowed_ips.present? && context[:ip].blank?) ||
43
+ broken?(permission_set: permission_set) ||
44
+ (permission_set.allowed_ips.present? && context[:ip].blank?) ||
36
45
  (permission_set.requires_session_flag.present? && context[:session_flags].nil?)
37
46
  end
38
47
 
48
+ # Whether the set holds a condition nobody can judge
49
+ # (ActivationConditions#conditions_broken?): such a set never passes.
50
+ #
51
+ # @param permission_set [PermissionSet, #allowed_ips]
52
+ # @return [Boolean]
53
+ def self.broken?(permission_set:)
54
+ PermissionForge::ActivationConditions.of(permission_set).conditions_broken?
55
+ end
56
+
39
57
  def initialize(permission_set, context)
40
58
  @permission_set = permission_set
59
+ @conditions = PermissionForge::ActivationConditions.of(permission_set)
41
60
  @context = context || {}
42
61
  end
43
62
 
@@ -57,45 +76,28 @@ module PermissionForge
57
76
  end
58
77
 
59
78
  # IP restriction: set only active when request IP matches allowed CIDRs.
60
- # The IP may be a String or an IPAddr; anything that is not an address
61
- # fails the condition.
79
+ # The IP may be a String or an IPAddr (ActivationConditions.request_address:
80
+ # an IPv4-mapped IPv6 address is read as its IPv4 address); anything
81
+ # that is not an address fails the condition, as does a list that
82
+ # cannot be judged.
62
83
  def ip_passes?
63
- allowed = @permission_set.allowed_ips
64
- return true if allowed.blank?
84
+ return true if @permission_set.allowed_ips.blank?
85
+ return false if @conditions.ip_list_broken?
65
86
 
66
- request_ip = @context[:ip]
67
- return false if request_ip.blank?
87
+ address = PermissionForge::ActivationConditions.request_address(@context[:ip])
88
+ return false unless address
68
89
 
69
- address = request_ip.is_a?(IPAddr) ? request_ip : IPAddr.new(request_ip.to_s)
70
- parse_ip_ranges(allowed).any? { |range| range.include?(address) }
71
- rescue IPAddr::Error
72
- false
90
+ @conditions.allowed_ip_ranges.any? { |range| range.include?(address) }
73
91
  end
74
92
 
75
- # Login hours: set only active during allowed hour window.
93
+ # Login hours: set only active during the allowed hour window; a
94
+ # broken window (one hour, the same hour twice, out of range) includes
95
+ # no hour.
76
96
  def hours_pass?
77
- start_hour = @permission_set.allowed_hours_start
78
- end_hour = @permission_set.allowed_hours_end
79
- return true if start_hour.nil? || end_hour.nil?
80
-
81
- hour_in_range?((@context[:time] || Time.current).hour, start_hour, end_hour)
82
- end
83
-
84
- def hour_in_range?(hour, start_hour, end_hour)
85
- if start_hour <= end_hour
86
- hour >= start_hour && hour < end_hour
87
- else
88
- # Wraps midnight (e.g., 22-06 means 22:00 to 05:59)
89
- hour >= start_hour || hour < end_hour
90
- end
91
- end
97
+ window = @conditions.hours_window
98
+ return true unless window.set?
92
99
 
93
- def parse_ip_ranges(allowed_ips)
94
- allowed_ips.split(',').filter_map do |cidr|
95
- IPAddr.new(cidr.strip)
96
- rescue IPAddr::Error
97
- nil
98
- end
100
+ window.include?((@context[:time] || Time.current).hour)
99
101
  end
100
102
  end
101
103
  end
@@ -37,7 +37,8 @@ module PermissionForge
37
37
 
38
38
  def clone_permission_sets(cloned_group)
39
39
  @group.permission_sets.each do |ps|
40
- attrs = { name: ps.name, permission_set_group: cloned_group }
40
+ # Names are unique per tenant: each copy is "<name> (copy)", numbered when taken.
41
+ attrs = { name: PermissionForge::PermissionSet.copy_name(ps.name), permission_set_group: cloned_group }
41
42
  attrs[:is_editable] = true if @unlock
42
43
  PermissionSetCloner.call(permission_set: ps, attrs: attrs, unlock: @unlock)
43
44
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ class PermitsAttributes
6
+ # What `PermitsAttributes.call(..., report: true)` answers: the filtered
7
+ # params and what was dropped from them.
8
+ #
9
+ # @!attribute [r] params
10
+ # @return [Hash] the filtered params, as `call` without `report:` returns them
11
+ # @!attribute [r] dropped
12
+ # @return [Array<String>] the paths of the dropped keys, in order, without
13
+ # repeats: `status`, `supplier_attributes.secret`,
14
+ # `lines_attributes.*.price`, `lines_attributes.*` (an element that is
15
+ # no record, or one whose id the user may not reach); never values
16
+ #
17
+ # @api public
18
+ Result = Data.define(:params, :dropped) do
19
+ # @return [Boolean] whether any key was dropped
20
+ def dropped? = dropped.any?
21
+
22
+ # The top-level keys behind the dropped paths (`lines_attributes`
23
+ # for `lines_attributes.*.price`), for a message such as
24
+ # "Not saved: status, notes".
25
+ #
26
+ # @return [Array<String>]
27
+ def dropped_fields = dropped.map { |path| path.split('.', 2).first }.uniq
28
+ end
29
+ end
30
+ end
31
+ end
@@ -31,8 +31,16 @@ module PermissionForge
31
31
  # `lines_attributes.*` for an element that is no record — never their
32
32
  # values. Without a user every key is dropped (reason nil_user).
33
33
  #
34
+ # With `report: true` the call answers a {Result} instead: the same
35
+ # filtered hash plus the paths of the dropped keys, so a controller can
36
+ # tell the user what was not saved (the default return is unchanged).
37
+ #
34
38
  # @example
35
39
  # filtered = PermitsAttributes.call(resource: customer, permitted_params: params)
40
+ # @example Telling the user what was not saved
41
+ # result = PermitsAttributes.call(resource: customer, permitted_params: params, report: true)
42
+ # customer.update(result.params)
43
+ # flash[:alert] = "Not saved: #{result.dropped_fields.join(', ')}" if result.dropped?
36
44
  #
37
45
  # @api public
38
46
  class PermitsAttributes
@@ -46,49 +54,61 @@ module PermissionForge
46
54
  DESTROY_KEY = '_destroy'.freeze
47
55
 
48
56
  # Filters incoming params by the user's field-level edit access (does not mutate them).
49
- # @param resource [String] the model name
57
+ # @param resource [ActiveRecord::Base, Class] the record or model class whose Resource rows decide
50
58
  # @param permitted_params [Hash]
51
- # @return [Hash]
52
- def self.call(resource:, permitted_params:)
53
- new(resource: resource, permitted_params: permitted_params).call
59
+ # @param report [Boolean] answer a {Result} (filtered params and dropped paths)
60
+ # @return [Hash, Result] the filtered hash; a {Result} with `report: true`
61
+ def self.call(resource:, permitted_params:, report: false)
62
+ new(resource: resource, permitted_params: permitted_params, report: report).call
54
63
  end
55
64
 
56
- def initialize(resource:, permitted_params:)
65
+ def initialize(resource:, permitted_params:, report: false)
57
66
  @user = PermissionForge.current_user
58
67
  @resource = resource
59
68
  @permitted_params = permitted_params
69
+ @report = report
60
70
  end
61
71
 
62
- # @return [Hash] a new hash holding only the fields the user may edit
72
+ # @return [Hash, Result] a new hash holding only the fields the user may
73
+ # edit; with `report: true`, a {Result} holding it and the dropped paths
63
74
  def call
64
- if @user.blank?
65
- report_dropped('nil_user') { each_pair(@permitted_params) { |key, _| dropped(key) } } if nil_user_report?
66
- return {}
67
- end
68
- return {} if resource_class.nil?
75
+ filtered = filter
76
+ @report ? Result.new(params: filtered, dropped: (@dropped || []).uniq) : filtered
77
+ end
78
+
79
+ private
80
+
81
+ def filter
82
+ return drop_everything if resource_class.nil? || @user.blank?
69
83
  return {} if @permitted_params.blank?
70
84
 
71
85
  report_dropped('field_access') { filter_for(resource_class, @permitted_params) }
72
86
  end
73
87
 
74
- private
88
+ # Every key is dropped: nobody is signed in (published as nil_user), or
89
+ # there is no model to filter for (reported only: params.filtered
90
+ # names the model).
91
+ def drop_everything
92
+ if @permitted_params.present? && (@report || PermissionForge::Audit.instrument?(EVENT))
93
+ report_dropped('nil_user') { @permitted_params.each_pair { |key, _| dropped(key) } }
94
+ end
95
+ {}
96
+ end
75
97
 
76
98
  # Runs the filter, collecting the dropped paths in @dropped only when
77
- # params.filtered is stored or heard, and publishes them.
99
+ # the caller asked for a report or params.filtered is stored or heard,
100
+ # and publishes them to the latter.
78
101
  def report_dropped(reason)
79
- return yield unless PermissionForge::Audit.instrument?(EVENT)
102
+ instrumented = !resource_class.nil? && PermissionForge::Audit.instrument?(EVENT)
103
+ return yield unless instrumented || @report
80
104
 
81
105
  @dropped = []
82
106
  @path = []
83
107
  result = yield
84
- publish_dropped(reason) unless @dropped.empty?
108
+ publish_dropped(reason) if instrumented && !@dropped.empty?
85
109
  result
86
110
  end
87
111
 
88
- def nil_user_report?
89
- !resource_class.nil? && @permitted_params.present? && PermissionForge::Audit.instrument?(EVENT)
90
- end
91
-
92
112
  def publish_dropped(reason)
93
113
  dropped = @dropped.uniq
94
114
  record = @resource.is_a?(Class) ? nil : @resource
@@ -130,7 +150,7 @@ module PermissionForge
130
150
  fields = editable_fields_for(klass)
131
151
  result = {}
132
152
 
133
- each_pair(params) do |key, value|
153
+ params.each_pair do |key, value|
134
154
  if (association = nested_association(klass, key))
135
155
  filtered = within(key) { filter_nested(klass, association, value) }
136
156
  filtered.nil? ? dropped(key) : result[key] = filtered
@@ -230,7 +250,7 @@ module PermissionForge
230
250
 
231
251
  def filter_indexed(target, records)
232
252
  result = {}
233
- each_pair(records) do |index, record|
253
+ records.each_pair do |index, record|
234
254
  filtered = within(EACH) { filter_record(target, record) }
235
255
  filtered.nil? ? dropped(EACH) : result[index] = filtered
236
256
  end
@@ -251,15 +271,16 @@ module PermissionForge
251
271
  @user.respond_to?(:permissions_hash) && PermissionForge::ResourceChain.entries(@user, resource_names(klass)).any?
252
272
  end
253
273
 
274
+ # A key is editable where the field is readable AND editable: the hash
275
+ # the builder writes says so already (FieldPermissionMerger counts edit
276
+ # only with read), but a hash another version cached may still say
277
+ # `edit_access: true, read_access: false` — a field nobody may read is
278
+ # not one they may write.
254
279
  def editable_fields_for(klass)
255
280
  return {} unless @user.respond_to?(:permissions_hash)
256
281
 
257
- PermissionForge::ResourceChain.fields(@user, resource_names(klass)).select { |_, access| access[:edit_access] }
258
- end
259
-
260
- # Iterates key/value pairs of an ActionController::Parameters or a Hash.
261
- def each_pair(params, &)
262
- params.each_pair(&)
282
+ PermissionForge::ResourceChain.fields(@user, resource_names(klass))
283
+ .select { |_, access| access[:read_access] && access[:edit_access] }
263
284
  end
264
285
 
265
286
  def each_pair?(value)
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ class ResourceFieldGenerator
6
+ # The one definition of a generated field row: the attributes a
7
+ # ResourceField gets when a path creates rows for a model's columns at
8
+ # a level — ResourceFieldGenerator (the API's generate_fields,
9
+ # Resource.grant_full_access!, the console), the admin's presets and
10
+ # "Sync fields" (Admin::ColumnFields) and the Fields tab's bulk add
11
+ # (Admin::ChangeSetParts::NewFields). Three paths, one row
12
+ # (release_readiness/decision_parity_test.rb, part a):
13
+ #
14
+ # - the flags of the level (PermissionForge::FieldLevel), with the
15
+ # privacy default: under gdpr_defaults, on a granting set, a column
16
+ # that looks like personal data (PermissionForge::SensitiveColumns)
17
+ # created Read-only starts Masked. Editable is full access and masks
18
+ # nothing; Hidden stays Hidden (a privacy default never grants). A
19
+ # Muting Permission Set gets the level chosen: there a row takes
20
+ # access away, and the privacy default has nothing to add;
21
+ # - then Editable is Read-only on a Resource without Edit
22
+ # (FieldLevel.capped): chosen Editable, a personal-data field is
23
+ # Read-only there, not Masked;
24
+ # - Rails' timestamps at most Read-only on a granting set
25
+ # (PermissionForge::TimestampFields);
26
+ # - the recommended mask strategy of a sensitive column, whether or not
27
+ # the row is masked now, so masking it later uses the right one;
28
+ # 'full' otherwise;
29
+ # - the label (the name itself: never English made up from it — the
30
+ # admin shows the host's translation, or the name), active, editable.
31
+ #
32
+ # A blank name (a host whose extra_fields_method lists one) is no row,
33
+ # nor is a level that is none (FieldLevel.row?: Unchanged on a Muting
34
+ # Permission Set mutes nothing, so no path writes a row for it).
35
+ #
36
+ # @api private
37
+ class RowBuilder
38
+ # Granting levels the privacy default turns into Masked for a column
39
+ # that looks like personal data.
40
+ MASKED_BY_PRIVACY_DEFAULTS = %w[read].freeze
41
+
42
+ # @param resource [Resource] the rows' Resource (its name resolves the model)
43
+ # @param muting [Boolean, nil] the rows are a Muting Permission Set's
44
+ # (default: the Resource's set says)
45
+ # @param configuration [Configuration]
46
+ # @param sensitive [SensitiveColumns] the matcher (default: one on `configuration`)
47
+ def initialize(resource, muting: nil, configuration: PermissionForge.configuration, sensitive: nil)
48
+ @resource = resource
49
+ @muting = muting.nil? ? (resource.permission_set&.is_muting || false) : muting
50
+ @configuration = configuration
51
+ @sensitive = sensitive || PermissionForge::SensitiveColumns.new(configuration: configuration)
52
+ end
53
+
54
+ # @return [Boolean] the rows are a Muting Permission Set's
55
+ def muting? = @muting
56
+
57
+ # @param column [String, Symbol] the field name (a column or a virtual field)
58
+ # @param level [String] a FieldLevel of the set's kind
59
+ # @return [Hash, nil] the row's attributes (without the Resource), or
60
+ # nil for a blank name or a level that is no row
61
+ # @raise [ArgumentError] a level the set's kind does not have
62
+ def attrs(column, level)
63
+ name = column.to_s
64
+ return if name.strip.empty?
65
+ return unless row_level?(level)
66
+
67
+ match = @sensitive.match(name)
68
+ level = PermissionForge::FieldLevel.capped(effective_level(level, match), resource_edit: @resource.edit_access, muting: @muting)
69
+ flags = PermissionForge::FieldLevel.flags_for(level, muting: @muting)
70
+ { name: name, label: name, is_active: true, is_editable: true, mask_strategy: match&.strategy || 'full' }
71
+ .merge(flags)
72
+ .merge(timestamps.overrides(name, read: flags[:read_access]))
73
+ end
74
+
75
+ private
76
+
77
+ def row_level?(level)
78
+ PermissionForge::FieldLevel.flags_for(level, muting: @muting) # raises for a level the kind lacks
79
+ PermissionForge::FieldLevel.row?(level, muting: @muting)
80
+ end
81
+
82
+ # The level after the privacy default.
83
+ def effective_level(level, match)
84
+ level = level.to_s
85
+ return level if @muting || match.nil? || !@configuration.gdpr_defaults
86
+
87
+ MASKED_BY_PRIVACY_DEFAULTS.include?(level) ? 'masked' : level
88
+ end
89
+
90
+ def timestamps
91
+ @timestamps ||= PermissionForge::TimestampFields.new(PermissionForge::ModelLookup.model_for(@resource.name), muting: @muting)
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -1,17 +1,56 @@
1
+ require_relative 'resource_field_generator/row_builder'
2
+
1
3
  module PermissionForge
2
4
  module Services
3
- # Creates a ResourceField for every column of a resource's model (and declared virtual fields
4
- # under `extra_fields_default_access = :unconfigured`).
5
+ # Creates a ResourceField for every field of a resource's model that has none: its columns and its
6
+ # virtual fields (PermissionForge::ManagedFields, the names the admin's presets and Sync write rows for).
5
7
  #
6
8
  # @api public
7
9
  class ResourceFieldGenerator
10
+ # Every generated row is built by {RowBuilder}, the one definition the
11
+ # admin's presets, "Sync fields" and the Fields tab's bulk add share.
12
+ # With `grant_all` the caller asked for full access (Editable): every
13
+ # field readable and editable, unmasked — also the columns that look
14
+ # like personal data, which keep the recommended mask strategy for
15
+ # when someone masks them and are still reported as personal data
16
+ # (GdprFieldAuditor, the admin's privacy audit). Without it every
17
+ # field is Hidden; under `gdpr_defaults` the privacy default masks what
18
+ # is granted Read, and nothing is granted here. On a Muting Permission
19
+ # Set `grant_all` takes everything away (Hide); without it nothing is
20
+ # written — Unchanged is no row (FieldLevel.row?), as in the admin.
21
+ # On a Resource without Edit, Editable rows are Read-only
22
+ # (FieldLevel.capped). Rails' timestamps
23
+ # (created_at / updated_at) are at most read-only on a granting set
24
+ # (PermissionForge::TimestampFields).
25
+ #
8
26
  # @param resource [Resource]
9
- # @param grant_all [Boolean] grant read and edit on the generated fields
27
+ # @param grant_all [Boolean] grant read and edit, unmasked, on the generated fields
10
28
  # @return [Array<ResourceField>] the fields created
11
29
  def self.call(resource:, grant_all: false)
12
30
  new(resource: resource, grant_all: grant_all).call
13
31
  end
14
32
 
33
+ # The attributes of one generated row — the one definition every path
34
+ # that creates rows for a model's columns uses ({RowBuilder#attrs}).
35
+ #
36
+ # @param resource [Resource] its Permission Set says whether the row is a muting one
37
+ # @param column [String, Symbol] a column or virtual field name
38
+ # @param level [String] a FieldLevel of the set's kind
39
+ # @return [Hash, nil] the row's attributes, nil for a blank name
40
+ def self.build(resource:, column:, level:)
41
+ rows(resource).attrs(column, level)
42
+ end
43
+
44
+ # A builder for several rows of one Resource (the model and the matcher
45
+ # are resolved once). Keywords as {RowBuilder#initialize}: `muting:`
46
+ # (default: the Resource's set says), `configuration:`, `sensitive:`.
47
+ #
48
+ # @param resource [Resource]
49
+ # @return [RowBuilder]
50
+ def self.rows(resource, **)
51
+ RowBuilder.new(resource, **)
52
+ end
53
+
15
54
  def initialize(resource:, grant_all: false)
16
55
  @resource = resource
17
56
  @grant_all = grant_all
@@ -19,9 +58,14 @@ module PermissionForge
19
58
 
20
59
  # All or nothing, under one audit correlation id (an enclosing block's,
21
60
  # if any): a field that cannot be saved leaves no field and no event.
61
+ # A Resource named after no model this app can load
62
+ # (PermissionForge::ModelLookup: `Custmer`, `Kernel`, a model without
63
+ # its table) has no columns: no fields, a log line — never an error
64
+ # (the API answers 422 before it gets here; `rake permission_forge:audit`
65
+ # lists the Resource under unknown_models).
22
66
  def call
23
- model_class = @resource.name.safe_constantize
24
- return [] unless model_class
67
+ model_class = PermissionForge::ModelLookup.model_for(@resource.name)
68
+ return no_model unless model_class
25
69
 
26
70
  fields = PermissionForge::Audit.correlate { PermissionForge::ResourceField.transaction { generate_fields(model_class) } }
27
71
  PermissionForge.configuration.record_gdpr_pattern_checksum
@@ -42,51 +86,36 @@ module PermissionForge
42
86
 
43
87
  private
44
88
 
89
+ def no_model
90
+ Rails.logger&.warn("[PermissionForge] #{@resource.name} names no model this app can load: no fields generated")
91
+ []
92
+ end
93
+
45
94
  def generate_fields(model_class)
46
95
  existing_names = @resource.resource_fields.pluck(:name)
47
- columns = model_class.column_names - ignored_fields + virtual_field_names(model_class) - existing_names
96
+ names = PermissionForge::ManagedFields.new(model_class).names
97
+ columns = self.class.without_blank_names(model_class, names) - existing_names
48
98
 
49
- columns.map do |column_name|
50
- attrs = build_field_attrs(column_name)
51
- apply_gdpr_defaults(attrs, column_name) if PermissionForge.configuration.gdpr_defaults
52
- PermissionForge::ResourceField.create!(attrs)
99
+ rows = RowBuilder.new(@resource, muting: muting?)
100
+ columns.filter_map do |column_name|
101
+ attrs = rows.attrs(column_name, level)
102
+ PermissionForge::ResourceField.create!(attrs.merge(resource: @resource)) if attrs
53
103
  end
54
104
  end
55
105
 
56
- def build_field_attrs(column_name)
57
- {
58
- resource: @resource,
59
- name: column_name,
60
- label: column_name.humanize,
61
- is_active: true,
62
- is_editable: true,
63
- read_access: @grant_all,
64
- edit_access: @grant_all
65
- }
106
+ def muting?
107
+ @resource.permission_set&.is_muting || false
66
108
  end
67
109
 
68
- def ignored_fields
69
- PermissionForge.configuration.ignored_fields
70
- end
71
-
72
- # Virtual fields get rows only under extra_fields_default_access =
73
- # :unconfigured. Under :grant ExtraFieldDefaults already gives declared
74
- # names read + edit, and a generated row (no access unless grant_all)
75
- # would take that away from callers that rely on it.
76
- def virtual_field_names(model_class)
77
- configuration = PermissionForge.configuration
78
- return [] if configuration.grant_extra_fields_by_default?
79
-
80
- self.class.without_blank_names(model_class, PermissionForge::VirtualFields.new(model_class, configuration).names)
81
- end
82
-
83
- def apply_gdpr_defaults(attrs, column_name)
84
- _pattern, strategy = PermissionForge.configuration.gdpr_field_patterns.find { |regex, _| column_name.match?(regex) }
85
- return unless strategy
86
-
87
- attrs[:read_access] = true
88
- attrs[:mask_access] = true
89
- attrs[:mask_strategy] = strategy
110
+ # The level generated rows get: full access or none; on a Muting
111
+ # Permission Set the level that takes everything away, or Unchanged,
112
+ # which the row builder writes no row for.
113
+ def level
114
+ if muting?
115
+ @grant_all ? 'hide' : 'leave'
116
+ else
117
+ @grant_all ? 'edit' : 'hidden'
118
+ end
90
119
  end
91
120
  end
92
121
  end
@@ -1,6 +1,7 @@
1
1
  module PermissionForge
2
2
  module Services
3
- # Creates a Resource with full CRUD access and its generated fields, all or nothing.
3
+ # Creates a Resource with full CRUD access and its generated fields (read and
4
+ # edit, unmasked: ResourceFieldGenerator with grant_all), all or nothing.
4
5
  #
5
6
  # @api public
6
7
  class ResourceWithFieldsBuilder