permission_forge 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (347) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2372 -0
  3. data/README.md +76 -25
  4. data/app/controllers/concerns/permission_forge/api/crud_actions.rb +3 -2
  5. data/app/controllers/concerns/permission_forge/api/input_shapes.rb +200 -0
  6. data/app/controllers/concerns/permission_forge/api/pagination.rb +12 -5
  7. data/app/controllers/concerns/permission_forge/api/response_renderer.rb +37 -1
  8. data/app/controllers/concerns/permission_forge/api_authentication.rb +13 -2
  9. data/app/controllers/concerns/permission_forge/cache_confirmation.rb +42 -11
  10. data/app/controllers/concerns/permission_forge/change_review.rb +42 -0
  11. data/app/controllers/concerns/permission_forge/copy_refusal.rb +39 -0
  12. data/app/controllers/concerns/permission_forge/param_shapes.rb +207 -7
  13. data/app/controllers/concerns/permission_forge/save_notice.rb +69 -0
  14. data/app/controllers/permission_forge/api/audit_events_controller.rb +1 -1
  15. data/app/controllers/permission_forge/api/base_controller.rb +163 -12
  16. data/app/controllers/permission_forge/api/dashboard_controller.rb +29 -4
  17. data/app/controllers/permission_forge/api/permission_set_group_users_controller.rb +5 -2
  18. data/app/controllers/permission_forge/api/permission_set_groups_controller.rb +5 -4
  19. data/app/controllers/permission_forge/api/permission_set_rules_controller.rb +7 -5
  20. data/app/controllers/permission_forge/api/permission_set_users_controller.rb +80 -19
  21. data/app/controllers/permission_forge/api/permission_sets_controller.rb +12 -6
  22. data/app/controllers/permission_forge/api/resource_actions_controller.rb +1 -1
  23. data/app/controllers/permission_forge/api/resource_fields_controller.rb +3 -4
  24. data/app/controllers/permission_forge/api/resources_controller.rb +26 -12
  25. data/app/controllers/permission_forge/api/unmatched_routes_controller.rb +20 -0
  26. data/app/controllers/permission_forge/application_controller.rb +39 -5
  27. data/app/controllers/permission_forge/assignments_controller.rb +36 -34
  28. data/app/controllers/permission_forge/bulk_assignments_controller.rb +8 -4
  29. data/app/controllers/permission_forge/field_lookups_controller.rb +1 -1
  30. data/app/controllers/permission_forge/maintenance_controller.rb +6 -2
  31. data/app/controllers/permission_forge/people_controller.rb +14 -14
  32. data/app/controllers/permission_forge/permission_set_editor.rb +20 -6
  33. data/app/controllers/permission_forge/permission_set_group_users_controller.rb +3 -1
  34. data/app/controllers/permission_forge/permission_set_groups_controller.rb +23 -9
  35. data/app/controllers/permission_forge/permission_set_rules_controller.rb +20 -10
  36. data/app/controllers/permission_forge/permission_sets_controller.rb +23 -24
  37. data/app/controllers/permission_forge/resource_actions_controller.rb +11 -6
  38. data/app/controllers/permission_forge/resource_fields_controller.rb +37 -19
  39. data/app/controllers/permission_forge/resources_controller.rb +12 -11
  40. data/app/frontend/permission_forge/pf_admin.css +99 -17
  41. data/app/frontend/permission_forge/pf_admin.js +36 -0
  42. data/app/helpers/permission_forge/application_helper.rb +22 -72
  43. data/app/helpers/permission_forge/audit_attribution_helper.rb +77 -0
  44. data/app/helpers/permission_forge/audit_helper.rb +57 -119
  45. data/app/helpers/permission_forge/audit_records_helper.rb +103 -0
  46. data/app/helpers/permission_forge/audit_sentences_helper.rb +56 -2
  47. data/app/helpers/permission_forge/badges_helper.rb +74 -17
  48. data/app/helpers/permission_forge/changes_helper.rb +24 -6
  49. data/app/helpers/permission_forge/conditions_helper.rb +119 -0
  50. data/app/helpers/permission_forge/labels_helper.rb +134 -0
  51. data/app/helpers/permission_forge/people_helper.rb +11 -19
  52. data/app/helpers/permission_forge/rule_problems_helper.rb +73 -0
  53. data/app/helpers/permission_forge/sentences_helper.rb +119 -98
  54. data/app/helpers/permission_forge/settings_helper.rb +2 -2
  55. data/app/helpers/permission_forge/ui_helper.rb +65 -4
  56. data/app/models/permission_forge/audit_event.rb +5 -1
  57. data/app/models/permission_forge/permission_set.rb +208 -18
  58. data/app/models/permission_forge/permission_set_group.rb +10 -4
  59. data/app/models/permission_forge/permission_set_group_user.rb +3 -1
  60. data/app/models/permission_forge/permission_set_rule.rb +254 -17
  61. data/app/models/permission_forge/permission_set_user.rb +38 -3
  62. data/app/models/permission_forge/resource.rb +18 -8
  63. data/app/models/permission_forge/resource_action.rb +15 -6
  64. data/app/models/permission_forge/resource_field.rb +97 -11
  65. data/app/views/permission_forge/assignments/confirm_destroy.html.erb +4 -4
  66. data/app/views/permission_forge/assignments/new.html.erb +10 -6
  67. data/app/views/permission_forge/audit_events/index.html.erb +1 -1
  68. data/app/views/permission_forge/audit_events/show.html.erb +16 -9
  69. data/app/views/permission_forge/bulk_assignments/confirm_revoke.html.erb +3 -3
  70. data/app/views/permission_forge/bulk_assignments/new.html.erb +3 -3
  71. data/app/views/permission_forge/field_lookups/show.html.erb +2 -2
  72. data/app/views/permission_forge/home/_broken_rules_card.html.erb +35 -0
  73. data/app/views/permission_forge/home/_conditions_card.html.erb +28 -0
  74. data/app/views/permission_forge/home/_ending_card.html.erb +11 -5
  75. data/app/views/permission_forge/home/_expired_card.html.erb +1 -1
  76. data/app/views/permission_forge/home/_mask_strategies_card.html.erb +27 -0
  77. data/app/views/permission_forge/home/_privacy_card.html.erb +21 -7
  78. data/app/views/permission_forge/home/_rules_card.html.erb +1 -1
  79. data/app/views/permission_forge/home/_secret_rules_card.html.erb +33 -0
  80. data/app/views/permission_forge/home/_stale_card.html.erb +7 -5
  81. data/app/views/permission_forge/home/_tidy_card.html.erb +1 -1
  82. data/app/views/permission_forge/home/index.html.erb +5 -6
  83. data/app/views/permission_forge/people/_access_tab.html.erb +26 -19
  84. data/app/views/permission_forge/people/_audit_tab.html.erb +8 -8
  85. data/app/views/permission_forge/people/_effective_field_source.html.erb +13 -7
  86. data/app/views/permission_forge/people/_effective_tab.html.erb +16 -15
  87. data/app/views/permission_forge/people/_effective_why.html.erb +20 -16
  88. data/app/views/permission_forge/people/_why_item.html.erb +1 -1
  89. data/app/views/permission_forge/people/confirm_remove_all.html.erb +3 -3
  90. data/app/views/permission_forge/people/erasure_preview.html.erb +1 -1
  91. data/app/views/permission_forge/permission_set_groups/confirm_destroy.html.erb +3 -3
  92. data/app/views/permission_forge/permission_set_groups/index.html.erb +3 -3
  93. data/app/views/permission_forge/permission_set_groups/show.html.erb +28 -19
  94. data/app/views/permission_forge/permission_set_rules/_builder.html.erb +35 -15
  95. data/app/views/permission_forge/permission_set_rules/_preview.html.erb +17 -0
  96. data/app/views/permission_forge/permission_set_rules/confirm_destroy.html.erb +3 -3
  97. data/app/views/permission_forge/permission_set_rules/preview.html.erb +12 -0
  98. data/app/views/permission_forge/permission_sets/_assignments_tab.html.erb +37 -22
  99. data/app/views/permission_forge/permission_sets/_audit_tab.html.erb +4 -4
  100. data/app/views/permission_forge/permission_sets/_condition_problems.html.erb +15 -0
  101. data/app/views/permission_forge/permission_sets/_conditions_tab.html.erb +14 -8
  102. data/app/views/permission_forge/permission_sets/_header.html.erb +6 -5
  103. data/app/views/permission_forge/permission_sets/_records_summary.html.erb +2 -2
  104. data/app/views/permission_forge/permission_sets/_resources_tab.html.erb +4 -4
  105. data/app/views/permission_forge/permission_sets/confirm_destroy.html.erb +4 -4
  106. data/app/views/permission_forge/permission_sets/confirm_switch_mode.html.erb +3 -3
  107. data/app/views/permission_forge/permission_sets/index.html.erb +6 -6
  108. data/app/views/permission_forge/permission_sets/new.html.erb +2 -2
  109. data/app/views/permission_forge/permission_sets/review.html.erb +13 -3
  110. data/app/views/permission_forge/permission_sets/show.html.erb +7 -5
  111. data/app/views/permission_forge/resource_actions/_across_sets.html.erb +7 -10
  112. data/app/views/permission_forge/resource_actions/_effect.html.erb +5 -5
  113. data/app/views/permission_forge/resource_actions/_form.html.erb +1 -1
  114. data/app/views/permission_forge/resource_actions/_managed_note.html.erb +5 -3
  115. data/app/views/permission_forge/resource_actions/confirm_destroy.html.erb +1 -1
  116. data/app/views/permission_forge/resource_actions/new.html.erb +1 -1
  117. data/app/views/permission_forge/resource_actions/show.html.erb +2 -2
  118. data/app/views/permission_forge/resource_fields/_explain.html.erb +19 -15
  119. data/app/views/permission_forge/resource_fields/show.html.erb +21 -12
  120. data/app/views/permission_forge/resources/_actions_tab.html.erb +4 -4
  121. data/app/views/permission_forge/resources/_fields_tab.html.erb +17 -9
  122. data/app/views/permission_forge/resources/_rules_tab.html.erb +9 -6
  123. data/app/views/permission_forge/resources/_sync.html.erb +8 -5
  124. data/app/views/permission_forge/resources/confirm_destroy.html.erb +3 -3
  125. data/app/views/permission_forge/resources/new.html.erb +3 -2
  126. data/app/views/permission_forge/resources/show.html.erb +6 -6
  127. data/app/views/permission_forge/settings/_row.html.erb +2 -2
  128. data/app/views/permission_forge/settings/show.html.erb +10 -1
  129. data/app/views/permission_forge/shared/_across_sets.html.erb +8 -9
  130. data/app/views/permission_forge/shared/_audit_events.html.erb +2 -2
  131. data/app/views/permission_forge/shared/_audit_footer.html.erb +13 -2
  132. data/app/views/permission_forge/shared/_crud_cell.html.erb +6 -6
  133. data/app/views/permission_forge/shared/_mode_banners.html.erb +1 -1
  134. data/app/views/permission_forge/shared/_savebar.html.erb +4 -2
  135. data/config/locales/permission_forge.en.yml +480 -69
  136. data/config/locales/permission_forge.mk.yml +499 -79
  137. data/config/locales/permission_forge.sq.yml +509 -80
  138. data/config/routes.rb +38 -23
  139. data/lib/generators/permission_forge/install/install_generator.rb +21 -0
  140. data/lib/generators/permission_forge/install/templates/initializer.rb.tt +35 -34
  141. data/lib/generators/permission_forge/install/templates/migration.rb.tt +5 -5
  142. data/lib/permission_forge/activation_conditions.rb +200 -0
  143. data/lib/permission_forge/admin/action_label.rb +33 -0
  144. data/lib/permission_forge/admin/action_rows.rb +3 -1
  145. data/lib/permission_forge/admin/actor_label.rb +52 -0
  146. data/lib/permission_forge/admin/attention_items.rb +152 -31
  147. data/lib/permission_forge/admin/audit_log.rb +4 -7
  148. data/lib/permission_forge/admin/audit_trail.rb +33 -17
  149. data/lib/permission_forge/admin/bulk_people_matcher.rb +32 -14
  150. data/lib/permission_forge/admin/chain_entries.rb +42 -0
  151. data/lib/permission_forge/admin/change_set.rb +12 -1
  152. data/lib/permission_forge/admin/change_set_parts/base.rb +5 -4
  153. data/lib/permission_forge/admin/change_set_parts/context.rb +3 -1
  154. data/lib/permission_forge/admin/change_set_parts/field_edit.rb +4 -1
  155. data/lib/permission_forge/admin/change_set_parts/field_levels.rb +1 -1
  156. data/lib/permission_forge/admin/change_set_parts/new_actions.rb +1 -1
  157. data/lib/permission_forge/admin/change_set_parts/new_fields.rb +38 -11
  158. data/lib/permission_forge/admin/change_set_parts/resource_edit.rb +17 -1
  159. data/lib/permission_forge/admin/change_set_parts/resource_flags.rb +1 -1
  160. data/lib/permission_forge/admin/change_set_parts/set_attrs.rb +4 -0
  161. data/lib/permission_forge/admin/change_set_parts/values.rb +10 -4
  162. data/lib/permission_forge/admin/column_fields.rb +22 -21
  163. data/lib/permission_forge/admin/conditions_form.rb +47 -15
  164. data/lib/permission_forge/admin/controller_catalog.rb +42 -14
  165. data/lib/permission_forge/admin/editable_lock.rb +83 -3
  166. data/lib/permission_forge/admin/effective_access.rb +147 -48
  167. data/lib/permission_forge/admin/effective_fields.rb +75 -67
  168. data/lib/permission_forge/admin/error_sentence.rb +47 -0
  169. data/lib/permission_forge/admin/expiry_presets.rb +17 -0
  170. data/lib/permission_forge/admin/exports/holdings.rb +6 -3
  171. data/lib/permission_forge/admin/field_across_sets.rb +7 -4
  172. data/lib/permission_forge/admin/field_chain.rb +109 -0
  173. data/lib/permission_forge/admin/field_detail.rb +24 -11
  174. data/lib/permission_forge/admin/field_label.rb +109 -0
  175. data/lib/permission_forge/admin/field_level.rb +4 -68
  176. data/lib/permission_forge/admin/field_provenance.rb +132 -0
  177. data/lib/permission_forge/admin/field_rows.rb +26 -11
  178. data/lib/permission_forge/admin/last_changes.rb +14 -0
  179. data/lib/permission_forge/admin/list_sentence.rb +32 -0
  180. data/lib/permission_forge/admin/mask_strategy_choices.rb +57 -0
  181. data/lib/permission_forge/admin/model_catalog.rb +83 -39
  182. data/lib/permission_forge/admin/model_label.rb +66 -0
  183. data/lib/permission_forge/admin/people_candidates.rb +21 -9
  184. data/lib/permission_forge/admin/people_directory.rb +23 -5
  185. data/lib/permission_forge/admin/permission_set_factory.rb +6 -15
  186. data/lib/permission_forge/admin/person_label.rb +105 -16
  187. data/lib/permission_forge/admin/prefill.rb +92 -0
  188. data/lib/permission_forge/admin/record_name.rb +45 -0
  189. data/lib/permission_forge/admin/rule_builder.rb +77 -3
  190. data/lib/permission_forge/admin/rule_form.rb +44 -10
  191. data/lib/permission_forge/admin/rule_form_input.rb +27 -11
  192. data/lib/permission_forge/admin/rule_notes.rb +43 -0
  193. data/lib/permission_forge/admin/rule_preview.rb +96 -0
  194. data/lib/permission_forge/admin/sensitive_columns.rb +10 -62
  195. data/lib/permission_forge/admin/settings_catalog.rb +64 -12
  196. data/lib/permission_forge/admin/state_label.rb +97 -0
  197. data/lib/permission_forge/admin_method_override.rb +44 -0
  198. data/lib/permission_forge/api/schema.rb +322 -0
  199. data/lib/permission_forge/api/serializers/base_serializer.rb +87 -1
  200. data/lib/permission_forge/api/serializers/permission_set_group_user_serializer.rb +1 -1
  201. data/lib/permission_forge/api/serializers/permission_set_rule_serializer.rb +1 -1
  202. data/lib/permission_forge/api/serializers/permission_set_serializer.rb +5 -2
  203. data/lib/permission_forge/api/serializers/permission_set_user_serializer.rb +1 -1
  204. data/lib/permission_forge/api/serializers/resource_action_serializer.rb +1 -1
  205. data/lib/permission_forge/api/serializers/resource_field_serializer.rb +1 -1
  206. data/lib/permission_forge/api/serializers/resource_serializer.rb +1 -1
  207. data/lib/permission_forge/api_authorizations.rb +59 -0
  208. data/lib/permission_forge/api_only_admin.rb +81 -0
  209. data/lib/permission_forge/audit/actor_codes.rb +32 -0
  210. data/lib/permission_forge/audit/actor_resolver.rb +1 -2
  211. data/lib/permission_forge/audit/boot_warnings.rb +29 -9
  212. data/lib/permission_forge/audit/event_catalog.rb +16 -6
  213. data/lib/permission_forge/audit/event_query.rb +3 -3
  214. data/lib/permission_forge/audit/reason_codes.rb +25 -0
  215. data/lib/permission_forge/audit/runtime.rb +9 -2
  216. data/lib/permission_forge/audit/settings_observer.rb +2 -2
  217. data/lib/permission_forge/audit/unmasked_without_user.rb +169 -0
  218. data/lib/permission_forge/audit.rb +24 -10
  219. data/lib/permission_forge/boot_checks.rb +180 -63
  220. data/lib/permission_forge/cache/base.rb +10 -0
  221. data/lib/permission_forge/cache/null_cache.rb +5 -0
  222. data/lib/permission_forge/cache/outcome.rb +119 -0
  223. data/lib/permission_forge/cache/rails_cache.rb +6 -0
  224. data/lib/permission_forge/cache/report.rb +150 -0
  225. data/lib/permission_forge/cache/user_keys.rb +8 -4
  226. data/lib/permission_forge/cache_warnings.rb +140 -0
  227. data/lib/permission_forge/concerns/auto_permission_scoping.rb +19 -2
  228. data/lib/permission_forge/concerns/has_tenant_scoping.rb +1 -1
  229. data/lib/permission_forge/concerns/has_uuid.rb +1 -1
  230. data/lib/permission_forge/concerns/keeps_copied_values.rb +48 -0
  231. data/lib/permission_forge/concerns/masked_attributes/alias_access.rb +12 -2
  232. data/lib/permission_forge/concerns/masked_attributes/virtual_readers.rb +6 -4
  233. data/lib/permission_forge/concerns/masked_attributes.rb +150 -34
  234. data/lib/permission_forge/concerns/normalizes_label.rb +78 -0
  235. data/lib/permission_forge/concerns/permission_cache_accessor.rb +3 -3
  236. data/lib/permission_forge/concerns/permission_scoped_findable.rb +121 -29
  237. data/lib/permission_forge/concerns/permissioned_user.rb +1 -1
  238. data/lib/permission_forge/concerns/records_audit_events.rb +13 -6
  239. data/lib/permission_forge/concerns/requires_name.rb +37 -0
  240. data/lib/permission_forge/concerns/validates_column_lengths.rb +24 -0
  241. data/lib/permission_forge/concerns/validates_dependency.rb +5 -2
  242. data/lib/permission_forge/concerns/validates_required_columns.rb +74 -0
  243. data/lib/permission_forge/conditional_sets.rb +13 -6
  244. data/lib/permission_forge/configuration/admin_ui_settings.rb +18 -4
  245. data/lib/permission_forge/configuration/audit_settings.rb +2 -1
  246. data/lib/permission_forge/configuration/controller_action_settings.rb +5 -4
  247. data/lib/permission_forge/configuration/masked_attributes_settings.rb +73 -19
  248. data/lib/permission_forge/configuration/presets.rb +20 -11
  249. data/lib/permission_forge/configuration/query_scoping_settings.rb +55 -0
  250. data/lib/permission_forge/configuration/strategy_values.rb +2 -1
  251. data/lib/permission_forge/configuration.rb +41 -18
  252. data/lib/permission_forge/controller_actions/managed_index.rb +8 -7
  253. data/lib/permission_forge/database_reach.rb +93 -0
  254. data/lib/permission_forge/deprecation.rb +1 -1
  255. data/lib/permission_forge/docs/api_samples.rb +250 -0
  256. data/lib/permission_forge/docs/configuration_reference.rb +172 -0
  257. data/lib/permission_forge/encrypted_attributes.rb +150 -0
  258. data/lib/permission_forge/engine.rb +46 -14
  259. data/lib/permission_forge/execution_state/restore.rb +74 -0
  260. data/lib/permission_forge/execution_state.rb +8 -1
  261. data/lib/permission_forge/field_level.rb +123 -0
  262. data/lib/permission_forge/field_names.rb +145 -0
  263. data/lib/permission_forge/field_variants.rb +96 -0
  264. data/lib/permission_forge/generated_text.rb +95 -0
  265. data/lib/permission_forge/inspection_filters.rb +83 -0
  266. data/lib/permission_forge/managed_fields.rb +66 -0
  267. data/lib/permission_forge/masked_models.rb +25 -19
  268. data/lib/permission_forge/masking_sync.rb +100 -25
  269. data/lib/permission_forge/model_bindings.rb +30 -19
  270. data/lib/permission_forge/model_lookup.rb +42 -0
  271. data/lib/permission_forge/operators/encrypted_alias.rb +67 -0
  272. data/lib/permission_forge/operators/registry.rb +6 -0
  273. data/lib/permission_forge/operators/rule_column.rb +242 -27
  274. data/lib/permission_forge/operators/rule_path.rb +179 -0
  275. data/lib/permission_forge/operators/rule_problem.rb +309 -0
  276. data/lib/permission_forge/operators/rule_sources.rb +96 -0
  277. data/lib/permission_forge/operators/value_casters/postgresql_types.rb +237 -0
  278. data/lib/permission_forge/operators/value_casters/temporal.rb +9 -20
  279. data/lib/permission_forge/operators/value_casters.rb +61 -13
  280. data/lib/permission_forge/operators/value_typing.rb +83 -7
  281. data/lib/permission_forge/query_scoping_warnings.rb +22 -17
  282. data/lib/permission_forge/rake_audit.rb +5 -4
  283. data/lib/permission_forge/rake_dry_run.rb +32 -0
  284. data/lib/permission_forge/rake_output.rb +189 -0
  285. data/lib/permission_forge/record_read_access.rb +51 -0
  286. data/lib/permission_forge/resource_chain.rb +14 -2
  287. data/lib/permission_forge/schema_read/ordered_types.rb +72 -0
  288. data/lib/permission_forge/schema_read.rb +335 -0
  289. data/lib/permission_forge/secret_columns.rb +49 -0
  290. data/lib/permission_forge/sensitive_columns.rb +114 -0
  291. data/lib/permission_forge/services/attribute_access_guard.rb +22 -14
  292. data/lib/permission_forge/services/bulk_permission_set_assignment.rb +16 -8
  293. data/lib/permission_forge/services/concerns/clonable.rb +9 -0
  294. data/lib/permission_forge/services/concerns/settles_cache.rb +58 -0
  295. data/lib/permission_forge/services/consent_withdrawal_handler.rb +37 -19
  296. data/lib/permission_forge/services/data_subject_erasure.rb +83 -45
  297. data/lib/permission_forge/services/expired_assignment_cleaner.rb +62 -25
  298. data/lib/permission_forge/services/extra_field_defaults.rb +1 -1
  299. data/lib/permission_forge/services/field_permission_merger.rb +44 -17
  300. data/lib/permission_forge/services/filters_response_attributes.rb +20 -5
  301. data/lib/permission_forge/services/gdpr_field_auditor/text_report.rb +33 -5
  302. data/lib/permission_forge/services/gdpr_field_auditor.rb +68 -48
  303. data/lib/permission_forge/services/host_key_guard.rb +8 -5
  304. data/lib/permission_forge/services/mask_value.rb +72 -11
  305. data/lib/permission_forge/services/permission_cache_clearer.rb +36 -15
  306. data/lib/permission_forge/services/permission_hash_builder.rb +48 -19
  307. data/lib/permission_forge/services/permission_rule_applier.rb +131 -68
  308. data/lib/permission_forge/services/permission_set_cloner.rb +32 -8
  309. data/lib/permission_forge/services/permission_set_condition_evaluator.rb +38 -36
  310. data/lib/permission_forge/services/permission_set_group_cloner.rb +2 -1
  311. data/lib/permission_forge/services/permits_attributes/result.rb +31 -0
  312. data/lib/permission_forge/services/permits_attributes.rb +48 -27
  313. data/lib/permission_forge/services/resource_field_generator/row_builder.rb +96 -0
  314. data/lib/permission_forge/services/resource_field_generator.rb +71 -42
  315. data/lib/permission_forge/services/resource_with_fields_builder.rb +2 -1
  316. data/lib/permission_forge/services/rls_operator_sql.rb +83 -24
  317. data/lib/permission_forge/services/rls_policy_generator.rb +83 -39
  318. data/lib/permission_forge/services/rls_read_access_sql.rb +131 -0
  319. data/lib/permission_forge/services/security_audit/action_check.rb +49 -5
  320. data/lib/permission_forge/services/security_audit/assignment_check.rb +2 -1
  321. data/lib/permission_forge/services/security_audit/audit_trail_check.rb +1 -3
  322. data/lib/permission_forge/services/security_audit/conditions_check.rb +92 -0
  323. data/lib/permission_forge/services/security_audit/config_check.rb +41 -18
  324. data/lib/permission_forge/services/security_audit/integrity_check.rb +71 -4
  325. data/lib/permission_forge/services/security_audit/masking_without_user.rb +66 -0
  326. data/lib/permission_forge/services/security_audit/personal_data_check.rb +7 -11
  327. data/lib/permission_forge/services/security_audit/rule_check.rb +180 -50
  328. data/lib/permission_forge/services/security_audit/text_report.rb +14 -2
  329. data/lib/permission_forge/services/security_audit/unmanaged_actions.rb +145 -0
  330. data/lib/permission_forge/services/security_audit/user_model_check.rb +4 -3
  331. data/lib/permission_forge/services/security_audit.rb +16 -4
  332. data/lib/permission_forge/services/stale_assignment_auditor.rb +8 -5
  333. data/lib/permission_forge/timestamp_fields.rb +60 -0
  334. data/lib/permission_forge/unique_conflict.rb +117 -0
  335. data/lib/permission_forge/version.rb +1 -1
  336. data/lib/permission_forge/virtual_fields/discovery.rb +2 -1
  337. data/lib/permission_forge/virtual_fields.rb +15 -5
  338. data/lib/permission_forge/warnings.rb +263 -0
  339. data/lib/permission_forge/web_request.rb +248 -0
  340. data/lib/permission_forge.rb +54 -0
  341. data/lib/tasks/permission_forge.rake +26 -6
  342. data/lib/tasks/permission_forge_audit_events.rake +8 -4
  343. data/lib/tasks/permission_forge_cache.rake +12 -12
  344. data/lib/tasks/permission_forge_docs.rake +48 -0
  345. data/lib/tasks/permission_forge_expiration.rake +11 -5
  346. data/lib/tasks/permission_forge_gdpr.rake +36 -20
  347. metadata +84 -1
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # The field entry tables a resource's fields are composed from, its own
6
+ # first, then those of the single-table-inheritance bases the person has
7
+ # an entry for — composed as ResourceChain.compose_field composes them
8
+ # for the attribute guard: one entry decides alone; among several, one
9
+ # that does not grant read hides every configured field, else the
10
+ # strictest field entry decides (the first in chain order at its level;
11
+ # at Hidden or Read-only, one no mute brought there first). A muting set
12
+ # is credited with hiding the field, or taking its editing away, only
13
+ # when the granting sets alone, composed the same way, would show it,
14
+ # or let it be edited (review R4 P1-2).
15
+ # Admin::EffectiveFields builds it; FieldProvenance words the entry that
16
+ # decides.
17
+ #
18
+ # @example
19
+ # chain = FieldChain.new([FieldChain::Table.new(sources, entries, nil)])
20
+ # chain.attributes('email', virtual: false, editable: true) # => { level: 'masked', ... } or nil
21
+ class FieldChain
22
+ # One entry table (`fields`: name => Admin::EffectiveFields entry): the
23
+ # resource's own (decided_by nil) or a base's (decided_by: the base
24
+ # Resource's name).
25
+ Table = Struct.new(:sources, :fields, :decided_by) do
26
+ def readable? = sources.flags[:read_access].granted?
27
+ # Whether a granting set grants read, muted later or not.
28
+ def granted_readable? = sources.flags[:read_access].granted_by.any?
29
+ def rank_of(name) = PermissionForge::FieldLevel.rank(PermissionForge::FieldLevel.effective_level(fields[name]))
30
+ def provenance(name) = FieldProvenance.new(fields[name], decided_by: decided_by)
31
+ # The field's flags as the granting sets alone left them.
32
+ def granted(name) = fields[name][:before] || fields[name]
33
+ # Whether a muting set brought the field to its level.
34
+ def muted?(name) = provenance(name).mutes.any?
35
+
36
+ # A field hidden because this entry does not grant read (ResourceChain::HIDDEN_ENTRY).
37
+ def hidden_attributes = FieldProvenance.hidden_by(sources.flags[:read_access].muted_on, decided_by: decided_by)
38
+ end
39
+
40
+ # @param tables [Array<Table>] the resource's first, then its bases' nearest first
41
+ def initialize(tables)
42
+ @tables = tables
43
+ end
44
+
45
+ # Every field name an entry along the chain has.
46
+ #
47
+ # @return [Array<String>]
48
+ def names = @tables.flat_map { |table| table.fields.keys }
49
+
50
+ # The FieldOutcome attributes but its name, from the entry that decides;
51
+ # nil when no entry configures the field.
52
+ #
53
+ # @return [Hash, nil]
54
+ def attributes(name, virtual:, editable:)
55
+ configured = @tables.select { |table| table.fields.key?(name) }
56
+ return if configured.empty?
57
+ return chain_mutes(hiding.hidden_attributes.merge(virtual: virtual), configured, name) if hiding
58
+
59
+ chain_mutes(deciding(configured, name).provenance(name).attributes(virtual: virtual, editable: editable), configured, name)
60
+ end
61
+
62
+ # Mutes that hide the field, or take its editing away, which the
63
+ # granting sets alone (composed along the chain) never showed, or never
64
+ # let be edited, are not credited. One entry: FieldProvenance did it.
65
+ CHAIN_MUTE_EFFECTS = {
66
+ 'hide' => ->(granted) { granted[:read_access] == true },
67
+ 'no_edit' => ->(granted) { granted[:edit_access] == true }
68
+ }.freeze
69
+ private_constant :CHAIN_MUTE_EFFECTS
70
+
71
+ private
72
+
73
+ def chain_mutes(attributes, configured, name)
74
+ return attributes if @tables.size <= 1
75
+
76
+ granted = granted_composition(configured, name)
77
+ mutes = attributes[:mutes].select { |mute| CHAIN_MUTE_EFFECTS.fetch(mute.level, ->(_) { true }).call(granted) }
78
+ attributes.merge(mutes: mutes, blocked_by: mutes.map(&:permission_set), blocked_level: mutes.first&.level)
79
+ end
80
+
81
+ # The field as the granting sets alone compose it (ResourceChain.compose_field).
82
+ def granted_composition(configured, name)
83
+ return ResourceChain::HIDDEN_ENTRY unless @tables.all?(&:granted_readable?)
84
+
85
+ ResourceChain.strictest(configured.map { |table| table.granted(name) })
86
+ end
87
+
88
+ # Among several entries, the first that does not grant read.
89
+ def hiding
90
+ return @hiding if defined?(@hiding)
91
+
92
+ @hiding = (@tables.find { |table| !table.readable? } if @tables.size > 1)
93
+ end
94
+
95
+ # The strictest entry; at one level the first in chain order, but at
96
+ # Hidden or Read-only one no mute brought there first (the masked
97
+ # strategy is the first masking entry's, as the engine takes it).
98
+ def deciding(configured, name)
99
+ configured.each_with_index.min_by do |table, index|
100
+ rank = table.rank_of(name)
101
+ [rank, rank == MASKED_RANK || !table.muted?(name) ? 0 : 1, index]
102
+ end.first
103
+ end
104
+
105
+ MASKED_RANK = PermissionForge::FieldLevel.rank('masked')
106
+ private_constant :MASKED_RANK
107
+ end
108
+ end
109
+ end
@@ -49,15 +49,26 @@ module PermissionForge
49
49
  # Locked when the field, its Resource or its Permission Set is (is_editable: false).
