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
@@ -4,7 +4,10 @@ module PermissionForge
4
4
  module Admin
5
5
  module ChangeSetParts
6
6
  # New fields: columns without a ResourceField row, picked one by one or
7
- # through a bulk selection.
7
+ # through a bulk selection. Only the model's managed fields
8
+ # (PermissionForge::ManagedFields — the names the presets, Sync and the
9
+ # generator write rows for) can be added: a posted `id`, an ignored
10
+ # field or a name the model does not have is no row.
8
11
  class NewFields < Base
9
12
  def call
10
13
  explicit = section('new_fields').flat_map { |resource_id, columns| explicit_new_fields(resource_id, columns) }
@@ -20,7 +23,7 @@ module PermissionForge
20
23
  existing = existing_names(resource)
21
24
  columns.filter_map do |column, attrs|
22
25
  name = column.to_s
23
- next if existing.include?(name) || !attrs.is_a?(Hash)
26
+ next if existing.include?(name) || !attrs.is_a?(Hash) || !addable?(resource, name)
24
27
 
25
28
  build_new_field(resource, name, attrs['level'].to_s)
26
29
  end
@@ -36,7 +39,21 @@ module PermissionForge
36
39
 
37
40
  # Bulk-selected columns that have neither a field nor an explicit pick.
38
41
  def bulk_columns(resource)
39
- Array(bulk['keys']).map(&:to_s).uniq - existing_names(resource) - explicit_columns(resource)
42
+ (Array(bulk['keys']).map(&:to_s).uniq & addable_names(resource)) - existing_names(resource) - explicit_columns(resource)
43
+ end
44
+
45
+ def addable?(resource, name)
46
+ addable_names(resource).include?(name)
47
+ end
48
+
49
+ # The model's managed fields; none when the Resource names no model
50
+ # this app can load.
51
+ def addable_names(resource)
52
+ @addable_names ||= {}
53
+ @addable_names[resource.id] ||= begin
54
+ model = PermissionForge::ModelLookup.model_for(resource.name)
55
+ model ? PermissionForge::ManagedFields.new(model).names : []
56
+ end
40
57
  end
41
58
 
42
59
  def existing_names(resource)
@@ -47,15 +64,25 @@ module PermissionForge
47
64
  (params.dig('new_fields', resource.id.to_s) || {}).keys.map(&:to_s)
48
65
  end
49
66
 
67
+ # The row comes from the one row builder (ResourceFieldGenerator::
68
+ # RowBuilder: privacy defaults, timestamps, the recommended strategy),
69
+ # so the change records the level the row really gets — Masked for a
70
+ # sensitive column added Read-only under gdpr_defaults, Read-only for
71
+ # a timestamp added Editable. A blank name is no row.
50
72
  def build_new_field(resource, column, level)
51
- return nil unless valid_level?(level) && level != 'leave'
52
-
53
- level = Values.effective_level(level, resource, muting: muting?)
54
- field = resource.resource_fields.build(name: column, label: column.humanize, is_active: true, is_editable: true)
55
- field.assign_attributes(flags_for(level))
56
- field.mask_strategy = sensitive.match(column)&.strategy || 'full'
57
- change(kind: :new_field, record: field, attribute: 'level', from: nil, to: [level, field.mask_strategy],
58
- resource: resource)
73
+ return nil unless valid_level?(level) && FieldLevel.row?(level, muting: muting?)
74
+
75
+ attrs = rows_of(resource).attrs(column, level)
76
+ return nil unless attrs
77
+
78
+ field = resource.resource_fields.build(attrs)
79
+ change(kind: :new_field, record: field, attribute: 'level', from: nil,
80
+ to: [FieldLevel.level_of(field, muting: muting?), field.mask_strategy], resource: resource)
81
+ end
82
+
83
+ def rows_of(resource)
84
+ @rows_of ||= {}
85
+ @rows_of[resource.id] ||= Services::ResourceFieldGenerator.rows(resource, muting: muting?, sensitive: sensitive)
59
86
  end
60
87
  end
61
88
  end
@@ -10,9 +10,12 @@ module PermissionForge
10
10
  TEXT = %w[label description].freeze
11
11
  READ_DEPENDENT = %w[edit_access delete_access view_all modify_all].freeze
12
12
 
