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,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # The choices of a field's mask-strategy select (the field drawer): the
6
+ # strategies this app knows, after the saved one when this app does not
7
+ # know it — a row written past the model, or a custom strategy since
8
+ # removed from config.custom_mask_strategies (Services::MaskValue.known?).
9
+ # The saved name is offered as it is stored, selected, so a save that
10
+ # changes something else never rewrites the strategy (field test round 6:
11
+ # the select showed nothing selected and a label change saved `full`);
12
+ # it masks fully until the admin picks one this app knows.
13
+ #
14
+ # @example
15
+ # choices = MaskStrategyChoices.new(field) # field.mask_strategy == 'emial'
16
+ # choices.unknown? # => true
17
+ # choices.names # => ['emial', 'full', 'email', 'last4', 'first1_last4']
18
+ # choices.options # => [['emial', :unknown_strategy], ['full', nil], ...]
19
+ class MaskStrategyChoices
20
+ # @param field [PermissionForge::ResourceField, nil] the saved row, nil for a column with none
21
+ def initialize(field)
22
+ @field = field
23
+ end
24
+
25
+ # The saved strategy is none this app knows, and the row masks.
26
+ def unknown?
27
+ !@field.nil? && @field.mask_access == true && !Services::MaskValue.known?(@field.mask_strategy)
28
+ end
29
+
30
+ # The names the select offers, the saved unknown one first.
31
+ #
32
+ # @return [Array<String>]
33
+ def names
34
+ known = Services::MaskValue.strategies.keys.map(&:to_s)
35
+ unknown? && @field.mask_strategy.present? ? [@field.mask_strategy] + known : known
36
+ end
37
+
38
+ # [name, note] per choice. The note is the key under
39
+ # `resource_fields.show` shown after the label: :unknown_strategy for
40
+ # the saved name this app does not know, :custom for a configured
41
+ # custom strategy, nil for a built-in.
42
+ #
43
+ # @return [Array<Array(String, Symbol, nil)>]
44
+ def options
45
+ names.map { |name| [name, self.class.note(name)] }
46
+ end
47
+
48
+ # @param name [String] a strategy name
49
+ # @return [Symbol, nil] :unknown_strategy, :custom or nil (built-in)
50
+ def self.note(name)
51
+ return :unknown_strategy unless Services::MaskValue.known?(name)
52
+
53
+ :custom unless Services::MaskValue::BUILT_IN_STRATEGIES.key?(name.to_s)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -5,6 +5,15 @@ module PermissionForge
5
5
  # Read-only reflection over the host app's ActiveRecord models, so the
6
6
  # admin UI only ever offers real models, columns and associations (no
7
7
  # free-text names that silently do nothing when misspelled).
8
+ #
9
+ # Columns are read through PermissionForge::SchemaRead: a model's column
10
+ # list is compared with the database once per catalog (a column a
11
+ # migration added while the process was up is offered, a removed one
12
+ # flagged), and a name that is not a column is asked again once before
13
+ # it is called virtual. Associations are the ones a rule's path can
14
+ # follow (Operators::RulePath.unjoinable_cause): the builder never offers
15
+ # one the applier refuses to join. Labels are Admin::FieldLabel's and
16
+ # Admin::ModelLabel's.
8
17
  class ModelCatalog
9
18
  Column = Struct.new(:name, :label, :type, :enum_values, :user_reference, :virtual, keyword_init: true) do
10
19
  def user_reference? = user_reference
@@ -16,8 +25,8 @@ module PermissionForge
16
25
  # @param target [Class] the reflection's resolved class
17
26
  # @param owner [Class] the model declaring the association
18
27
  def self.from(reflection, target, owner)