50
50
  def locked? = !EditableLock.locked_record(field, resource).nil?
51
51
 
52
- # The mask strategy the select starts on: the saved one, else the
52
+ # The mask strategy the select starts on: the saved one — also one this
53
+ # app does not know (unknown_strategy?), which stays as stored unless
54
+ # the admin picks another and is masked fully meanwhile — else the
53
55
  # recommended one for a sensitive column, else 'full'.
54
56
  def strategy
55
- field&.mask_strategy || row.match&.strategy || DEFAULT_STRATEGY
57
+ field&.mask_strategy.presence || row.match&.strategy || DEFAULT_STRATEGY
56
58
  end
57
59
 
58
- # Other columns that follow this one (`_id` → `_uuid`, `_cents` → base name).
60
+ # The choices of the strategy select (MaskStrategyChoices): the saved
61
+ # one this app does not know is offered as stored, so saving the drawer
62
+ # with another change never rewrites it.
63
+ def strategy_choices = @strategy_choices ||= MaskStrategyChoices.new(field)
64
+
65
+ # The saved strategy is none this app knows: masked fully until fixed.
66
+ def unknown_strategy? = strategy_choices.unknown?
67
+
68
+ # Other names of the model that follow this one (`_id` → `_uuid`,
69
+ # `_cents` → base name), as the engine applies them (FieldVariants).
59
70
  def variants