13
- def initialize(resource, muting:)
13
+ # @param refused [Array] collects a ChangeSet::Refusal for each flag
14
+ # the request turns on and the cascades keep off
15
+ def initialize(resource, muting:, refused: [])
14
16
  @resource = resource
15
17
  @muting = muting
18
+ @refused = refused
16
19
  end
17
20
 
18
21
  def changes(attrs)
@@ -29,6 +32,7 @@ module PermissionForge
29
32
  # part of) the request.
30
33
  def flag_changes(requested)
31
34
  wanted = wanted_flags(requested)
35
+ note_refused(requested, wanted)
32
36
  FLAGS.filter_map do |flag|
33
37
  to = wanted[flag]
34
38
  change(flag, to, kind: :resource_flag, cascaded: requested.fetch(flag, :absent) != to).assign
@@ -41,6 +45,18 @@ module PermissionForge
41
45
  wanted
42
46
  end
43
47
 
48
+ # A flag the request turns on that the cascades keep off (Edit without
49
+ # Read, say: a form the UI never sends) is not a change, so the review
50
+ # would say nothing about it. Noted, with what it needs.
51
+ def note_refused(requested, wanted)
52
+ requested.each do |flag, value|
53
+ next unless value && !wanted[flag] && !@resource.public_send(flag)
54
+
55
+ needs = flag == 'modify_all' && wanted['read_access'] ? :view_all_and_edit : :read
56
+ @refused << ChangeSet::Refusal.new(resource: @resource, attribute: flag, needs: needs)
57
+ end
58
+ end
59
+
44
60
  def cascade!(flags)
45
61
  READ_DEPENDENT.each { |flag| flags[flag] = false } unless flags['read_access']
46
62
  flags['modify_all'] = false unless flags['edit_access'] && flags['view_all']
@@ -10,7 +10,7 @@ module PermissionForge
10
10
  resource = unlocked_resource(id)
11
11
  next [] unless resource && attrs.is_a?(Hash)
12
12
 
13
- ResourceEdit.new(resource, muting: muting?).changes(attrs)
13
+ ResourceEdit.new(resource, muting: muting?, refused: refused).changes(attrs)
14
14
  end
15
15
  end
16
16
  end
@@ -6,6 +6,7 @@ module PermissionForge
6
6
  # Set header: name, description, active, group.
7
7
  class SetAttrs < Base
8
8
  SET_ATTRS = %w[name description is_active permission_set_group_id].freeze
9
+ TEXT_ATTRS = %w[name description].freeze
9
10
  # A group id that is neither blank (no group) nor digits.
10
11
  NOT_A_GROUP = Object.new.freeze
11
12
  TEXT_CAST = ->(value) { value.to_s.strip.presence if value.is_a?(String) || value.is_a?(Numeric) }
@@ -36,7 +37,10 @@ module PermissionForge
36
37
  EditableLock.locked_group_for_move(from: permission_set.permission_set_group_id, to: group_id).present?
37
38
  end
38
39
 
40
+ # A blank name over a generated one, or a blank description over
41
+ # none, is no change (Prefill.posted).
39
42
  def cast(attr, value)
43
+ value = Prefill.posted(permission_set, attr, value) if TEXT_ATTRS.include?(attr)
40
44
  CASTS.fetch(attr, TEXT_CAST).call(value)
41
45
  end
42
46
  end
@@ -5,14 +5,20 @@ module PermissionForge
5
5
  module ChangeSetParts
6
6
  # Stateless value rules shared by the ChangeSet collaborators.
7
7
  module Values
8
+ # A flag of the change set's params (`pf[...]`), read as every
9
+ # admin and API flag is (ParamShapes.boolean: the forms post "0" and
10
+ # "1"); nil is false.
11
+ #
12
+ # @raise [ParamShapes::InvalidFlag] for anything else: ActiveModel's
13
+ # cast read "nope" as true (the admin answers with a flash)
8
14
  def self.truthy?(value)
9
- ActiveModel::Type::Boolean.new.cast(value) == true
15
+ value.nil? ? false : ParamShapes.flag('pf', value)
10
16
  end
11
17
 
12
- # Edit on a resource without Edit is downgraded to Read (muting sets
13
- # have inverted semantics, so Edit is always available there).
18
+ # Edit on a resource without Edit is downgraded to Read
19
+ # (FieldLevel.capped, the one definition).
14
20
  def self.effective_level(level, resource, muting:)