19
- name = reflection.name
20
- new(name: name.to_s, label: owner.human_attribute_name(name), class_name: target.name,
28
+ name = reflection.name.to_s
29
+ new(name: name, label: FieldLabel.for(owner, name).text, class_name: target.name,
21
30
  foreign_key: reflection.foreign_key.to_s, macro: reflection.macro)
22
31
  end
23
32
  end
@@ -31,27 +40,35 @@ module PermissionForge
31
40
  # read + edit entry from ExtraFieldDefaults right now.
32
41
  delegate :implicit_grant?, to: :virtual_fields
33
42
 
34
- def initialize(klass, configuration)
43
+ # @param verify [Boolean] compare the column list with the database
44
+ # (one metadata query per model and catalog); false for the
45
+ # catalogs that only count (Home and the navigation's attention
46
+ # items)
47
+ def initialize(klass, configuration, verify: true)
35
48
  @klass = klass
36
49
  @configuration = configuration
50
+ @verify = verify
37
51
  end
38
52
 
39
53
  def name = @klass.name
40
- def label = @klass.model_name.human
41
- def plural_label = @klass.model_name.human(count: 2)
42
-
43
- def column_names
44
- @column_names ||= @klass.column_names
54
+ def label = ModelLabel.for(@klass).text
55
+ def plural_label = ModelLabel.plural(@klass)
56
+
57
+ # The model's columns (ignored_columns excluded), compared with the
58
+ # database once for this catalog unless it only counts
59
+ # (PermissionForge::SchemaRead).
60
+ def column_names = managed_fields.column_names
61
+
62
+ # Whether `name` is a column of the model, asked again once after the
63
+ # schema is read again before "no" (PermissionForge::SchemaRead).
64
+ def column?(name)
65
+ PermissionForge::SchemaRead.column?(@klass, name.to_s, ignored: false)
45
66
  end
46
67
 
47
68
  # Columns PermissionForge manages (ignored_fields removed).
48
- def managed_column_names
49
- column_names - ignored_column_names
50
- end
69
+ def managed_column_names = managed_fields.managed_column_names
51
70
 
52
- def ignored_column_names
53
- column_names & Array(@configuration.ignored_fields).map(&:to_s)
54
- end
71
+ def ignored_column_names = managed_fields.ignored_column_names
55
72
 
56
73
  # Virtual fields the model declares through extra_fields_method.
57
74
  def declared_virtual_field_names
@@ -64,10 +81,10 @@ module PermissionForge
64
81
  virtual_fields.names
65
82
  end
66
83
 
67
- # Every field the admin configures: managed columns, then virtual fields.
68
- def managed_field_names
69
- managed_column_names + virtual_field_names
70
- end
84
+ # Every field the admin configures: managed columns, then virtual
85
+ # fields (PermissionForge::ManagedFields, the names every path that
86
+ # creates rows writes rows for).
87
+ def managed_field_names = managed_fields.names
71
88
 
72
89
  def virtual_field?(name)
73
90
  virtual_fields.include?(name)
@@ -87,9 +104,9 @@ module PermissionForge
87
104
 
88
105
  def column(name)
89
106
  name = name.to_s
90
- return virtual_column(name) unless column_names.include?(name)
107
+ return virtual_column(name) unless column?(name)
91
108
 
92
- Column.new(name: name, label: @klass.human_attribute_name(name), type: column_type(name),
109
+ Column.new(name: name, label: FieldLabel.for(@klass, name).text, type: column_type(name),
93
110
  enum_values: enum_values(name), user_reference: user_reference?(name), virtual: false)
94
111
  end
95
112
 
@@ -103,18 +120,26 @@ module PermissionForge
103
120
 
104
121
  private
105
122
 
106
- def virtual_fields
107
- @virtual_fields ||= PermissionForge::VirtualFields.new(@klass, @configuration)
123
+ def virtual_fields = managed_fields.virtual_fields
124
+
125
+ def managed_fields
126
+ @managed_fields ||= PermissionForge::ManagedFields.new(@klass, configuration: @configuration, verify: @verify)
108
127
  end
109
128
 
110
- # nil for polymorphic or unresolvable associations.
129
+ # nil for an association a rule's path cannot follow
130
+ # (Operators::RulePath.unjoinable_cause: polymorphic, a
131
+ # record-dependent scope, a broken :through or inverse_of, a class
132
+ # that cannot be loaded): the applier refuses to join it.
111
133
  def build_association(ref)
112
- target = safe_klass(ref) unless ref.polymorphic?
113
- Association.from(ref, target, @klass) if target
134
+ return if PermissionForge::Operators::RulePath.unjoinable_cause(ref)
135
+
136
+ Association.from(ref, ref.klass, @klass)
137
+ rescue *PermissionForge::Operators::RuleColumn::UNTELLABLE
138
+ nil
114
139
  end
115
140
 
116
141
  def virtual_column(name)
117
- Column.new(name: name, label: @klass.human_attribute_name(name), type: :string,
142
+ Column.new(name: name, label: FieldLabel.for(@klass, name).text, type: :string,
118
143
  enum_values: nil, user_reference: false, virtual: true)
119
144
  end
120
145
 
@@ -144,13 +169,16 @@ module PermissionForge
144
169
  end
145
170
  end
146
171
 
147
- def initialize(configuration: PermissionForge.configuration)
172
+ # @param verify [Boolean] whether the column lists are compared with
173
+ # the database (ModelInfo); false for a catalog that only counts
174
+ def initialize(configuration: PermissionForge.configuration, verify: true)
148
175
  @configuration = configuration
176
+ @verify = verify
149
177
  end
150
178
 
151
179
  # @return [Array<ModelInfo>] sorted by label
152
180
  def models
153
- @models ||= model_classes.map { |k| ModelInfo.new(k, @configuration) }.sort_by(&:label)
181
+ @models ||= model_classes.map { |k| ModelInfo.new(k, @configuration, verify: @verify) }.sort_by(&:label)
154
182
  end
155
183
 
156
184
  # @return [ModelInfo, nil] for a model name, whether or not it is listed.
@@ -165,8 +193,8 @@ module PermissionForge
165
193
  private
166
194
 
167
195
  def build_info(name)
168
- klass = name.safe_constantize
169
- ModelInfo.new(klass, @configuration) if usable?(klass)
196
+ klass = PermissionForge::ModelLookup.model_for(name)
197
+ ModelInfo.new(klass, @configuration, verify: @verify) if klass && usable?(klass)
170
198
  end
171
199
 
172
200
  def model_classes
@@ -185,7 +213,7 @@ module PermissionForge
185
213
 
186
214
  def usable?(klass)
187
215
  klass.is_a?(Class) && klass < ActiveRecord::Base && !klass.abstract_class? &&
188
- !klass.name.to_s.start_with?('PermissionForge::') && klass.table_exists?
216
+ !klass.name.to_s.start_with?('PermissionForge::') && PermissionForge::SchemaRead.table?(klass, klass.table_name)
189
217
  rescue StandardError
190
218
  false
191
219
  end
@@ -200,16 +228,32 @@ module PermissionForge
200
228
  true
201
229
  end
202
230
 
203
- def eager_load_app_models
204
- return unless defined?(Rails) && Rails.root && Rails.respond_to?(:autoloaders)
231
+ def eager_load_app_models = self.class.eager_load_app_models
232
+
233
+ class << self
234
+ # Loads the application's models (`app/models`, through its main
235
+ # autoloader), so `ActiveRecord::Base.descendants` lists them: the
236
+ # one definition for whatever must see every model in a process that
237
+ # does not eager-load — the admin's model list, and
238
+ # `rake permission_forge:audit` (a single-table subclass's `encrypts`
239
+ # or a model on a table a rule joins is seen only once loaded:
240
+ # PermissionForge::EncryptedAttributes). A failure is logged at debug
241
+ # and skipped: the caller sees the models already loaded.
242
+ #
243
+ # @return [void]
244
+ def eager_load_app_models
245
+ return unless defined?(Rails) && Rails.root && Rails.respond_to?(:autoloaders)
246
+
247
+ eager_load_dir(Rails.autoloaders.main, Rails.root.join('app', 'models').to_s)
248
+ rescue StandardError => e
249
+ Rails.logger&.debug("[PermissionForge] model eager load skipped: #{e.class}")
250
+ end
205
251
 
206
- eager_load_dir(Rails.autoloaders.main, Rails.root.join('app', 'models').to_s)
207
- rescue StandardError => e
208
- Rails.logger&.debug("[PermissionForge] model eager load skipped: #{e.class}")
209
- end
252
+ private
210
253
 
211
- def eager_load_dir(loader, dir)
212
- loader.eager_load_dir(dir) if File.directory?(dir) && loader.respond_to?(:eager_load_dir)
254
+ def eager_load_dir(loader, dir)
255
+ loader.eager_load_dir(dir) if File.directory?(dir) && loader.respond_to?(:eager_load_dir)
256
+ end
213
257
  end
214
258
  end
215
259
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # The one definition of the label the admin shows for a model (a
6
+ # Resource): the Resource row's own label when an admin gave it one,
7
+ # else the host application's translation (`activerecord.models.<model>`,
8
+ # through `model_name.human` — never its English fallback), else the
9
+ # constant's name verbatim ("Billing::Invoice"). The plural ("Only show
10
+ # [Customers] where …") is the host's plural translation when it has
11
+ # one, else `permission_forge.ui.resource_plural` around the label:
12
+ # Rails' `pluralize` is English whatever the page's language.
13
+ #
14
+ # @example
15
+ # ModelLabel.for(resource).text # => "Clients" (the row's label), "Kunde" (host), or "Customer"
16
+ # ModelLabel.plural('Customer') # => "Customers" (host translation), else "Customer records"
17
+ #
18
+ # @api private
19
+ class ModelLabel
20
+ class << self
21
+ # @param resource_or_name [Resource, #name, String, Class, ModelCatalog::ModelInfo]
22
+ # @return [FieldLabel::Label] verbatim when the text is the constant's name
23
+ def for(resource_or_name)
24
+ label = row_label(resource_or_name)
25
+ return FieldLabel::Label.new(text: label, verbatim: false) if label
26
+
27
+ name = name_of(resource_or_name)
28
+ translated = host_translation(name)
29
+ translated ? FieldLabel::Label.new(text: translated, verbatim: false) : FieldLabel::Label.new(text: name, verbatim: true)
30
+ end
31
+
32
+ # @param name [String, Class, #name] the model
33
+ # @return [String]
34
+ def plural(name)
35
+ host_translation(name_of(name), count: 2) ||
36
+ I18n.t('permission_forge.ui.resource_plural', name: self.for(name).text, count: 2)
37
+ end
38
+
39
+ # The host's translation of the model's name, or nil.
40
+ #
41
+ # @return [String, nil]
42
+ def host_translation(name, count: 1)
43
+ klass = FieldLabel.klass_of(name)
44
+ return nil unless klass.respond_to?(:model_name)
45
+
46
+ value = klass.model_name.human(count: count, default: FieldLabel::MISSING)
47
+ value == FieldLabel::MISSING ? nil : value
48
+ end
49
+
50
+ private
51
+
52
+ def row_label(resource)
53
+ return nil unless resource.respond_to?(:label) && !resource.is_a?(Class)
54
+
55
+ resource.label.to_s.strip.presence
56
+ end
57
+
58
+ def name_of(resource_or_name)
59
+ return resource_or_name.name.to_s if resource_or_name.respond_to?(:name) && !resource_or_name.is_a?(String)
60
+
61
+ resource_or_name.to_s
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -11,10 +11,14 @@ module PermissionForge
11
11
  end
12
12
 
13
13
  # SQL-backed: searches whichever of the usual name/email columns exist.
14
+ # A column the user model encrypts (PermissionForge::EncryptedAttributes)
15
+ # holds ciphertext, which a LIKE would match by its JSON (`iv` found
16
+ # everyone): it is left out, and a deterministically encrypted one
17
+ # matches the whole query exactly (`where(email: query)` encrypts it).
14
18
  class Relation
15
19
  # Common person columns; only those the user table has are searched.
16
- SEARCH_COLUMNS = %w[name email full_name display_name first_name last_name firstname lastname
17
- username login].freeze
20
+ SEARCH_COLUMNS = (%w[name] + PersonLabel::EMAIL_ATTRIBUTES.map(&:to_s) +
21
+ %w[full_name display_name first_name last_name firstname lastname username login]).freeze
18
22
  # A query of digits also matches the id — up to a bigint's length.
19
23
  NUMERIC = /\A\d{1,18}\z/
20
24
 
@@ -31,11 +35,12 @@ module PermissionForge
31
35
  end
32
36
 
33
37
  def search(query)
34
- columns = SEARCH_COLUMNS & @relation.klass.column_names
38
+ model = @relation.klass
39
+ columns = PermissionForge::SchemaRead.columns_among(model, SEARCH_COLUMNS)
35
40
  id = query.to_i if query.match?(NUMERIC)
36
- return Relation.new(@relation.none) if columns.empty? && id.nil?
37
-
38
- Relation.new(@relation.where(word_conditions(columns, query.downcase, id)))
41
+ condition = word_conditions(PermissionForge::EncryptedAttributes.plain(model, columns), query.downcase, id)
42
+ scopes = [(@relation.where(condition) if condition), *exact_matches(model, columns, query)].compact
43
+ Relation.new(scopes.empty? ? @relation.none : scopes.reduce(:or))
39
44
  end
40
45
 
41
46
  def count
@@ -50,15 +55,22 @@ module PermissionForge
50
55
 
51
56
  # Every word must match some column, so "ana kras" finds Ana Krasniqi
52
57
  # whether the name lives in one column or in first/last name columns.
53
- # A numeric query also matches the id.
58
+ # A numeric query also matches the id. nil when nothing can match.
54
59
  def word_conditions(columns, needle, id)
55
60
  words = needle.split
56
- matched = words.map { |word| any_column(columns, word) }.compact.reduce(:and)
57
- return table[:id].eq(id) if matched.nil?
61
+ matched = words.map { |word| any_column(columns, word) }.compact.reduce(:and) if columns.any?
62
+ return id && table[:id].eq(id) if matched.nil?
58
63
 
59
64
  id ? matched.or(table[:id].eq(id)) : matched
60
65
  end
61
66
 
67
+ # The whole query against each deterministically encrypted column, as
68
+ # typed and lower-cased.
69
+ def exact_matches(model, columns, query)
70
+ values = [query, query.downcase].uniq
71
+ PermissionForge::EncryptedAttributes.deterministic(model, columns).map { |column| @relation.where(column => values) }
72
+ end
73
+
62
74
  def any_column(columns, word)
63
75
  like = "%#{ActiveRecord::Base.sanitize_sql_like(word)}%"
64
76
  columns.map { |column| table[column].lower.matches(like) }.reduce(:or)
@@ -9,7 +9,6 @@ module PermissionForge
9
9
  # (see PeopleCandidates).
10
10
  class PeopleDirectory
11
11
  FILTERS = %w[all ending expired muting no_access].freeze
12
- ENDING_WINDOW_DAYS = 7
13
12
  DEFAULT_PER_PAGE = 25
14
13
  # Beyond this the offset overflows the database's integer; the page is
15
14
  # empty long before.
@@ -19,9 +18,7 @@ module PermissionForge
19
18
 
20
19
  # Assignments whose people a filter keeps ('no_access' keeps everyone else).
21
20
  FILTER_ASSIGNMENTS = {
22
- 'ending' => lambda {
23
- PermissionForge::PermissionSetUser.where(expires_at: Time.current..ENDING_WINDOW_DAYS.days.from_now.end_of_day)
24
- },
21
+ 'ending' => -> { PermissionForge::PermissionSetUser.ending_within },
25
22
  'expired' => -> { PermissionForge::PermissionSetUser.expired },
26
23
  'muting' => lambda {
27
24
  PermissionForge::PermissionSetUser.active_assignments.where(permission_set_id: PermissionForge::PermissionSet.muting.select(:id))
@@ -55,7 +52,8 @@ module PermissionForge
55
52
  def search(query: nil, filter: 'all', **paging)
56
53
  filter = 'all' unless FILTERS.include?(filter.to_s)
57
54
  candidates = apply_query(apply_filter(base_candidates, filter.to_s), query)
58
- paginate(candidates, paging[:page].to_s.to_i.clamp(1, MAX_PAGE), paging.fetch(:per_page, DEFAULT_PER_PAGE))
55
+ page = ParamShapes.integer(paging[:page], default: 1, range: 1..).clamp(1, MAX_PAGE) # the one reader: "2abc" is page 1
56
+ paginate(candidates, page, paging.fetch(:per_page, DEFAULT_PER_PAGE))
59
57
  end
60
58
 
61
59
  # Counts for the filter chips (respecting the search query).
@@ -109,6 +107,26 @@ module PermissionForge
109
107
  (ids.is_a?(Array) ? ids : [ids]).filter_map { |id| cast_one(id, type) }.uniq
110
108
  end
111
109
 
110
+ # The request values that name none of `found` (cast ids, e.g. the
111
+ # people #find_all returned): each cast to the key type when it can
112
+ # be (99, not "99"), else as sent, trimmed and without NUL bytes.
113
+ # Blanks and values that are no id at all (an object, a list) are
114
+ # left out.
115
+ #
116
+ # @return [Array] distinct, in order
117
+ def self.unmatched(ids, found, configuration: PermissionForge.configuration)
118
+ model = configuration.user_model
119
+ type = model.type_for_attribute(model.primary_key)
120
+ (ids.is_a?(Array) ? ids : [ids]).filter_map do |id|
121
+ next unless id.is_a?(String) || id.is_a?(Integer)
122
+
123
+ cast = cast_one(id, type)
124
+ next id.to_s.delete("\0").strip.presence if cast.nil?
125
+
126
+ cast unless found.include?(cast)
127
+ end.uniq
128
+ end
129
+
112
130
  def self.cast_one(id, type)
113
131
  return nil unless id.is_a?(String) || id.is_a?(Integer)
114
132
 
@@ -14,29 +14,20 @@ module PermissionForge
14
14
  PermissionForge::PermissionSet.create!(attrs.merge(is_active: true, is_editable: true))
15
15
  end
16
16
 
17
- # An editable copy. A source in a locked Permission Set Group is copied
17
+ # An editable copy, named "<name> (copy)" (numbered when taken) unless
18
+ # a name is given. A source in a locked Permission Set Group is copied
18
19
  # outside it: the copy would change the locked group's list of sets.
19
- def self.duplicate(source, name:)
20
+ def self.duplicate(source, name: PermissionForge::PermissionSet.copy_name(source.name))
20
21
  attrs = { name: name, is_editable: true }
21
22
  attrs[:permission_set_group] = nil if EditableLock.locked?(source.permission_set_group)
22
23
  clone_unlocked(source, attrs)
23
24
  end
24
25
 
25
- # Case-insensitive.
26
- def self.name_taken?(name)
27
- PermissionForge::PermissionSet.where('LOWER(name) = ?', name.downcase).exists?
28
- end
26
+ # Case-insensitive, in the current tenant (PermissionSet.name_taken?).
27
+ def self.name_taken?(name) = PermissionForge::PermissionSet.name_taken?(name)
29
28
 
30
29
  # `base`, else "`base` 2", "`base` 3", ... — the first free name.
31
- def self.unique_name(base)
32
- candidate = base
33
- suffix = 2
34
- while name_taken?(candidate)
35
- candidate = "#{base} #{suffix}"
36
- suffix += 1
37
- end
38
- candidate
39
- end
30
+ def self.unique_name(base) = PermissionForge::PermissionSet.unique_name(base)
40
31
 
41
32
  # Every copied row is created unlocked, so its create event holds the
42
33
  # state it has (no second, unaudited write).
@@ -5,24 +5,70 @@ module PermissionForge
5
5
  # Display name / email / initials for a host-app user in the admin UI.
6
6
  #
7
7
  # Honors config.user_label_formatter for the name. Otherwise tries :name,
8
- # :full_name, :display_name, then composes first_name/last_name (or firstname/lastname). Attribute
8
+ # :full_name, :display_name, then composes first_name/last_name (or firstname/lastname), then
9
+ # the email (EMAIL_ATTRIBUTES: `email`, or Rails 8's `email_address`). Attribute
9
10
  # reads go raw-column-first (read_attribute_before_type_cast) so
10
11
  # MaskedAttributes on the User model never garbles admin lists; virtual
11
- # attributes and blank columns fall back to the public reader.
12
+ # attributes and blank columns fall back to the public reader. A column
13
+ # the model encrypts (`encrypts :email`) is read decrypted through its
14
+ # attribute type (PermissionForge::EncryptedAttributes), never as the
15
+ # ciphertext it stores — for the admin's display, which is never cached.
16
+ # `decrypt: false` (the default profile_builder, whose profile is cached
17
+ # with the permission hash in the cache store) reads such a column as
18
+ # nil instead: neither its ciphertext nor its plaintext. Nor does it ask
19
+ # a public reader for a name or an email ({LABEL_ATTRIBUTES}) while the
20
+ # model encrypts any of them: a `name` composed from encrypted columns
21
+ # would hand over their plaintext.
12
22
  class PersonLabel
13
23
  NAME_METHODS = %i[name full_name display_name].freeze
14
-
15
- def initialize(user, configuration: PermissionForge.configuration)
24
+ # The one definition of "the user's email attribute", in order: `email`,
25
+ # then `email_address` (the column Rails 8's authentication generator
26
+ # creates). Labels, people search, bulk matching and the default
27
+ # profile_builder all read it from here.
28
+ EMAIL_ATTRIBUTES = %i[email email_address].freeze
29
+ # The split name attributes composed into a name.
30
+ NAME_PARTS = %i[first_name last_name firstname lastname].freeze
31
+ # Every attribute a name or an email is read from.
32
+ LABEL_ATTRIBUTES = (NAME_METHODS + NAME_PARTS + EMAIL_ATTRIBUTES).freeze
33
+
34
+ # @param user [Object]
35
+ # @param configuration [PermissionForge::Configuration]
36
+ # @param decrypt [Boolean] false: an encrypted column reads as nil
37
+ def initialize(user, configuration: PermissionForge.configuration, decrypt: true)
16
38
  @user = user
17
39
  @configuration = configuration
40
+ @decrypt = decrypt
41
+ end
42
+
43
+ # A user attribute read raw-column-first (read_attribute_before_type_cast,
44
+ # past MaskedAttributes), the public reader for a virtual attribute or a
45
+ # blank column; nil when the user has neither. The one definition for the
46
+ # admin's labels (pf_raw_user_attribute) and the default profile_builder.
47
+ #
48
+ # @param user [Object]
49
+ # @param attr [Symbol, String]
50
+ # @return [Object, nil]
51
+ def self.raw_value(user, attr)
52
+ new(user).raw_value(attr)
53
+ end
54
+
55
+ # @see .raw_value
56
+ def raw_value(attr)
57
+ attr_name = attr.to_s
58
+ if readable_column?(attr_name)
59
+ value = column_value(attr_name)
60
+ return value if value.to_s.strip.present? || encrypted?(attr_name)
61
+ end
62
+ @user.try(attr) unless withheld_reader?(attr_name)
18
63
  end
19
64
 
20
65
  def name
21
66
  @name ||= formatted_name || default_name || email || fallback
22
67
  end
23
68
 
69
+ # The first EMAIL_ATTRIBUTES value present, stripped.
24
70
  def email
25
- @email ||= raw_value(:email).to_s.strip.presence
71
+ @email ||= first_present(EMAIL_ATTRIBUTES)
26
72
  end
27
73
 
28
74
  # Email shown under the name — omitted when the name already is the email.
@@ -30,8 +76,17 @@ module PermissionForge
30
76
  email unless email == name
31
77
  end
32
78
 
79
+ # Two letters for the avatar: the first and last word of a name ("AK"),
80
+ # or the first two letters of a single word. A label that is the email
81
+ # takes them from its local part ("dave@acme.test" => "DA"), never from
82
+ # the domain.
33
83
  def initials
34
- initial_letters(name.to_s.scan(/[[:alnum:]]+/)).join.upcase.presence || '?'
84
+ initial_letters(initials_source.scan(/[[:alnum:]]+/)).join.upcase.presence || '?'
85
+ end
86
+
87
+ # Whether the label is the "User #<id>" fallback: neither a name nor an email.
88
+ def fallback?
89
+ name == fallback
35
90
  end
36
91
 
37
92
  # Lower-cased "name email" for in-memory search.
@@ -39,12 +94,29 @@ module PermissionForge
39
94
  "#{name} #{email}".downcase
40
95
  end
41
96
 
97
+ # The first word of a person's name, for sentences ("What Ana can do").
98
+ # A label that is not a name (the email, or the "User #2" fallback) is
99
+ # used whole: its first word would read as a placeholder ("User").
42
100
  def first_name
101
+ return name unless personal_name?
102
+
43
103
  name.to_s.split(/\s+/).first.presence || name
44
104
  end
45
105
 
46
106
  private
47
107
 
108
+ # Whether the label comes from a name (formatter or name attributes),
109
+ # not from the email or the id fallback.
110
+ def personal_name?
111
+ label = name
112
+ label != email && label != fallback
113
+ end
114
+
115
+ def initials_source
116
+ label = name.to_s
117
+ label == email ? label.split('@').first.to_s : label
118
+ end
119
+
48
120
  # First letters of the first and last word, or the first two letters of a single word.
49
121
  def initial_letters(words)
50
122
  words.size > 1 ? [words.first[0], words.last[0]] : [words.first.to_s[0, 2]]
@@ -58,11 +130,15 @@ module PermissionForge
58
130
  end
59
131
 
60
132
  def default_name
61
- NAME_METHODS.each do |method|
62
- value = raw_value(method).to_s.strip
133
+ first_present(NAME_METHODS) || composed(*NAME_PARTS.first(2)) || composed(*NAME_PARTS.last(2))
134
+ end
135
+
136
+ def first_present(attrs)
137
+ attrs.each do |attr|
138
+ value = raw_value(attr).to_s.strip
63
139
  return value unless value.empty?
64
140
  end
65
- composed(:first_name, :last_name) || composed(:firstname, :lastname)
141
+ nil
66
142
  end
67
143
 
68
144
  def composed(first, last)
@@ -73,13 +149,26 @@ module PermissionForge
73
149
  I18n.t('permission_forge.shared.user_fallback', id: @user.id)
74
150
  end
75
151
 
76
- def raw_value(attr)
77
- attr_name = attr.to_s
78
- if readable_column?(attr_name)
79
- value = @user.read_attribute_before_type_cast(attr_name)
80
- return value if value.to_s.strip.present?
81
- end
82
- @user.try(attr)
152
+ # The stored value; decrypted for an encrypted column (nil when this
153
+ # application cannot decrypt it: the reader would raise), nil for one
154
+ # under `decrypt: false`.
155
+ def column_value(attr_name)
156
+ value = @user.read_attribute_before_type_cast(attr_name)
157
+ return value if value.nil? || !encrypted?(attr_name)
158
+
159
+ PermissionForge::EncryptedAttributes.decrypt(@user.class, attr_name, value) if @decrypt
160
+ end
161
+
162
+ def encrypted?(attr_name)
163
+ PermissionForge::EncryptedAttributes.encrypted?(@user.class, attr_name)
164
+ end
165
+
166
+ # Under `decrypt: false`, a name or email attribute's public reader is
167
+ # not asked while the model encrypts any LABEL_ATTRIBUTES: the reader
168
+ # may read them decrypted.
169
+ def withheld_reader?(attr_name)
170
+ !@decrypt && LABEL_ATTRIBUTES.include?(attr_name.to_sym) &&
171
+ LABEL_ATTRIBUTES.any? { |attribute| encrypted?(attribute) }
83
172
  end
84
173
 
85
174
  # True when attr_name is a loaded column on an AR-like user (guards