60
- @variants ||= @configuration.field_variants_for(key)
71
+ @variants ||= FieldVariants.new(resource.name, @configuration).call(key)
61
72
  end
62
73
 
63
74
  # The edit level needs edit access on the Resource, except in a muting set.
@@ -67,19 +78,21 @@ module PermissionForge
67
78
 
68
79
  # The note under the level control while no setting is in force, as the
69
80
  # suffix of a locale key under `resource_fields.show` — or nil.
81
+ # In a Muting Permission Set a field without a setting takes nothing
82
+ # away (no strategy applies), so its note says that instead.
70
83
  def unconfigured_note
71
84
  return nil if row.in_force?
72
85
 
73
- implicit = row.implicit_grant
74
- if row.configured?
75
- implicit ? :turned_off_implicit : :turned_off
76
- else
77
- implicit ? :not_configured_implicit : :not_configured
78
- end
86
+ base = row.configured? ? :turned_off : :not_configured
87
+ return :"#{base}_muting" if muting?
88
+
89
+ row.implicit_grant ? :"#{base}_implicit" : base
79
90
  end
80
91
 
81
- # Setting the note links to.
92
+ # Setting the note links to; nil in a Muting Permission Set (no setting decides it).
82
93
  def unconfigured_setting
94
+ return nil if muting?
95
+
83
96
  row.implicit_grant ? 'extra_fields_default_access' : 'unconfigured_field_strategy'