15
- level == 'edit' && !(muting || resource.edit_access) ? 'read' : level
21
+ FieldLevel.capped(level, resource_edit: resource.edit_access, muting: muting)
16
22
  end
17
23
  end
18
24
  end
@@ -2,18 +2,28 @@
2
2
 
3
3
  module PermissionForge
4
4
  module Admin
5
- # Creates ResourceFields for model columns and virtual fields at one level,
6
- # applying privacy defaults: sensitive names start Masked when gdpr_defaults
7
- # is on, and the muting 'hide_sensitive' level hides only sensitive names.
5
+ # Creates ResourceFields for model columns and virtual fields at one
6
+ # level: the admin's presets ("Add resource"), "Add all new" and "Sync
7
+ # fields". The rows themselves come from the one row builder
8
+ # (Services::ResourceFieldGenerator::RowBuilder): privacy defaults,
9
+ # timestamps, the recommended strategy, the label. This class only turns
10
+ # a preset into a level per column — the muting 'hide_sensitive' preset
11
+ # hides the sensitive names and leaves the others alone — and skips the
12
+ # levels that are no row (Unchanged, a level the set's kind lacks).
13
+ #
14
+ # @api private
8
15
  class ColumnFields
16
+ # The muting preset that hides the sensitive names and leaves the others.
17
+ HIDE_SENSITIVE = 'hide_sensitive'
18
+
9
19
  def initialize(resource, muting:, sensitive: SensitiveColumns.new, configuration: PermissionForge.configuration)
10
20
  @resource = resource
11
21
  @muting = muting
12
22
  @sensitive = sensitive
13
- @configuration = configuration
23
+ @rows = Services::ResourceFieldGenerator.rows(resource, muting: muting, configuration: configuration, sensitive: sensitive)
14
24
  end
15
25
 
16
- # @return [Integer] number of fields created (columns whose level resolves to nil are skipped)
26
+ # @return [Integer] number of fields created (columns whose level resolves to nil, and blank names, are skipped)
17
27
  def create(columns, level)
18
28
  columns.filter_map { |column| create_field(column, level) }.size
19
29
  end
@@ -21,27 +31,18 @@ module PermissionForge
21
31
  private
22
32
 
23
33
  def create_field(column, level)
24
- match = @sensitive.match(column)
25
- column_level = @muting ? muting_level(level, match) : granting_level(level, match)
34
+ column_level = column_level(column, level)
26
35
  return unless column_level
27
36
 
28
- attrs = FieldLevel.flags_for(column_level, muting: @muting)
29
- @resource.resource_fields.create!(attrs.merge(name: column, label: column.humanize, is_active: true, is_editable: true,
30
- mask_strategy: match&.strategy || 'full'))
31
- end
32
-
33
- def muting_level(level, match)
34
- return 'hide' if level == 'hide_sensitive' && match
35
- return nil if %w[hide_sensitive leave].include?(level) || level.blank?
36
-
37
- FieldLevel.valid?(level, muting: true) ? level : nil
37
+ attrs = @rows.attrs(column, column_level)
38
+ @resource.resource_fields.create!(attrs) if attrs
38
39
  end
39
40
 
40
- def granting_level(level, match)
41
- return nil unless FieldLevel.valid?(level, muting: false)
42
- return 'masked' if match && %w[read edit].include?(level) && @configuration.gdpr_defaults
41
+ # @return [String, nil] the level the column's row gets, nil for no row
42
+ def column_level(column, level)
43
+ return level if FieldLevel.row?(level, muting: @muting)
43
44
 
44
- level
45
+ 'hide' if @muting && level == HIDE_SENSITIVE && @sensitive.sensitive?(column)
45
46
  end
46
47
  end
47
48
  end
@@ -1,13 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ipaddr'
4
-
5
3
  module PermissionForge
6
4
  module Admin
7
5
  # Activation conditions form (Conditions tab): turns the posted IP list,
8
6
  # hour window and session flag into PermissionSet attributes, collecting
9
- # per-input errors (:ips => [messages], :flag => message).
7
+ # per-input errors (:ips => [messages], :hours => message, :flag =>
8
+ # message). What it refuses is what ActivationConditions cannot judge,
9
+ # for the same reason: an entry ActivationConditions.ip_range does not
10
+ # read as an address, a window whose HoursWindow#problem is not nil.
10
11
  class ConditionsForm
