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
@@ -6,8 +6,6 @@ module PermissionForge
6
6
  # a read-only query; actions (extend, keep, clean up, acknowledge) live in
7
7
  # the controllers.
8
8
  class AttentionItems
9
- ENDING_DAYS = 7
10
-
11
9
  # `field` — the field row, when there is one, switched on or off.
12
10
  # `implicit_grant` — no active field row, and the field is readable and
13
11
  # editable anyway because the model declares it as an extra (virtual) field.
@@ -18,6 +16,16 @@ module PermissionForge
18
16
  def turned_off? = !field.nil? && !field.is_active
19
17
  def implicit_grant? = implicit_grant == true
20
18
  def name = field&.name || column
19
+
20
+ # Masking, acknowledging or adding the field changes what the locked
21
+ # field, Resource or Permission Set holds: refused (EditableLock), so
22
+ # Home offers neither "Fix" nor "This is intended" for it.
23
+ def locked? = !EditableLock.locked_record(field, resource).nil?
24
+
25
+ # Home offers "This is intended" for it: a field that is shown unmasked
26
+ # and can be changed (acknowledging needs the column, see
27
+ # .acknowledgement_supported?).
28
+ def acknowledgeable? = !unconfigured? && !locked?
21
29
  end
22
30
 
23
31
  TidyFinding = Struct.new(:permission_set, :kind, :people_count, keyword_init: true) do
@@ -42,22 +50,75 @@ module PermissionForge
42
50
  end
43
51
  end
44
52
 
45
- def self.acknowledgement_supported?
46
- PermissionForge::ResourceField.column_names.include?('sensitive_acknowledged_at')
47
- rescue StandardError
48
- false
53
+ # An active rule in effect that matches nothing because it names a
54
+ # column or an association that is not there (Operators::RuleProblem):
55
+ # everyone it applies to is denied every record of its resource.
56
+ BrokenRuleFinding = Struct.new(:rule, :problem, :people_count, keyword_init: true) do
57
+ def resource = rule.resource
58
+ def permission_set = rule.resource.permission_set
59
+
60
+ # The rule, its Resource or its Permission Set is locked: Home opens
61
+ # the Rules tab instead of the rule's edit page (which refuses it).
62
+ def locked? = !EditableLock.locked_record(rule).nil?
63
+ end
64
+
65
+ # A field row in effect masked with a strategy this app does not know.
66
+ MaskStrategyFinding = Struct.new(:field, :people_count, keyword_init: true) do
67
+ def resource = field.resource
68
+ def permission_set = field.resource.permission_set
49
69
  end
50
70
 