84
97
  end
85
98
 
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # The one definition of the label the admin shows for a stored field,
6
+ # column or association name: the host application's own translation
7
+ # when it has one (`activerecord.attributes.<model>.<name>`, read
8
+ # through `human_attribute_name` with a sentinel default), else the label
9
+ # an admin wrote on the ResourceField row — a label the gem generated
10
+ # (PermissionForge::GeneratedText.label?: blank, the name, the name
11
+ # humanized, 'Untitled Field') is no label — else, on an English page,
12
+ # Rails' own English for the name (`human_attribute_name`: "Created at",
13
+ # what an English host saw before 0.15; #english), else the name
14
+ # verbatim (`verbatim?`: the view draws it in <code>; an admin looks for
15
+ # the stored name). Rails' English is never shown on a page in another
16
+ # language (release_readiness/inflection_guard_test.rb). A column with
17
+ # no row and one with a generated label read the same (field test round
18
+ # 7, B#26).
19
+ #
20
+ # @example
21
+ # FieldLabel.for(Customer, 'full_address').text # => "Adresa" (host translation); on sq "full_address", on en "Full address"
22
+ # FieldLabel.for('Customer', 'email', row: field).text # => the label an admin wrote, when the host has no translation
23
+ #
24
+ # @api private
25
+ class FieldLabel
26
+ # What `human_attribute_name` / `model_name.human` answer when the host
27
+ # has no translation (their `default:`): a value never shown.
28
+ MISSING = "\u0000permission_forge:missing\u0000"
29
+
30
+ # A label and whether it is the stored name itself.
31
+ Label = Data.define(:text, :verbatim) do
32
+ def verbatim? = verbatim
33
+ def to_s = text
34
+ end
35
+
36
+ class << self
37
+ # @param model [Class, ModelCatalog::ModelInfo, String, nil] the model the name belongs to
38
+ # @param name [String, Symbol] the stored name
39
+ # @param row [#label, nil] the ResourceField (anything with a `label`) for the name
40
+ # @return [Label]
41
+ def for(model, name, row: nil)
42
+ name = name.to_s
43
+ translated = host_translation(model, name)
44
+ return Label.new(text: translated, verbatim: false) if translated
45
+
46
+ label = written_label(row, name)
47
+ return Label.new(text: label, verbatim: false) if label
48
+
49
+ fallback(klass_of(model), name)
50
+ end
51
+
52
+ # The label an admin wrote on `row`, or nil for none — a label the
53
+ # gem generated (GeneratedText.label?) is none.
54
+ #
55
+ # @return [String, nil]
56
+ def written_label(row, name)
57
+ label = row.respond_to?(:label) ? row.label.to_s.strip : ''
58
+ label unless PermissionForge::GeneratedText.label?(label, name)
59
+ end
60
+
61
+ # A name with no translation and no label of the admin's: Rails' own
62
+ # English on an English page (#english), the name verbatim elsewhere
63
+ # — and wherever the model cannot be loaded (the stored name is what
64
+ # the admin looks for).
65
+ #
66
+ # @param klass [Class, nil] the model the name belongs to, when known
67
+ # @return [Label]
68
+ def fallback(klass, name)
69
+ text = english(name) { klass.human_attribute_name(name) if klass.respond_to?(:human_attribute_name) }
70
+ text ? Label.new(text: text, verbatim: false) : Label.new(text: name.to_s, verbatim: true)
71
+ end
72
+
73
+ # Rails' English for a name, only while the page is English: what
74
+ # the block answers (a model's `human_attribute_name`: "Created at"),
75
+ # or without a block Rails' humanization of the name (an action key,
76
+ # which belongs to no attribute: "Export csv"). nil on any other
77
+ # page, and for a blank name.
78
+ #
79
+ # @return [String, nil]
80
+ def english(name)
81
+ return nil if name.blank? || !I18n.locale.to_s.start_with?('en')
82
+
83
+ block_given? ? yield : ActiveSupport::Inflector.humanize(name)
84
+ end
85
+
86
+ # The host's translation of `name` on `model`, or nil. An empty name
87
+ # (a doubled dot in a path) is no attribute: never asked.
88
+ #
89
+ # @return [String, nil]
90
+ def host_translation(model, name)
91
+ klass = klass_of(model)
92
+ return nil if name.blank? || !klass.respond_to?(:human_attribute_name)
93
+
94
+ value = klass.human_attribute_name(name, default: MISSING)
95
+ value == MISSING ? nil : value
96
+ end
97
+
98
+ # The model class behind a Class, a ModelCatalog::ModelInfo or a
99
+ # stored Resource name (validated by Resource::NAME_FORMAT).
100
+ def klass_of(model)
101
+ return model.klass if model.respond_to?(:klass)
102
+ return PermissionForge::ModelLookup.model_for(model) if model.is_a?(String) || model.is_a?(Symbol)
103
+
104
+ model
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -2,73 +2,9 @@
2
2
 