12
+ # The hours the tab shows before anything is chosen.
13
+ DEFAULT_HOURS = { from: 8, to: 18 }.freeze
11
14
  FLAG_FORMAT = /\A[\w.:-]{1,100}\z/
12
15
 
13
16
  attr_reader :errors
@@ -18,25 +21,41 @@ module PermissionForge
18
21
  @errors = {}
19
22
  end
20
23
 
21
- # Evaluated in order, so errors are keyed :ips before :flag.
24
+ # Evaluated in order, so errors are keyed :ips, then :hours, then :flag.
22
25
  def attributes
23
26
  @attributes ||= {
24
27
  allowed_ips: ips.join(',').presence,
25
28
  allowed_hours_start: hour(:allowed_hours_start),
26
29
  allowed_hours_end: hour(:allowed_hours_end),
27
30
  requires_session_flag: session_flag
28
- }
31
+ }.tap { check_hours }
29
32
  end
30
33
 
31
34
  private
32
35
 
36
+ # The entries as the engine reads them (ActivationConditions.entries:
37
+ # commas, newlines or spaces between them); an entry
38
+ # ActivationConditions.ip_range does not read as an address is refused.
33
39
  def ips
34
- list = @params[:allowed_ips].to_s.split(/[\n,]/).map(&:strip).reject(&:empty?)
35
- bad = list.each_with_index.reject { |ip, _| valid_ip?(ip) }
40
+ list = PermissionForge::ActivationConditions.entries(@params[:allowed_ips])
41
+ bad = list.each_with_index.reject { |ip, _| PermissionForge::ActivationConditions.ip_range(ip) }
36
42
  @errors[:ips] = bad.map { |ip, i| I18n.t('permission_forge.errors.admin.ip', line: i + 1, ip: ip) } if bad.any?
37
43
  list
38
44
  end
39
45
 
46
+ # A window nobody can judge (HoursWindow#problem: one hour of the two,
47
+ # an hour outside 0–23, the same hour twice) is refused by the model
48
+ # (PermissionSet#hours_window_is_judgeable); said here too, keyed
49
+ # :hours, so the form shows it next to the selects.
50
+ def check_hours
51
+ problem = posted_window.problem
52
+ @errors[:hours] = I18n.t("permission_forge.errors.admin.#{problem}") if problem
53
+ end
54
+
55
+ def posted_window
56
+ PermissionForge::ActivationConditions::HoursWindow.new(from: hour(:allowed_hours_start), to: hour(:allowed_hours_end))
57
+ end
58
+
40
59
  public
41
60
 
42
61
  # Whether the hour window was switched on in the posted form.
@@ -50,6 +69,27 @@ module PermissionForge
50
69
  RecordId.cast(@params[key])
51
70
  end
52
71
 
72
+ # The hour window the Conditions tab shows: the posted hours of a
73
+ # refused form (`form`), else the set's (HoursWindow; the tab asks it
74
+ # whether it is set, wraps midnight or is broken, never compares the
75
+ # hours itself).
76
+ #
77
+ # @param set [PermissionSet]
78
+ # @param form [ConditionsForm, nil]
79
+ # @return [ActivationConditions::HoursWindow]
80
+ def self.window(set, form)
81
+ stored = set.hours_window
82
+ return stored unless form
83
+
84
+ PermissionForge::ActivationConditions::HoursWindow.new(from: form.submitted_hour(:allowed_hours_start) || stored.from,
85
+ to: form.submitted_hour(:allowed_hours_end) || stored.to)
86
+ end
87
+
88
+ # Whether the tab shows the hour window switched on: as posted, else as stored.
89
+ def self.hours_on?(set, form)
90
+ form ? form.hours_enabled? : set.hours_window.set?
91
+ end
92
+
53
93
  private
54
94
 
55
95
  # nil unless the hour window is enabled.
@@ -65,14 +105,6 @@ module PermissionForge
65
105
  @errors[:flag] = I18n.t('permission_forge.errors.admin.flag')
66
106
  nil
67
107
  end
68
-
69
- # IPAddr::Error is an ArgumentError.
70
- def valid_ip?(value)
71
- IPAddr.new(value)
72
- true
73
- rescue ArgumentError
74
- false
75
- end
76
108
  end