51
- def initialize(configuration: PermissionForge.configuration, catalog: ModelCatalog.new,
71
+ # A set whose activation condition nobody can judge: a muting one
72
+ # applies to every request, a granting one never passes.
73
+ ConditionsFinding = Struct.new(:permission_set, :people_count, keyword_init: true)
74
+
75
+ # A rule in effect that compares a column holding a secret
76
+ # (PermissionForge::SecretColumns), stored before the model refused one.
77
+ SecretRuleFinding = Struct.new(:rule, :people_count, keyword_init: true) do
78
+ def resource = rule.resource
79
+ def permission_set = rule.resource.permission_set
80
+ def locked? = !EditableLock.locked_record(rule).nil?
81
+ end
82
+
83
+ def self.acknowledgement_supported? = PermissionForge::ResourceField.acknowledgement_supported?
84
+
85
+ # The threshold of "due for review", in days: config.admin_stale_assignment_days,
86
+ # or the default while it is nil. The one definition — Home's Stale
87
+ # card, the People page (pf_stale?), the audit's stale_assignments and
88
+ # StaleAssignmentAuditor all read it.
89
+ #
90
+ # @param configuration [Configuration]
91
+ # @return [Integer]
92
+ def self.stale_days(configuration = PermissionForge.configuration)
93
+ configuration.try(:admin_stale_assignment_days) || Configuration::AdminUiSettings::DEFAULT_STALE_ASSIGNMENT_DAYS
94
+ end
95
+
96
+ # The catalog only counts (`verify: false`): every admin page draws the
97
+ # navigation's count, so it reads the schema cache, never the database.
98
+ def initialize(configuration: PermissionForge.configuration, catalog: ModelCatalog.new(verify: false),
52
99
  sensitive: SensitiveColumns.new(configuration: configuration))
53
100
  @configuration = configuration
54
101
  @catalog = catalog
55
102
  @sensitive = sensitive
56
103
  end
57
104
 
105
+ # Every list Home draws a card for (the navigation counts them all) =>
106
+ # the partial (home/_<partial>) that draws it, in Home's priority
107
+ # order: the one list Home renders from (#cards).
108
+ CARDS = { audit_findings: 'audit_card', broken_rules: 'broken_rules_card', privacy_findings: 'privacy_card',
109
+ unknown_mask_strategies: 'mask_strategies_card', broken_conditions: 'conditions_card',
110
+ secret_rules: 'secret_rules_card', unenforced_rules: 'rules_card', ending_soon: 'ending_card',
111
+ expired: 'expired_card', stale: 'stale_card', tidy: 'tidy_card' }.freeze
112
+
58
113
  def total
59
- [audit_findings.size, privacy_findings.size, unenforced_rules.size, ending_soon.size, expired.size, stale.size,
60
- tidy.size].sum
114
+ CARDS.each_key.sum { |card| public_send(card).size }
115
+ end
116
+
117
+ # The partials of the cards Home draws: those whose list has items.
118
+ #
119
+ # @return [Array<String>]
120
+ def cards
121
+ CARDS.filter_map { |list, partial| partial if public_send(list).any? }
61
122
  end
62
123
 
63
124
  def all_good?
@@ -83,12 +144,64 @@ module PermissionForge
83
144
  def unenforced_rules
84
145
  @unenforced_rules ||= begin
85
146
  enforced = rule_enforced_resources
86
- counts = unenforced_rule_scope.where(resource_id: enforced.keys).group(:resource_id).count
147
+ counts = unenforced_rule_counts(enforced.keys)
87
148
  findings = counts.map { |id, count| RulesFinding.build(enforced.fetch(id), count, people_counts) }
88
149
  findings.sort_by { |finding| [finding.permission_set.name, finding.resource.name] }
89
150
  end
90
151
  end
91
152
 
153
+ # Rules in effect that name a column or an association that is not
154
+ # there — the audit's missing_rule_columns finding, the same rules.
155
+ def broken_rules
156
+ @broken_rules ||= rules_in_effect.filter_map do |rule|
157
+ problem = PermissionForge::Operators::RuleProblem.for(rule)
158
+ next unless problem
159
+
160
+ BrokenRuleFinding.new(rule: rule, problem: problem, people_count: people_counts.fetch(rule.resource.permission_set_id, 0))
161
+ end
162
+ end
163
+
164
+ # Rules in effect that compare a column holding a secret — the audit's
165
+ # secret_rule_columns finding, the same rules. They still apply.
166
+ def secret_rules
167
+ @secret_rules ||= begin
168
+ secrets = PermissionForge::SecretColumns.new(configuration: @configuration)
169
+ rules_in_effect.select { |rule| secrets.rule?(rule) }.map do |rule|
170
+ SecretRuleFinding.new(rule: rule, people_count: people_counts.fetch(rule.resource.permission_set_id, 0))
171
+ end
172
+ end
173
+ end
174
+
175
+ # --- Field rows and conditions the engine cannot read as written ----------
176
+
177
+ # Field rows in effect masked with a strategy this app does not know
178
+ # (ResourceField.with_unknown_mask_strategy, the audit's
179
+ # unknown_mask_strategies): masked fully until fixed.
180
+ def unknown_mask_strategies
181
+ @unknown_mask_strategies ||= PermissionForge::ResourceField.with_unknown_mask_strategy.map do |field|
182
+ MaskStrategyFinding.new(field: field, people_count: people_counts.fetch(field.resource.permission_set_id, 0))
183
+ end
184
+ end
185
+
186
+ # Sets whose activation conditions nobody can judge
187
+ # (PermissionSet.with_broken_conditions, the audit's incomplete_conditions).
188
+ def broken_conditions
189
+ @broken_conditions ||= PermissionForge::PermissionSet.with_broken_conditions.map do |set|
190
+ ConditionsFinding.new(permission_set: set, people_count: people_counts.fetch(set.id, 0))
191
+ end
192
+ end
193
+
194
+ # Active rules of the granting resources in effect: the rules that are
195
+ # applied right now (a muting set's rules never are). Not the ones View
196
+ # All skips — nobody is denied by those.
197
+ def rules_in_effect
198
+ @rules_in_effect ||= begin
199
+ enforced = granting_resources.reject(&:rules_bypassed?).map(&:id)
200
+ PermissionForge::PermissionSetRule.active.where(resource_id: enforced)
201
+ .includes(resource: :permission_set).order(:resource_id, :id).to_a
202
+ end
203
+ end
204
+
92
205
  def auto_query_scoping?
93
206
  @configuration.auto_query_scoping_enabled?
94
207
  end
@@ -96,8 +209,7 @@ module PermissionForge
96
209
  # --- Assignments --------------------------------------------------------
97
210
 
98
211
  def ending_soon
99
- @ending_soon ||= assignments.where(expires_at: Time.current..ENDING_DAYS.days.from_now.end_of_day)
100
- .order(:expires_at).to_a
212
+ @ending_soon ||= assignments.ending_within.order(:expires_at).to_a
101
213
  end
102
214
 
103
215
  def expired
@@ -110,7 +222,7 @@ module PermissionForge
110
222
  end
111
223
 
112
224
  def stale_days
113
- @configuration.admin_stale_assignment_days || 90
225
+ self.class.stale_days(@configuration)
114
226
  end
115
227
 
116
228
  # --- Permission Sets to tidy up -----------------------------------------
@@ -129,42 +241,51 @@ module PermissionForge
129
241
  PermissionForge::PermissionSetUser.includes(:permission_set, :user)
130
242
  end
131
243
 
132
- # Resources whose rules are in force (View All / Modify All skip them), by id.
244
+ # Readable Resources whose rules are in force (View All / Modify All
245
+ # skip them), by id. A Resource named after no model this app can load
246
+ # (`Custmer`) has no query to scope: the audit's integrity/unknown_models names it.
133
247
  def rule_enforced_resources
134
- granting_resources.reject(&:rules_bypassed?).index_by(&:id)
248
+ readable_resources.reject { |resource| resource.rules_bypassed? || @catalog.find(resource.name).nil? }
249
+ .index_by(&:id)
135
250
  end
136
251
 
137
- def unenforced_rule_scope
138
- rules = PermissionForge::PermissionSetRule.active
139
- auto_query_scoping? ? rules.join_type_rules : rules
252
+ # Active rules the app's query scoping does not apply, counted per
253
+ # resource: every one while auto_query_scoping is off, the join rules
254
+ # (PermissionSetRule#join_rule?, the one definition) when it is on.
255
+ def unenforced_rule_counts(resource_ids)
256
+ rows = PermissionForge::PermissionSetRule.active.where(resource_id: resource_ids)
257
+ rows = rows.join_type_rules if auto_query_scoping?
258
+ rows.pluck(:resource_id, :join_type)
259
+ .select { |_, join_type| !auto_query_scoping? || !PermissionForge::PermissionSetRule.filter_join_type?(join_type) }
260
+ .group_by(&:first).transform_values(&:size)
140
261
  end
141
262
 
142
263
  def people_counts
143
264
  @people_counts ||= PermissionForge::PermissionSetUser.active_assignments.group(:permission_set_id).count
144
265
  end
145
266
 
267
+ # The granting Resource rows that count right now (Resource.in_effect,
268
+ # the one definition), with their sets and fields loaded.
146
269
  def granting_resources
147
- @granting_resources ||= PermissionForge::Resource.active.where(read_access: true)
148
- .where(permission_set_id: PermissionForge::PermissionSet.active.granting.select(:id))
270
+ @granting_resources ||= PermissionForge::Resource.in_effect.merge(PermissionForge::PermissionSet.granting)
149
271
  .includes(:permission_set, :resource_fields).to_a
150
272
  end
151
273
 
274
+ # Those of them people can read: where an unmasked sensitive field shows.
275
+ def readable_resources
276
+ @readable_resources ||= granting_resources.select(&:read_access)
277
+ end
278
+
279
+ # Readable and unmasked, and not acknowledged as intentional
280
+ # (ResourceField#exposes_unacknowledged?).
152
281
  def unmasked_sensitive_fields
153
- acknowledged = self.class.acknowledgement_supported?
154
- granting_resources.flat_map do |resource|
282
+ readable_resources.flat_map do |resource|
155
283
  resource.resource_fields.filter_map do |field|
156
- sensitive_finding(resource, field.name, field: field) if exposed?(field, acknowledged)
284
+ sensitive_finding(resource, field.name, field: field) if field.exposes_unacknowledged?
157
285
  end
158
286
  end
159
287
  end
160
288
 
161
- # Readable and unmasked, and not acknowledged as intentional.
162
- def exposed?(field, acknowledged)
163
- return false unless field.is_active && field.read_access && !field.mask_access
164
-
165
- !(acknowledged && field.sensitive_acknowledged_at.present?)
166
- end
167
-
168
289
  # Sensitive columns and virtual fields with no active field row are a
169
290
  # leak when they are readable unmasked anyway: the unconfigured-field
170
291
  # strategy lets them through, or the model declares them as extra fields
@@ -175,7 +296,7 @@ module PermissionForge
175
296
  passes = %w[pass_through warn].include?(@configuration.unconfigured_field_strategy.to_s)
176
297
  return [] unless passes || @configuration.grant_extra_fields_by_default?
177
298
 
178
- granting_resources.flat_map { |resource| unconfigured_findings(resource, passes) }
299
+ readable_resources.flat_map { |resource| unconfigured_findings(resource, passes) }
179
300
  end
180
301
 
181
302
  # `passes` — whether unconfigured_field_strategy lets a field with no row through.
@@ -37,9 +37,7 @@ module PermissionForge
37
37
  # log can be read while recording is switched off; without the table
38
38
  # there is nothing to read and every list is empty.
39
39
  def self.table?
40
- PermissionForge::AuditEvent.table_exists?
41
- rescue StandardError
42
- false
40
+ PermissionForge::DatabaseReach.table_exists?(PermissionForge::AuditEvent)
43
41
  end
44
42
 
45
43
  # Why the log is not recording, or nil when it records:
@@ -134,12 +132,11 @@ module PermissionForge
134
132
  Page.new(events: events, total: total, number: current, per_page: per_page)
135
133
  end
136
134
 
137
- # 1 for anything that is not a number; at most the last page.
135
+ # 1 for anything that is not a whole number from 1 (ParamShapes.integer,
136
+ # the one reader); at most the last page.
138
137
  def self.page_number(number, total, per_page)
139
- return 1 unless number.to_s.match?(/\A\d{1,9}\z/)
140
-
141
138
  last = [(total + per_page - 1) / per_page, 1].max
142
- number.to_i.clamp(1, [last, MAX_PAGE].min)
139
+ ParamShapes.integer(number, default: 1, range: 1..).clamp(1, [last, MAX_PAGE].min)
143
140
  end
144
141
 
145
142
  # People the log names lately (KNOWN_PEOPLE_WINDOW), for the actor and
@@ -2,13 +2,14 @@
2
2
 
3
3
  module PermissionForge
4
4
  module Admin
5
- # Who created and who last edited each permission record. Creation comes
6
- # from created_by / created_at (HasAuditTrail); the last edit from the
7
- # record's latest change event when the audit log has one (LastChanges),
8
- # else from updated_by / updated_at — an event names the real editor,
9
- # while updated_by keeps the previous one across an edit by nobody, an
10
- # HTTP Basic login or an API client. The full history is the audit log
11
- # (Admin::AuditLog).
5
+ # Who created and who last edited each permission record. The creator is
6
+ # the actor of the record's creation event when the audit log has one
7
+ # (LastChanges.creations), else created_by; the last edit is the record's
8
+ # latest change event when the log has one (LastChanges), else
9
+ # updated_by / updated_at. An event names who really acted, while the
10
+ # columns stamp nobody for an HTTP Basic login or an API client (and
11
+ # updated_by keeps the previous editor). The full history is the audit
12
+ # log (Admin::AuditLog).
12
13
  class AuditTrail
13
14
  TYPES = {
14
15
  'permission_set' => 'PermissionForge::PermissionSet',
@@ -22,11 +23,13 @@ module PermissionForge
22
23
  }.freeze
23
24
 
24
25
  DAY_FILTERS = [7, 30, 90].freeze
25
- CSV_HEADER = ['Type', 'Record', 'Where', 'Created by', 'Created at', 'Last edited by', 'Last edited at'].freeze
26
+ # The CSV's columns, named by permission_forge.ui.audit.csv.<column>.
27
+ CSV_COLUMNS = %w[type record where created_by created_at edited_by edited_at].freeze
26
28
 
27
29
  # `last_event` — the record's latest change event, or nil (no events:
28
30
  # auditing off, or the record predates the audit log).
29
- Entry = Struct.new(:type, :record, :last_event, keyword_init: true) do
31
+ # `creation_event` — the record's creation event, or nil (likewise).
32
+ Entry = Struct.new(:type, :record, :last_event, :creation_event, keyword_init: true) do
30
33
  def created_by = record.try(:created_by)
31
34
  def updated_by = record.try(:updated_by)
32
35
  def created_at = record.created_at
@@ -42,6 +45,13 @@ module PermissionForge
42
45
 
43
46
  last_event.actor_id if last_event.actor_type == 'user'
44
47
  end
48
+
49
+ # The creator's user id as a String, or nil (not a person, or unknown).
50
+ def creator_id
51
+ return created_by_id&.to_s unless creation_event
52
+
53
+ creation_event.actor_id if creation_event.actor_type == 'user'
54
+ end
45
55
  end
46
56
 
47
57
  def self.type_of(record)
@@ -73,7 +83,7 @@ module PermissionForge
73
83
 
74
84
  # Every record belonging to a Permission Set (the set, its resources,
75
85
  # fields, actions, rules and assignments), filtered and sorted.
76
- # Filters (all optional): who:, type:, days:, sort: ('asc' / 'desc', default 'desc').
86
+ # Filters (all optional): who:, type:, days: (an Integer of DAY_FILTERS), sort: ('asc' / 'desc', default 'desc').
77
87
  # @return [Array<Entry>]
78
88
  def for_permission_set(permission_set, **filters)
79
89
  entries = apply_filters(permission_set_entries(permission_set), filters)
@@ -84,7 +94,8 @@ module PermissionForge
84
94
  # @param describe [#call] (entry) -> [type_label, record_label, where]
85
95
  # @param person [#call] (entry) -> [created by, last edited by]
86
96
  def to_csv(entries, describe:, person:)
87
- Csv.document([CSV_HEADER] + entries.map { |entry| csv_row(entry, describe, person) })
97
+ header = CSV_COLUMNS.map { |column| I18n.t("permission_forge.ui.audit.csv.#{column}") }
98
+ Csv.document([header] + entries.map { |entry| csv_row(entry, describe, person) })
88
99
  end
89
100
 
90
101
  private
@@ -109,14 +120,14 @@ module PermissionForge
109
120
 
110
121
  # Created it, or made its last edit (by the event when there is one).
111
122
  def made_by?(entry, user_id)
112
- entry.created_by_id.to_s == user_id.to_s || entry.editor_id == user_id.to_s
123
+ entry.creator_id == user_id.to_s || entry.editor_id == user_id.to_s
113
124
  end
114
125
 
115
126
  def apply_filters(entries, filters)
116
127
  type, who, days = filters.values_at(:type, :who, :days)
117
128
  entries = entries.select { |e| e.type == type } if type.present? && TYPES.key?(type)
118
129
  entries = filter_who(entries, who) if who.present?
119
- entries = filter_days(entries, days.to_i) if DAY_FILTERS.include?(days.to_i)
130
+ entries = filter_days(entries, days) if DAY_FILTERS.include?(days)
120
131
  entries
121
132
  end
122
133
 
@@ -147,14 +158,19 @@ module PermissionForge
147
158
  with_people(scope).map { |record| [type, record] }
148
159
  end
149
160
 
150
- # [[type, record], ...] → entries, each with its latest change event.
161
+ # [[type, record], ...] → entries, each with its latest change event
162
+ # and its creation event.
151
163
  def entries_for(typed_records)
152
- last = LastChanges.new(typed_records.map(&:last))
153
- typed_records.map { |type, record| Entry.new(type: type, record: record, last_event: last.last_edit(record)) }
164
+ records = typed_records.map(&:last)
165
+ last = LastChanges.new(records)
166
+ created = LastChanges.creations(records)
167
+ typed_records.map do |type, record|
168
+ Entry.new(type: type, record: record, last_event: last.last_edit(record), creation_event: created[record])
169
+ end
154
170
  end
155
171
 
156
172
  def filter_who(entries, who)
157
- entries.select { |e| e.created_by_id.to_s == who.to_s || e.editor_id == who.to_s }
173
+ entries.select { |e| e.creator_id == who.to_s || e.editor_id == who.to_s }
158
174
  end
159
175
 
160
176
  def filter_days(entries, days)
@@ -4,7 +4,10 @@ module PermissionForge
4
4
  module Admin
5
5
  # Resolves pasted emails/names (one per line or comma-separated) to people
6
6
  # for bulk assignment. Exact, case-insensitive matches only — a near miss
7
- # is reported as "not found" rather than guessed. Only people within
7
+ # is reported as "not found" rather than guessed. A column the user model
8
+ # encrypts (PermissionForge::EncryptedAttributes) holds ciphertext: a
9
+ # deterministically encrypted one matches the pasted value exactly
10
+ # (`where(email: value)` encrypts it), any other is not matched. Only people within
8
11
  # config.user_list_resolver match (PeopleDirectory#find_all): anyone else
9
12
  # is "not found" too.
10
13
  class BulkPeopleMatcher
@@ -60,14 +63,28 @@ module PermissionForge
60
63
  end
61
64
 
62
65
  def lookup(token)
63
- needle = token.downcase
64
- return exact(column_if_present('email'), needle) if token.include?('@')
66
+ return exact(email_columns, token) if token.include?('@')
65
67
 
66
- exact(name_column, needle) || first_last_match(needle)
68
+ exact(name_column, token) || first_last_match(token)
67
69
  end
68
70
 
69
- def exact(column, needle)
70
- column && first_listed(model.where(model.arel_table[column].lower.eq(needle)))
71
+ # The first listed person whose value in any of `columns` equals the token.
72
+ def exact(columns, token)
73
+ scopes = Array(columns).filter_map { |column| equal_to(column, token) }
74
+ first_listed(scopes.reduce(:or)) if scopes.any?
75
+ end
76
+
77
+ # The people whose `column` equals the token, whatever its case; for a
78
+ # deterministically encrypted column the value as pasted or lower-cased
79
+ # (the attribute type encrypts it); nil for a column SQL cannot compare
80
+ # (encrypted otherwise).
81
+ def equal_to(column, token)
82
+ if PermissionForge::EncryptedAttributes.encrypted?(model, column)
83
+ return unless PermissionForge::EncryptedAttributes.deterministic?(model, column)
84
+
85
+ return model.where(column => [token, token.downcase].uniq)
86
+ end
87
+ model.where(model.arel_table[column].lower.eq(token.downcase))
71
88
  end
72
89
 
73
90
  # The first match that config.user_list holds.
@@ -79,26 +96,27 @@ module PermissionForge
79
96
  @directory ||= PeopleDirectory.new(configuration: @configuration)
80
97
  end
81
98
 
82
- def column_if_present(column)
83
- column if model.column_names.include?(column)
99
+ # Every email column the user table has (`email`, `email_address`).
100
+ def email_columns
101
+ PermissionForge::SchemaRead.columns_among(model, PersonLabel::EMAIL_ATTRIBUTES.map(&:to_s))
84
102
  end
85
103
 
86
104
  # "Ana Krasniqi" against separate first/last name columns.
87
- def first_last_match(needle)
88
- first, last = needle.split(/\s+/, 2)
105
+ def first_last_match(token)
106
+ first, last = token.split(/\s+/, 2)
89
107
  pair = first_last_columns
90
108
  return nil unless pair && last
91
109
 
92
- table = model.arel_table
93
- first_listed(model.where(table[pair.first].lower.eq(first).and(table[pair.last].lower.eq(last))))
110
+ scopes = [equal_to(pair.first, first), equal_to(pair.last, last)]
111
+ first_listed(scopes.first.merge(scopes.last)) if scopes.all?
94
112
  end
95
113
 
96
114
  def first_last_columns
97
- FIRST_LAST_COLUMNS.find { |cols| (cols - model.column_names).empty? }
115
+ FIRST_LAST_COLUMNS.find { |cols| PermissionForge::SchemaRead.columns_among(model, cols).size == cols.size }
98
116
  end
99
117
 
100
118
  def name_column
101
- (%w[name full_name display_name] & model.column_names).first
119
+ PermissionForge::SchemaRead.columns_among(model, %w[name full_name display_name]).first
102
120
  end
103
121
 
104
122
  def picked(ids)
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # Whether a Permission Set holds more than one Resource of a
6
+ # single-table-inheritance chain (ResourceChain: a base and a subclass on
7
+ # its table) — then a row that names the set about one of them says which
8
+ # ("Sales rep · VipCustomer", LabelsHelper#pf_set_entry_name): the field
9
+ # drawer's "Across all Permission Sets" and a person's effective access
10
+ # showed two rows "Sales rep" with different levels and no way to tell
11
+ # them apart (field test round 7, B#35). Every Resource row of the set
12
+ # counts, turned off or not: it is about naming, not about access.
13
+ #
14
+ # @example
15
+ # ChainEntries.several?(set, %w[VipCustomer Customer]) # => true when the set has both Resources
16
+ #
17
+ # @api private
18
+ module ChainEntries
19
+ # @param permission_set [PermissionSet]
20
+ # @param names [Array<String, Symbol>] the Resource names of the chain
21
+ # @return [Boolean]
22
+ def self.several?(permission_set, names)
23
+ names = names.map(&:to_s)
24
+ return false if names.size < 2
25
+
26
+ permission_set.resources.count { |resource| names.include?(resource.name) } > 1
27
+ end
28
+
29
+ # The Resource rows named by one of `names`, in the order of `names`
30
+ # (a chain: the model first, then its bases nearest first), so the row
31
+ # nearest the model comes first (EffectiveAccess: the Resource a muting
32
+ # set is named with, B#38).
33
+ #
34
+ # @param resources [Array<Resource>] one Permission Set's rows
35
+ # @param names [Array<String>]
36
+ # @return [Array<Resource>]
37
+ def self.in_chain_order(resources, names)
38
+ names.flat_map { |name| resources.select { |resource| resource.name == name } }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -52,12 +52,16 @@ module PermissionForge
52
52
  end
53
53
  end
54
54
 
55
+ # A flag the request turned on that stays off because what it needs is
56
+ # off (`needs`: :read, or :view_all_and_edit for Modify All). Not a change.
57
+ Refusal = Struct.new(:resource, :attribute, :needs, keyword_init: true)
58
+
55
59
  class Invalid < StandardError
56
60
  attr_reader :record
57
61
 
58
62
  def initialize(record)
59
63
  @record = record
60
- super(record.errors.full_messages.to_sentence)
64
+ super(ListSentence.call(ErrorSentence.all(record.errors)))
61
65
  end
62
66
  end
63
67
 
@@ -79,6 +83,13 @@ module PermissionForge
79
83
  changes.empty?
80
84
  end
81
85
 
86
+ # @return [Array<Refusal>] what the request asked for and the
87
+ # dependencies kept off (Edit with Read off, ...)
88
+ def refused
89
+ changes
90
+ @context.refused
91
+ end
92
+
82
93
  # Saves every pending record, all or nothing, under one audit
83
94
  # correlation id. Its own savepoint (requires_new) rolls back the saves
84
95
  # before a failing one — and their audit events — also when the caller's
@@ -13,7 +13,7 @@ module PermissionForge
13
13
 
14
14
  private
15
15
 
16
- delegate :permission_set, :params, :sensitive, :muting?, :section, :resources, :resource_of,
16
+ delegate :permission_set, :params, :sensitive, :muting?, :section, :resources, :resource_of, :refused,
17
17
  to: :@context
18
18
 
19
19
  def change(**attrs)
@@ -43,10 +43,11 @@ module PermissionForge
43
43
  section('bulk')
44
44
  end
45
45
 
46
- # Records flagged is_editable: false are managed by the system/API and
47
- # are never changed from the admin UI (their disabled inputs still post).
46
+ # Locked records (is_editable: false on them or on what they belong
47
+ # to: EditableLock, the one definition) are managed by the system/API
48
+ # and never changed from the admin UI (their disabled inputs still post).
48
49
  def locked?(record)
49
- record.respond_to?(:is_editable) && record.is_editable == false
50
+ EditableLock.locked?(record)
50
51
  end
51
52
 
52
53
  def truthy?(value)
@@ -8,7 +8,8 @@ module PermissionForge
8
8
  # same resource/field objects, so later parts see earlier parts' edits
9
9
  # (e.g. field levels see the resource's new Edit flag).
10
10
  class Context
11
- attr_reader :permission_set, :params, :sensitive
11
+ # refused: the ChangeSet::Refusal list the parts add to.
12
+ attr_reader :permission_set, :params, :sensitive, :refused
12
13
 
13
14
  def initialize(permission_set:, params:, sensitive:, catalog: nil)
14
15
  @permission_set = permission_set
@@ -16,6 +17,7 @@ module PermissionForge
16
17
  @sensitive = sensitive
17
18
  @controller_catalog = catalog
18
19
  @muting = permission_set.is_muting
20
+ @refused = []
19
21
  end
20
22
 
21
23
  # The app's controller actions (see NewActions). Built on first use:
@@ -9,8 +9,11 @@ module PermissionForge
9
9
  class FieldEdit
10
10
  ATTRS = %w[label description is_active].freeze
11
11
  MASK_LEVELS = %w[masked mask].freeze
12
+ # A blank label over a generated one is no change (Prefill.posted);
13
+ # over a label an admin wrote it is what the model stores for a blank
14
+ # label, the name (ResourceField#label_to_store: NormalizesLabel).
12
15
  CASTS = {
13
- 'label' => ->(value, field) { value.to_s.strip.presence || field.name.humanize },
16
+ 'label' => ->(value, field) { field.label_to_store(Prefill.posted(field, :label, value).to_s.strip) },
14
17
  'is_active' => ->(value, _field) { Values.truthy?(value) }
15
18
  }.freeze
16
19
  TEXT_CAST = ->(value, _field) { value.to_s.strip.presence }
@@ -37,7 +37,7 @@ module PermissionForge
37
37
  return [] unless resource
38
38
 
39
39
  fields = Array(bulk['keys']).filter_map { |key| resource.resource_fields.find { |field| field.name == key.to_s } }
40
- fields.reject { |field| field.is_editable == false }
40
+ fields.reject { |field| locked?(field) }
41
41
  end
42
42
 
43
43
  def changes_for_field(field, attrs)
@@ -80,7 +80,7 @@ module PermissionForge
80
80
  end
81
81
 
82
82
  def build_new_action(resource, key, permitted)
83
- action = resource.resource_actions.build(name: key, label: key.humanize, is_active: true,
83
+ action = resource.resource_actions.build(name: key, label: key, is_active: true,
84
84
  is_permitted: truthy?(permitted))
85
85
  change(kind: :new_action, record: action, attribute: 'is_permitted', from: nil, to: action.is_permitted,
86
86
  resource: resource)