3
3
  module PermissionForge
4
4
  module Admin
5
- # Maps the admin UI's single "access level" choice to ResourceField flags
6
- # and back. Granting sets offer Hidden / Masked / Read-only / Editable;
7
- # muting sets offer Unchanged / Force mask / Hide, where the same flags mean
8
- # "remove" (see PermissionHashBuilder#mute_single_field).
9
- module FieldLevel
10
- GRANTING = {
11
- 'hidden' => { read_access: false, edit_access: false, mask_access: false },
12
- 'masked' => { read_access: true, edit_access: false, mask_access: true },
13
- 'read' => { read_access: true, edit_access: false, mask_access: false },
14
- 'edit' => { read_access: true, edit_access: true, mask_access: false }
15
- }.freeze
16
-
17
- MUTING = {
18
- 'leave' => { read_access: false, edit_access: false, mask_access: false },
19
- 'mask' => { read_access: false, edit_access: false, mask_access: true },
20
- 'hide' => { read_access: true, edit_access: true, mask_access: false }
21
- }.freeze
22
-
23
- # Ordered from most closed to most open (granting) — used to rank levels.
24
- GRANTING_ORDER = %w[hidden masked read edit].freeze
25
-
26
- module_function
27
-
28
- def levels_for(muting:)
29
- muting ? MUTING.keys : GRANTING.keys
30
- end
31
-
32
- def flags_for(level, muting:)
33
- (muting ? MUTING : GRANTING).fetch(level.to_s) { raise ArgumentError, "Unknown field level: #{level.inspect}" }
34
- end
35
-
36
- def valid?(level, muting:)
37
- (muting ? MUTING : GRANTING).key?(level.to_s)
38
- end
39
-
40
- # Derives the level from a field's flags. Flag combinations the UI never
41
- # writes (e.g. edit + mask) collapse to the closest level.
42
- def level_of(field, muting:)
43
- read = field.read_access
44
- edit = field.edit_access
45
- mask = field.mask_access
46
- return muting_level(read, edit, mask) if muting
47
-
48
- return 'hidden' unless read
49
- return 'masked' if mask
50
-
51
- edit ? 'edit' : 'read'
52
- end
53
-
54
- def muting_level(read, edit, mask)
55
- return 'hide' if read || edit
56
-
57
- mask ? 'mask' : 'leave'
58
- end
59
-
60
- # Level an engine field-permission entry resolves to
61
- # ({ read_access:, edit_access:, mask_access: } from the permissions hash).
62
- def effective_level(entry)
63
- return 'hidden' if entry.nil? || !entry[:read_access]
64
- return 'masked' if entry[:mask_access]
65
-
66
- entry[:edit_access] ? 'edit' : 'read'
67
- end
68
-
69
- def rank(level)
70
- GRANTING_ORDER.index(level.to_s) || -1
71
- end
72
- end
5
+ # The level tables moved to PermissionForge::FieldLevel in 0.15.0 (the
6
+ # row builder in lib/ reads them too). Internal API: the old name is
7
+ # the same module, kept for the views and tests that spell it out.
8
+ FieldLevel = PermissionForge::FieldLevel
73
9
  end