77
109
  end
78
110
  end
@@ -8,10 +8,11 @@ module PermissionForge
8
8
  #
9
9
  # Reads the route table once per instance. Routes without a static
10
10
  # controller and action (mounted engines and Rack apps, redirects, dynamic
11
- # segments), internal routes, excluded controllers and actions whose name
12
- # cannot be a ResourceAction key are skipped. A
13
- # controller that fails to load, or a host resolver that raises, drops
14
- # that one entry instead of the whole catalog.
11
+ # segments), internal routes and excluded controllers are skipped; actions
12
+ # whose name cannot be a ResourceAction key are left out of #entries (no
13
+ # row can manage them) but kept in #mapped_entries, since the guard still
14
+ # checks them. A controller that fails to load, or a host resolver that
15
+ # raises, drops that one entry instead of the whole catalog.
15
16
  #
16
17
  # @example
17
18
  # catalog = ControllerCatalog.new
@@ -26,11 +27,16 @@ module PermissionForge
26
27
  # One mapped controller action. `key` is the action key the guard asks
27
28
  # for; `crud` is :read / :create / :edit / :delete when the action means
28
29
  # a CRUD permission, nil for a custom action. `guarded` is whether the
29
- # controller runs the guard (ControllerActions.guarded?).
30
+ # controller runs the guard (ControllerActions.guarded?). `manageable`
31
+ # is whether a row can manage it (Mapping::Target#manageable?).
30
32
  Entry = Struct.new(:controller_path, :action_name, :verbs, :paths, :resource, :key, :crud, :guarded,
31
- keyword_init: true) do
33
+ :manageable, keyword_init: true) do
32
34
  def crud? = !crud.nil?
33
35
  def guarded? = guarded == true
36
+ def manageable? = manageable == true
37
+
38
+ # ['Customer', 'export']: what a ResourceAction row of that resource and name manages
39
+ def pair = [resource, key]
34
40
 
35
41
  # 'customers#export'
36
42
  def endpoint = "#{controller_path}##{action_name}"
@@ -82,13 +88,23 @@ module PermissionForge
82
88
  # controller_path => conventional resource, filled by Mapping: one
83
89
  # lookup per controller instead of one per route.
84
90
  @conventions = {}
91
+ @skipped = []
85
92
  end
86
93
 
87
- # @return [Array<Entry>] one per mapped (controller_path, action_name),
88
- # sorted by resource, key, controller and action
94
+ # @return [Array<Entry>] one per mapped (controller_path, action_name)
95
+ # a row can manage, sorted by resource, key, controller and action
89
96
  def entries
90
- @entries ||= routed_actions.filter_map { |(path, action), route| build_entry(path, action, route) }
91
- .sort_by(&:sort_key).freeze
97
+ @entries ||= mapped_entries.select(&:manageable?).freeze
98
+ end
99
+
100
+ # Every mapped controller action, those whose name cannot be an action
101
+ # key included: the guard checks them too (with no row possible, the
102
+ # strategy for unmanaged actions decides).
103
+ #
104
+ # @return [Array<Entry>] sorted as #entries
105
+ def mapped_entries
106
+ @mapped_entries ||= routed_actions.filter_map { |(path, action), route| build_entry(path, action, route) }
107
+ .sort_by(&:sort_key).freeze
92
108
  end
93
109
 
94
110
  # @param resource_name [String, Symbol] e.g. 'Customer'
@@ -104,6 +120,17 @@ module PermissionForge
104
120
  for_resource(resource_name).map(&:key).uniq
105
121
  end
106
122
 
123
+ # What reading the routes could not judge: a controller that fails to
124
+ # load (listed, but whether it runs the guard is unknown) and a
125
+ # controller action dropped because its controller, the resolver or the
126
+ # mapping raised. The log has the first error.
127
+ #
128
+ # @return [Array<String>] "customers#export: RuntimeError", "broken: NameError"
129
+ def skipped
130
+ mapped_entries
131
+ @skipped.dup.freeze
132
+ end
133
+
107
134
  # @return [Boolean] whether any listed controller runs the guard (see
108
135
  # ControllerActions.guarded?). When false, permitting or blocking an
109
136
  # action changes nothing at runtime.
@@ -130,11 +157,11 @@ module PermissionForge
130
157
  klass = controller_class(path)