74
10
  end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # The outcome of one effective field entry, and which Permission Sets it
6
+ # credits (EX: the explanation comes from the entry that decides, never a
7
+ # second walk).
8
+ # The entry is Admin::EffectiveFields': the engine's own flags
9
+ # (FieldPermissionMerger.merge_row, then the muting phase), plus
10
+ # `sources` ([set, row level, row strategy, Resource] in merge order),
11
+ # `mutes` ([set, level, Resource] in muting order) and `before` (the
12
+ # flags before muting). Each FieldSource names the Resource whose row
13
+ # it credits (B#38).
14
+ #
15
+ # - grants: the sets that make the field readable, one each at its most
16
+ # open level, most open first and, at one level, in merge order — the
17
+ # first one is the entry's level and, masked, the row whose strategy
18
+ # the entry took. A Hidden row grants nothing and has no say in the
19
+ # mask; an implicit (virtual-field) entry credits no set.
20
+ # - mutes: the muting sets that changed the entry (MUTE_EFFECTS), each at
21
+ # the strongest level it is credited with, the strongest first — the
22
+ # first one is the level the entry shows (hide → Hidden, mask → Masked,
23
+ # no_edit → Read-only). A mute that changed nothing (a field nobody
24
+ # shows, editing nobody gave) is not listed.
25
+ #
26
+ # @example
27
+ # FieldProvenance.new(entry, decided_by: nil).attributes(virtual: false, editable: true)
28
+ # # => { level: 'masked', capped: false, mask_strategy: 'email', grants: [FieldSource], mutes: [], ... }
29
+ class FieldProvenance
30
+ FieldSource = EffectiveAccess::FieldSource
31
+
32
+ # What a muting set does to a field, strongest first (FieldSource#level).
33
+ MUTING_ORDER = %w[hide mask no_edit].freeze
34
+ # Whether a muting level changed the entry: (flags before muting, entry
35
+ # after) => true. Hide took a readable field away; mask leaves it
36
+ # masked; no_edit took editing away and left it readable.
37
+ MUTE_EFFECTS = {
38
+ 'hide' => ->(before, after) { before[:read_access] == true && !after[:read_access] },
39
+ 'mask' => ->(before, after) { before[:mask_access] != true && PermissionForge::FieldLevel.effective_level(after) == 'masked' },
40
+ 'no_edit' => ->(before, after) { before[:edit_access] == true && after[:read_access] == true && !after[:edit_access] }
41
+ }.freeze
42
+
43
+ # The attributes of a field hidden because an entry does not grant
44
+ # read (ResourceChain::HIDDEN_ENTRY): no set grants it; the muting sets
45
+ # that took that entry's read away hide it.
46
+ #
47
+ # @param muted_on [Hash{PermissionForge::PermissionSet => String}] each
48
+ # muting set => the Resource whose row takes the read away
49
+ # (EffectiveAccess::Outcome#muted_on)
50
+ # @param decided_by [String, nil] the entry's Resource, for a base's
51
+ # @return [Hash] EffectiveAccess::FieldOutcome attributes but name and virtual
52
+ def self.hidden_by(muted_on, decided_by:)
53
+ mutes = muted_on.map { |set, resource_name| FieldSource.new(permission_set: set, level: 'hide', resource_name: resource_name) }
54
+ { level: 'hidden', capped: false, unconfigured: false, implicit: false, unreadable: true, decided_by: decided_by,
55
+ mask_strategy: ResourceChain::HIDDEN_ENTRY[:mask_strategy], granted_by: [], grants: [], blocked_by: muted_on.keys,
56
+ mutes: mutes, blocked_level: mutes.first&.level }
57
+ end
58
+
59
+ # @param entry [Hash] an Admin::EffectiveFields entry
60
+ # @param decided_by [String, nil] the base Resource whose entry it is
61
+ # (single-table inheritance), nil for the resource's own
62
+ def initialize(entry, decided_by: nil)
63
+ @entry = entry
64
+ @decided_by = decided_by
65
+ end
66
+
67
+ # The entry's outcome and the sets it credits, as EffectiveAccess::FieldOutcome takes them.
68
+ #
69
+ # @param virtual [Boolean] the model declares the field (not a column)
70
+ # @param editable [Boolean] the resource grants Edit: an editable field
71
+ # is capped to Read-only without it, and a mute that takes its
72
+ # editing away changes nothing then (not credited; review of round 8, D3)
73
+ # @return [Hash] every FieldOutcome attribute but name and unconfigured
74
+ def attributes(virtual:, editable:)
75
+ granting = grants
76
+ muting = editable ? mutes : mutes.reject { |source| source.level == 'no_edit' }
77
+ { virtual: virtual || implicit?, implicit: implicit?, mask_strategy: @entry[:mask_strategy],
78
+ variant_of: @entry[:variant_of], decided_by: @decided_by, unreadable: false, **level(editable),
79
+ granted_by: granting.map(&:permission_set), grants: granting, blocked_by: muting.map(&:permission_set),
80
+ mutes: muting, blocked_level: muting.first&.level }
81
+ end
82
+
83
+ # An implicit (virtual-field) entry: no set configured the field.
84
+ def implicit? = @entry[:extra].present?
85
+
86
+ # @return [Array<EffectiveAccess::FieldSource>]
87
+ def grants
88
+ return [] if implicit?
89
+
90
+ rows = @entry[:sources].each_with_index.select { |(_, level), _| FieldLevel.rank(level) >= 1 }
91
+ rows.sort_by { |(_, level), index| [-FieldLevel.rank(level), index] }.map(&:first).uniq(&:first)
92
+ .map { |set, level, strategy, name| FieldSource.new(permission_set: set, level: level, mask_strategy: strategy, resource_name: name) }
93
+ end
94
+
95
+ # @return [Array<EffectiveAccess::FieldSource>]
96
+ def mutes
97
+ sources = credited_mutes.group_by(&:first).map { |set, rows| strongest(set, rows) }
98
+ sources.each_with_index.sort_by { |source, index| [MUTING_ORDER.index(source.level), index] }.map(&:first)
99
+ end
100
+
101
+ private
102
+
103
+ # [set, level, Resource] for each muting row or resource that changed the entry.
104
+ def credited_mutes
105
+ (@entry[:mutes] || []).select { |_, level| changed?(level) }
106
+ end
107
+
108
+ # The set at its strongest level, through its first row at that level.
109
+ def strongest(permission_set, rows)
110
+ _, level, resource_name = MUTING_ORDER.lazy.filter_map { |name| rows.find { |_, row_level| row_level == name } }.first
111
+ FieldSource.new(permission_set: permission_set, level: level, resource_name: resource_name)
112
+ end
113
+
114
+ # The entry's level (FieldLevel.effective_level); Edit is capped to
115
+ # Read-only when the resource does not grant Edit. Then a mute that
116
+ # took the field's editing away changed nothing (D3): the field is
117
+ # capped as the granting sets left its editing.
118
+ def level(editable)
119
+ level = FieldLevel.effective_level(editable ? @entry : @entry.merge(edit_access: granted_edit?))
120
+ capped = level == 'edit' && !editable
121
+ { level: capped ? 'read' : level, capped: capped }
122
+ end
123
+
124
+ # Whether the field is readable and the granting sets let it be edited.
125
+ def granted_edit? = @entry[:read_access] == true && (@entry[:before] || @entry)[:edit_access] == true
126
+
127
+ def changed?(level)
128
+ MUTE_EFFECTS.fetch(level).call(@entry[:before] || {}, @entry)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -4,7 +4,10 @@ module PermissionForge
4
4
  module Admin
5
5
  # One row per field on a Resource page: configured ResourceFields, model
6
6
  # columns nobody configured yet, and virtual fields — with the flags the
7
- # UI needs (sensitive, virtual, column missing, level).
7
+ # UI needs (sensitive, virtual, column missing, level). A row's label is
8
+ # Admin::FieldLabel's, the same for a configured row and a column with
9
+ # none (field test round 7, B#26): the host's translation, else the
10
+ # admin's label, else Rails' English on an English page, else the name.
8
11
  class FieldRows
9
12
  # `declared` — a virtual field the model lists in extra_fields_method
10
13
  # (a virtual row that is not declared was discovered). `implicit_grant` —
@@ -15,7 +18,12 @@ module PermissionForge
15
18
  keyword_init: true) do
16
19
  def configured? = !field.nil?
17
20
  def sensitive? = !match.nil?
18
- def locked? = field && field.is_editable == false
21
+ # The label is the name itself (FieldLabel: no translation, no label
22
+ # of the admin's, a page that is not English): the grid shows the
23
+ # name once, in <code>.
24
+ def verbatim? = label == name
25
+ # Locked when the row, its Resource or its Permission Set is (EditableLock).
26
+ def locked? = !field.nil? && EditableLock.locked?(field)
19
27
  def active? = field.nil? || field.is_active