131
158
  target = ControllerActions::Mapping.call(controller_path: path, action_name: action, controller_class: klass,
132
159
  configuration: @configuration, conventions: @conventions)
133
- return nil unless target&.manageable?
160
+ return nil unless target
134
161
 
135
162
  Entry.new(controller_path: path, action_name: action, verbs: route[:verbs].sort, paths: route[:paths].sort,
136
163
  resource: target.resource, key: target.action, crud: target.crud,
137
- guarded: ControllerActions.guarded?(klass))
164
+ guarded: ControllerActions.guarded?(klass), manageable: target.manageable?)
138
165
  rescue StandardError => e
139
166
  log_skipped("#{path}##{action}", e)
140
167
  nil
@@ -153,9 +180,10 @@ module PermissionForge
153
180
  nil
154
181
  end
155
182
 
156
- # Logged once per catalog: a raising resolver would otherwise repeat the
157
- # same line for every route.
183
+ # Kept for #skipped; logged once per catalog: a raising resolver would
184
+ # otherwise repeat the same line for every route.
158
185
  def log_skipped(what, error)
186
+ @skipped << "#{what}: #{error.class}"
159
187
  return if @logged
160
188
 
161
189
  @logged = true
@@ -23,10 +23,20 @@ module PermissionForge
23
23
  # those changes goes the other way: adding a holder or keeping them
24
24
  # longer narrows, removing one or ending their assignment sooner widens.
25
25
  #
26
+ # "Locked" has one definition, here, for the controllers, the API, the
27
+ # read models and the views alike: .locked?(record) walks the chain, and
28
+ # .permits?(record, action) says whether the admin may offer an action
29
+ # on a record — the one table the controllers' refusals and the views'
30
+ # "show, disable or leave out" both read (html/locked_pages_test.rb
31
+ # checks every control of every page rendered for a locked record
32
+ # against it).
33
+ #
26
34
  # @example
27
35
  # EditableLock.locked_record(field) # => the locked Resource or Permission Set, or nil
36
+ # EditableLock.locked?(field) # => true when the field, its Resource or its set is locked
28
37
  # EditableLock.chain(rule) # => [rule, resource, permission_set]
29
38
  # EditableLock.widening_lock(set, :revoke) # => the set when it is locked and muting, else nil
39
+ # EditableLock.permits?(assignment, :revoke) # => false on a locked Muting Permission Set
30
40
  module EditableLock
31
41
  # What each record belongs to, for the lock: the first that is present.
32
42
  PARENTS = {
@@ -42,6 +52,13 @@ module PermissionForge
42
52
  # locked container refuses the deletion (a set in a locked group).
43
53
  CONTAINERS = { 'PermissionForge::PermissionSet' => :permission_set_group }.freeze
44
54
 
55
+ # The changes to who holds a set (.widens_access?): judged by direction,
56
+ # on the set the record is or belongs to.
57
+ HOLDER_CHANGES = %i[grant revoke change_expiry].freeze
58
+ # Actions a lock never refuses: a copy is editable, a review changes no
59
+ # access, offboarding and a cache refresh narrow or change nothing.
60
+ ALWAYS_PERMITTED = %i[duplicate review revoke_all refresh consent erasure].freeze
61
+
45
62
  module_function
46
63
 
47
64
  # @param record [ActiveRecord::Base, nil]
@@ -59,17 +76,47 @@ module PermissionForge
59
76
  # @return [ActiveRecord::Base, nil] the first locked record in their chains
60
77
  def locked_record(*records)
61
78
  records.flatten.compact.each do |record|
62
- locked = chain(record).find { |link| locked?(link) }
79
+ locked = chain(record).find { |link| flagged?(link) }
63
80
  return locked if locked
64
81
  end
65
82
  nil
66
83
  end
67
84
 
68
- # @return [Boolean] whether the record itself is flagged is_editable: false
85
+ # Whether the record is locked: itself, or what it belongs to (its
86
+ # chain) flagged is_editable: false. The one answer for a view's
87
+ # "locked" and a controller's refusal.
88
+ #
89
+ # @param record [ActiveRecord::Base, nil]
90
+ # @return [Boolean]
69
91
  def locked?(record)
92
+ !locked_record(record).nil?
93
+ end
94
+
95
+ # @return [Boolean] whether the record itself carries the flag (is_editable: false)
96
+ def flagged?(record)
70
97
  record.respond_to?(:is_editable) && record.is_editable == false
71
98
  end
72
99
 
100
+ # Whether the admin may offer `action` on `record`: a change to the
101
+ # holders of a set (:grant, :revoke, :change_expiry with from:/to:)
102
+ # goes by direction (.widening_lock); ALWAYS_PERMITTED actions always
103
+ # may; :delete also asks the record's container (.container_of); any
104
+ # other action (:edit, :add, :toggle, :sync, :acknowledge, ...) changes
105
+ # what the record holds and is refused while the record is locked.
106
+ #
107
+ # @param record [ActiveRecord::Base] the record the action is on (an
108
+ # assignment or its set for a holder change)
109
+ # @param action [Symbol]
110
+ # @param expiry [Hash] from:, to: for :change_expiry
111
+ # @return [Boolean]
112
+ def permits?(record, action, **expiry)
113
+ return true if ALWAYS_PERMITTED.include?(action)
114
+ return holder_change_permitted?(record, action, **expiry) if HOLDER_CHANGES.include?(action)
115
+ return false if locked?(record)
116
+
117
+ action != :delete || !flagged?(container_of(record))
118
+ end
119
+
73
120
  # Moving a set from one Permission Set Group to another changes both
74
121
  # groups' lists of sets: refused when either is locked (the group
75
122
  # page's add/remove set refuses the same).
@@ -81,7 +128,7 @@ module PermissionForge
81
128
  to = RecordId.cast(to)
82
129
  return nil if to == from
83
130
 
84
- PermissionForge::PermissionSetGroup.where(id: [from, to].compact).detect { |group| locked?(group) }
131
+ PermissionForge::PermissionSetGroup.where(id: [from, to].compact).detect { |group| flagged?(group) }
85
132
  end
86
133
 
87
134
  # The container whose list deleting `record` would change, or nil.
@@ -92,6 +139,21 @@ module PermissionForge
92
139
  association && record.public_send(association)
93
140
  end
94
141
 
142
+ # The set a holder change is on: the record when it is one, else the
143
+ # set an assignment belongs to; nil for a group or its membership.
144
+ def permission_set_of(record)
145
+ set = record.respond_to?(:permission_set) ? record.permission_set : record
146
+ set if set.is_a?(PermissionForge::PermissionSet)
147
+ end
148
+
149
+ # A holder change on a set goes by direction (.widening_lock); one on a
150
+ # Permission Set Group (a membership, or the group) has none — a
151
+ # locked group freezes its members both ways.
152
+ def holder_change_permitted?(record, action, **expiry)
153
+ set = permission_set_of(record)
154
+ set ? widening_lock(set, action, **expiry).nil? : !locked?(record)
155
+ end
156
+
95
157
  # How a change moves a person's holding of a set: +1 they hold it more
96
158
  # (added, or kept longer), -1 less (removed, or ended sooner), 0 not at
97
159
  # all (the same end date again).
@@ -127,6 +189,24 @@ module PermissionForge
127
189
  locked_record(permission_set)
128
190
  end
129
191
 
192
+ # The end-date changes a "Change end date" menu (or Home's "Extend 30
193
+ # days", ExpiryPresets::EXTEND_KEY) offers for `assignment`: those the
194
+ # lock lets through (.widening_lock), so a locked set never offers a
195
+ # choice that would be refused.
196
+ #
197
+ # @param assignment [PermissionForge::PermissionSetUser]
198
+ # @param keys [Array<String>] the changes to choose from (ExpiryPresets.change_to keys)
199
+ # @return [Array<String>]
200
+ def expiry_choices(assignment, keys: ExpiryPresets::CHANGE_KEYS, today: Time.zone.today)
201
+ set = assignment.permission_set
202
+ return keys unless locked_record(set)
203
+
204
+ keys.reject do |key|
205
+ to = ExpiryPresets.change_to(key, from: assignment.expires_at, today: today)
206
+ widening_lock(set, :change_expiry, from: assignment.expires_at, to: to)
207
+ end
208
+ end
209
+
130
210
  # +1 when the end date moves later or is cleared, -1 when it moves
131
211
  # earlier or is set where there was none, 0 when it stays. Compared at
132
212
  # the column's precision (microseconds): a preset computes its end with