20
28
 
21
29
  # A setting the engine applies: it exists and is turned on. A row that
@@ -25,7 +33,10 @@ module PermissionForge
25
33
  end
26
34
 
27
35
  GRANTING_FILTERS = %w[all hidden masked read edit sensitive unconfigured].freeze
28
- MUTING_FILTERS = %w[all leave mask hide sensitive unconfigured].freeze
36
+ # No "Not configured" here: in a Muting Permission Set a column with no
37
+ # setting mutes nothing, which is what Unchanged means — one state, one
38
+ # name (its row starts on Unchanged, and saving it creates no row).
39
+ MUTING_FILTERS = %w[all leave mask hide sensitive].freeze
29
40
 
30
41
  attr_reader :resource, :model
31
42
 
@@ -78,6 +89,13 @@ module PermissionForge
78
89
  new_columns.any? || missing_fields.any?
79
90
  end
80
91
 
92
+ # Whether the sync panel is a warning. A column with no setting in a
93
+ # Muting Permission Set takes nothing away — the normal state, not a
94
+ # gap — so there only a configured field whose column is gone warns.
95
+ def sync_warning?
96
+ missing_fields.any? || (!@muting && new_columns.any?)
97
+ end
98
+
81
99
  private
82
100
 
83
101
  # Every row counts, switched off or not: a field with a row cannot get a
@@ -93,16 +111,17 @@ module PermissionForge
93
111
  def configured_row(field)
94
112
  name = field.name
95
113
  virtual = virtual?(name)
96
- Row.new(name: name, label: field.label.presence || human(name), field: field, match: @sensitive.match(name),
97
- virtual: virtual, missing: @model.present? && !virtual && @model.column_names.exclude?(name),
114
+ Row.new(name: name, label: FieldLabel.for(@model, name, row: field).text, field: field, match: @sensitive.match(name),
115
+ virtual: virtual, missing: @model.present? && !virtual && !@model.column?(name),
98
116
  level: FieldLevel.level_of(field, muting: @muting), declared: declared?(name),
99
117
  implicit_grant: !field.is_active && implicit_grant?(name))
100
118
  end
101
119
 
102
120
  def unconfigured_rows
121
+ level = FieldLevel.unconfigured_level(muting: @muting)
103
122
  new_columns.map do |name|
104
- Row.new(name: name, label: human(name), field: nil, match: @sensitive.match(name), virtual: virtual?(name),
105
- missing: false, level: nil, declared: declared?(name), implicit_grant: implicit_grant?(name))
123
+ Row.new(name: name, label: FieldLabel.for(@model, name).text, field: nil, match: @sensitive.match(name), virtual: virtual?(name),
124
+ missing: false, level: level, declared: declared?(name), implicit_grant: implicit_grant?(name))
106
125
  end
107
126
  end
108
127
 
@@ -120,10 +139,6 @@ module PermissionForge
120
139
  @model.present? && @model.declared_virtual_field_names.include?(name)
121
140
  end
122
141
 
123
- def human(name)
124
- @model ? @model.klass.human_attribute_name(name) : name.to_s.humanize
125
- end
126
-
127
142
  def match_filter?(row, filter)
128
143
  case filter
129
144
  when 'all' then true
@@ -9,12 +9,26 @@ module PermissionForge
9
9
  # an edit by nobody, by an HTTP Basic login or by an API client keeps the
10
10
  # previous editor — so wherever the admin shows "last edited by", an
11
11
  # event wins over the columns (see AuditTrail::Entry and the footer).
12
+ # The same holds for "created by" (.creations).
12
13
  #
13
14
  # Two queries per record type and slice: the newest event id per record
14
15
  # (the ids grow with every insert), then those events.
15
16
  class LastChanges
16
17
  SLICE = 500
17
18
 
19
+ # The creation event of each record — its model's `audit_event_name(:create)`:
20
+ # permission_set.created, assignment.granted, group_membership.added, ... —
21
+ # read with #[]. Who created a record is that event's actor wherever the
22
+ # admin shows it (AuditHelper#pf_audit_creator): an HTTP Basic login or an
23
+ # API client stamps no created_by, and the audit log still names them.
24
+ #
25
+ # @param records [Array<ActiveRecord::Base>] permission records
26
+ # @return [LastChanges]
27
+ def self.creations(records)
28
+ records = Array(records).compact
29
+ new(records, events: records.map { |record| record.class.audit_event_name(:create) }.uniq)
30
+ end
31
+
18
32
  # @param records [Array<ActiveRecord::Base>] permission records
19
33
  # @param events [Array<String>, nil] only these change events (e.g.
20
34
  # %w[assignment.granted] — who granted it); nil: any change event
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # A list as a sentence ("Bob, Carol and Dan") with the connectors of the
6
+ # current locale, read from the gem's own keys
7
+ # (permission_forge.support.array.*): Array#to_sentence reads Rails'
8
+ # support.array keys, which an app without rails-i18n has in English only,
9
+ # so a Macedonian page would read "Bob and Carol". The admin joins every
10
+ # list through here (helper: pf_to_sentence); a test fails on a bare
11
+ # to_sentence in app/ or lib/.
12
+ #
13
+ # @api private
14
+ #
15
+ # @example
16
+ # ListSentence.call(%w[Bob Carol Dan]) # => "Bob, Carol and Dan" (en)
17
+ module ListSentence
18
+ # @param items [Array<#to_s>]
19
+ # @param overrides [Hash] connectors that replace the locale's (e.g. "or")
20
+ # @return [String]
21
+ def self.call(items, **overrides)
22
+ Array(items).to_sentence(**connectors, **overrides)
23
+ end
24
+
25
+ def self.connectors
26
+ { words_connector: I18n.t('permission_forge.support.array.words_connector'),
27
+ two_words_connector: I18n.t('permission_forge.support.array.two_words_connector'),
28
+ last_word_connector: I18n.t('permission_forge.support.array.last_word_connector') }
29
+ end
30
+ end
31
+ end
32
+ end