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
@@ -5,7 +5,10 @@ module PermissionForge
5
5
  # @api private
6
6
  class PermissionSetsController < ApplicationController
7
7
  include PermissionSetEditor
8
+ include PermissionForge::ChangeReview
8
9
  include PermissionForge::AuditLogGate
10
+ include PermissionForge::CopyRefusal
11
+ include PermissionForge::CacheConfirmation
9
12
 
10
13
  FILTERS = %w[all granting muting conditional off].freeze
11
14
  TABS = %w[resources assignments conditions audit].freeze
@@ -47,7 +50,10 @@ module PermissionForge
47
50
  return render(:new, status: :unprocessable_entity) if @error
48
51
 
49
52
  set = build_set
50
- redirect_to permission_forge.permission_set_path(set), notice: t('permission_forge.flash.permission_sets.created', name: set.name)
53
+ redirect_to permission_forge.permission_set_path(set), notice: t('permission_forge.flash.permission_sets.created', name: pf_record_name(set))
54
+ rescue ActiveRecord::RecordInvalid => e
55
+ @error = pf_copy_refused_message(e, @name)
56
+ render :new, status: :unprocessable_entity
51
57
  end
52
58
 
53
59
  def show
@@ -72,7 +78,7 @@ module PermissionForge
72
78
  @change_set = change_set
73
79
  if @change_set.empty?
74
80
  return redirect_to(pf_back_or(permission_forge.permission_set_path(@permission_set)),
75
- notice: t('permission_forge.flash.permission_sets.no_changes'))
81
+ pf_nothing_to_save_flash(@change_set))
76
82
  end
77
83
 
78
84
  load_editor
@@ -81,25 +87,22 @@ module PermissionForge
81
87
  end
82
88
 
83
89
  def apply
84
- changes = change_set.apply!
85
- redirect_to back_to_set, notice: t('permission_forge.flash.permission_sets.saved',
86
- count: changes.size, people: helpers.pf_people_text(pf_people_count(@permission_set)))
90
+ applied = change_set
91
+ outcome = cache_outcome { applied.apply! }
92
+ redirect_to back_to_set, **pf_applied_flash(applied, outcome)
87
93
  rescue PermissionForge::Admin::ChangeSet::Invalid => e
88
94
  redirect_to back_to_set, alert: t('permission_forge.flash.permission_sets.save_failed', errors: e.message)
89
95
  end
90
96
 
91
97
  def duplicate
92
- name = PermissionForge::Admin::PermissionSetFactory.unique_name(
93
- t('permission_forge.permission_sets.copy_name', name: @permission_set.name)
94
- )
95
- copy = PermissionForge::Admin::PermissionSetFactory.duplicate(@permission_set, name: name)
98
+ copy = PermissionForge::Admin::PermissionSetFactory.duplicate(@permission_set)
96
99
  group = @permission_set.permission_set_group
97
- notice = if group && copy.permission_set_group_id.nil?
98
- t('permission_forge.flash.permission_sets.duplicated_outside_group', name: @permission_set.name, group: group.name)
99
- else
100
- t('permission_forge.flash.permission_sets.duplicated', name: @permission_set.name)
101
- end
102
- redirect_to permission_forge.permission_set_path(copy), notice: notice
100
+ key = group && copy.permission_set_group_id.nil? ? 'duplicated_outside_group' : 'duplicated'
101
+ redirect_to permission_forge.permission_set_path(copy),
102
+ notice: t("permission_forge.flash.permission_sets.#{key}", name: pf_record_name(@permission_set),
103
+ group: (pf_record_name(group) if group))
104
+ rescue ActiveRecord::RecordInvalid => e
105
+ redirect_to permission_forge.permission_set_path(@permission_set), alert: pf_copy_refused_message(e, pf_record_name(@permission_set))
103
106
  end
104
107
 
105
108
  def confirm_destroy
@@ -107,7 +110,7 @@ module PermissionForge
107
110
  end
108
111
 
109
112
  def destroy
110
- name = @permission_set.name
113
+ name = pf_record_name(@permission_set)
111
114
  @permission_set.destroy!
112
115
  redirect_to permission_forge.permission_sets_path, notice: t('permission_forge.flash.permission_sets.deleted', name: name)
113
116
  end
@@ -121,23 +124,19 @@ module PermissionForge
121
124
  @permission_set.update!(is_muting: !@permission_set.is_muting)
122
125
  key = @permission_set.is_muting ? 'switched_muting' : 'switched_granting'
123
126
  redirect_to permission_forge.permission_set_path(@permission_set),
124
- notice: t("permission_forge.flash.permission_sets.#{key}", name: @permission_set.name)
127
+ notice: t("permission_forge.flash.permission_sets.#{key}", name: pf_record_name(@permission_set))
125
128
  end
126
129
 
127
130
  def conditions
128
131
  @conditions_form = PermissionForge::Admin::ConditionsForm.new(params) # the re-render reads it back
129
132
  attrs = @conditions_form.attributes
130
133
  @condition_errors = @conditions_form.errors
131
- if @condition_errors.empty? && @permission_set.update(attrs)
134
+ if @condition_errors.empty? && pf_save_changed(@permission_set, attrs, &:save) # unchanged: not saved (SaveNotice)
132
135
  return redirect_to permission_forge.permission_set_path(@permission_set, tab: 'conditions'),
133
- notice: t('permission_forge.flash.permission_sets.conditions_saved')
136
+ **pf_save_flash(@permission_set) { t('permission_forge.flash.permission_sets.conditions_saved') }
134
137
  end
135
138
 
136
- @tab = 'conditions'
137
- @ip_text = params[:allowed_ips].to_s
138
- load_editor
139
- load_conditions_tab
140
- render :show, status: :unprocessable_entity
139
+ render_conditions_refused
141
140
  end
142
141
 
143
142
  private
@@ -33,10 +33,14 @@ module PermissionForge
33
33
  @people_count = pf_people_count(@permission_set)
34
34
  end
35
35
 
36
+ # A blank label over a generated one, or a blank over nothing, is no
37
+ # change (Admin::Prefill.posted); an unchanged action is not saved, and
38
+ # says so (SaveNotice).
36
39
  def update
37
- @action_record.assign_attributes(action_params.except(:name))
38
- @action_record.save!
39
- redirect_to pf_back_or(resource_path), notice: t('permission_forge.flash.actions.updated', action: @action_record.display_name)
40
+ attrs = PermissionForge::Admin::Prefill.posted_attributes(@action_record, action_params.except(:name), :label, :description)
41
+ pf_save_changed(@action_record, attrs, &:save!)
42
+ redirect_to pf_back_or(resource_path),
43
+ **pf_save_flash(@action_record) { t('permission_forge.flash.actions.updated', action: @action_record.display_name) }
40
44
  end
41
45
 
42
46
  def confirm_destroy; end
@@ -58,9 +62,9 @@ module PermissionForge
58
62
  @action_record = @resource.resource_actions.find(params[:id])
59
63
  end
60
64
 
65
+ # The action's chain (action, Resource, Permission Set), or the Resource's for a new action.
61
66
  def reject_locked
62
- locked = [@permission_set, @resource].find { |r| pf_locked?(r) }
63
- pf_reject_locked!(locked, resource_path) if locked
67
+ pf_reject_locked!(@action_record || @resource, resource_path)
64
68
  end
65
69
 
66
70
  def resource_path
@@ -68,7 +72,8 @@ module PermissionForge
68
72
  end
69
73
 
70
74
  def action_params
71
- pf_require_hash(:resource_action).permit(:name, :label, :description, :is_permitted, :is_active)
75
+ pf_boolean_attributes(pf_require_hash(:resource_action).permit(:name, :label, :description, :is_permitted, :is_active),
76
+ :is_permitted, :is_active)
72
77
  end
73
78
 
74
79
  # "Export CSV " => "export_csv"
@@ -5,6 +5,8 @@ module PermissionForge
5
5
  # @api private
6
6
  class ResourceFieldsController < ApplicationController
7
7
  include PermissionForge::ActingAdmin
8
+ include PermissionForge::CacheConfirmation
9
+ include PermissionForge::ChangeReview
8
10
 
9
11
  before_action :load_field
10
12
  before_action :reject_locked, only: %i[update acknowledge apply_recommendation]
@@ -21,10 +23,14 @@ module PermissionForge
21
23
  end
22
24
 
23
25
  def update
24
- attrs = pf_require_hash(:field).permit(:level, :mask_strategy, :label, :description, :is_active).to_h
25
- changes = apply_field_changes(attrs)
26
- finish_new_field(attrs) unless @field
27
- redirect_to pf_back_or(resource_path), notice: saved_notice(changes)
26
+ attrs = pf_boolean_attributes(pf_require_hash(:field).permit(:level, :mask_strategy, :label, :description, :is_active).to_h,
27
+ :is_active)
28
+ applied = nil
29
+ outcome = cache_outcome do
30
+ applied = apply_field_changes(attrs)
31
+ finish_new_field(attrs) unless @field
32
+ end
33
+ redirect_to pf_back_or(resource_path), **pf_applied_flash(applied, outcome)
28
34
  rescue PermissionForge::Admin::ChangeSet::Invalid => e
29
35
  redirect_to field_path, alert: t('permission_forge.flash.permission_sets.save_failed', errors: e.message)
30
36
  end
@@ -43,12 +49,13 @@ module PermissionForge
43
49
 
44
50
  # Applies the recommended mask for a sensitive column.
45
51
  def apply_recommendation
46
- match = PermissionForge::Admin::SensitiveColumns.new.match(@key)
52
+ match = PermissionForge::SensitiveColumns.new.match(@key)
47
53
  return redirect_to(field_path) unless match
48
54
 
49
55
  level = @permission_set.is_muting ? 'mask' : 'masked'
50
- apply_field_changes('level' => level, 'mask_strategy' => match.strategy)
51
- redirect_to pf_back_or(field_path), notice: t('permission_forge.flash.fields.recommendation_applied', field: @key)
56
+ applied = apply_field_changes('level' => level, 'mask_strategy' => match.strategy)
57
+ redirect_to pf_back_or(field_path),
58
+ **pf_save_flash(applied.changes.any?) { t('permission_forge.flash.fields.recommendation_applied', field: @key) }
52
59
  end
53
60
 
54
61
  private
@@ -68,13 +75,15 @@ module PermissionForge
68
75
 
69
76
  # Applies `attrs` to this field through a ChangeSet; an unconfigured column
70
77
  # is created at attrs['level'].
78
+ #
79
+ # @return [PermissionForge::Admin::ChangeSet] the applied change set
71
80
  def apply_field_changes(attrs)
72
81
  change_params = if @field
73
82
  { fields: { @field.id.to_s => attrs } }
74
83
  else
75
84
  { new_fields: { @resource.id.to_s => { @key => { 'level' => attrs['level'] } } } }
76
85
  end
77
- PermissionForge::Admin::ChangeSet.new(permission_set: @permission_set, params: change_params).apply!
86
+ PermissionForge::Admin::ChangeSet.new(permission_set: @permission_set, params: change_params).tap(&:apply!)
78
87
  end
79
88
 
80
89
  # Filter rules (no relationship) on this field.
@@ -82,9 +91,9 @@ module PermissionForge
82
91
  @resource.permission_set_rules.select { |rule| rule.field_name == @key && rule.field_accessor_path.blank? }
83
92
  end
84
93
 
94
+ # The field's chain (field, Resource, Permission Set), or the Resource's for a column with no row.
85
95
  def reject_locked
86
- locked = [@permission_set, @resource, @field].compact.find { |r| pf_locked?(r) }
87
- pf_reject_locked!(locked, field_path) if locked
96
+ pf_reject_locked!(@field || @resource, field_path)
88
97
  end
89
98
 
90
99
  def resource_path
@@ -104,15 +113,10 @@ module PermissionForge
104
113
  field.update!(extra) if extra.any?
105
114
  end
106
115
 
107
- def saved_notice(changes)
108
- return t('permission_forge.flash.permission_sets.no_changes') if changes.empty?
109
-
110
- t('permission_forge.flash.permission_sets.saved', count: changes.size,
111
- people: helpers.pf_people_text(pf_people_count(@permission_set)))
112
- end
113
-
116
+ # People holding a set with this Resource, or one of the STI bases its
117
+ # records answer to (ResourceChain: a base's rows reach the subclass).
114
118
  def explain_candidates
115
- set_ids = PermissionForge::Resource.where(name: @resource.name).select(:permission_set_id)
119
+ set_ids = PermissionForge::Resource.where(name: chain_names).select(:permission_set_id)
116
120
  user_ids = PermissionForge::PermissionSetUser.active_assignments.where(permission_set_id: set_ids)
117
121
  .distinct.limit(200).pluck(:user_id)
118
122
  PermissionForge::Admin::PeopleDirectory.new.find_all(user_ids)
@@ -122,8 +126,22 @@ module PermissionForge
122
126
  user = PermissionForge::Admin::PeopleDirectory.new.find(user_id)
123
127
  return nil unless user
124
128
 
125
- outcome = PermissionForge::Admin::EffectiveAccess.call(user: user).find { |r| r.name == @resource.name }
129
+ outcome = nearest_outcome(PermissionForge::Admin::EffectiveAccess.call(user: user))
126
130
  { user: user, field: outcome&.fields&.find { |field| field.name == @key } }
127
131
  end
132
+
133
+ def chain_names
134
+ PermissionForge::ResourceChain.names_for(@resource.name).map(&:to_s)
135
+ end
136
+
137
+ # The person's outcome for this model: its own entry, else the nearest
138
+ # base's (a subclass without an entry of its own is its base).
139
+ def nearest_outcome(outcomes)
140
+ chain_names.each do |name|
141
+ outcome = outcomes.find { |r| r.name == name }
142
+ return outcome if outcome
143
+ end
144
+ nil
145
+ end
128
146
  end
129
147
  end
@@ -4,6 +4,9 @@ module PermissionForge
4
4
  #
5
5
  # @api private
6
6
  class ResourcesController < ApplicationController
7
+ include PermissionForge::ChangeReview
8
+ include PermissionForge::CacheConfirmation
9
+
7
10
  TABS = %w[fields actions rules].freeze
8
11
  GRANTING_PRESETS = %w[full read nothing].freeze
9
12
  MUTING_PRESETS = %w[hide_sensitive block_changes leave].freeze
@@ -15,7 +18,6 @@ module PermissionForge
15
18
  }.freeze
16
19
  # Field level each preset gives the model's columns (nil = no fields).
17
20
  PRESET_LEVELS = { 'full' => 'edit', 'read' => 'read', 'nothing' => 'hidden', 'hide_sensitive' => 'hide_sensitive' }.freeze
18
-
19
21
  before_action :set_permission_set
20
22
  before_action :set_resource, except: %i[new create]
21
23
  before_action :reject_locked_set, only: %i[new create review apply sync destroy confirm_destroy]
@@ -42,7 +44,7 @@ module PermissionForge
42
44
 
43
45
  def review
44
46
  @change_set = change_set
45
- return redirect_to(resource_path, notice: t('permission_forge.flash.permission_sets.no_changes')) if @change_set.empty?
47
+ return redirect_to(resource_path, pf_nothing_to_save_flash(@change_set)) if @change_set.empty?
46
48
 
47
49
  load_page
48
50
  @changes = @change_set.changes
@@ -50,10 +52,9 @@ module PermissionForge
50
52
  end
51
53
 
52
54
  def apply
53
- changes = change_set.apply!
54
- redirect_to pf_back_or(resource_path),
55
- notice: t('permission_forge.flash.permission_sets.saved', count: changes.size,
56
- people: helpers.pf_people_text(pf_people_count(@permission_set)))
55
+ applied = change_set
56
+ outcome = cache_outcome { applied.apply! }
57
+ redirect_to pf_back_or(resource_path), **pf_applied_flash(applied, outcome)
57
58
  rescue PermissionForge::Admin::ChangeSet::Invalid => e
58
59
  redirect_to resource_path, alert: t('permission_forge.flash.permission_sets.save_failed', errors: e.message)
59
60
  end
@@ -77,7 +78,7 @@ module PermissionForge
77
78
  label = helpers.pf_resource_label(@resource)
78
79
  @resource.destroy!
79
80
  redirect_to permission_forge.permission_set_path(@permission_set),
80
- notice: t('permission_forge.flash.resources.removed', resource: label, set: @permission_set.name)
81
+ notice: t('permission_forge.flash.resources.removed', resource: label, set: pf_record_name(@permission_set))
81
82
  end
82
83
 
83
84
  private
@@ -138,7 +139,7 @@ module PermissionForge
138
139
 
139
140
  def load_new_form
140
141
  load_models
141
- @sensitive = PermissionForge::Admin::SensitiveColumns.new
142
+ @sensitive = PermissionForge::SensitiveColumns.new
142
143
  @existing = @permission_set.resources.pluck(:name)
143
144
  @presets = @permission_set.is_muting ? MUTING_PRESETS : GRANTING_PRESETS
144
145
  preset = params[:preset]
@@ -161,8 +162,7 @@ module PermissionForge
161
162
  end
162
163
 
163
164
  def added_notice(resource)
164
- t('permission_forge.flash.resources.added', resource: helpers.pf_resource_label(resource),
165
- count: resource.resource_fields.size)
165
+ helpers.pf_added_resource_notice(resource, muting: @permission_set.is_muting)
166
166
  end
167
167
 
168
168
  def build_resource(model, preset)
@@ -181,7 +181,8 @@ module PermissionForge
181
181
  end
182
182
 
183
183
  # Creates ResourceFields for columns and virtual fields at a level, applying
184
- # privacy defaults (sensitive names start Masked when gdpr_defaults is on).
184
+ # privacy defaults (see Admin::ColumnFields: under gdpr_defaults sensitive
185
+ # names start Masked, except at full access).
185
186
  def create_fields(columns, level, resource: @resource)
186
187
  PermissionForge::Admin::ColumnFields.new(resource, muting: @permission_set.is_muting).create(columns, level)
187
188
  end
@@ -14,9 +14,11 @@
14
14
  --pf-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
15
15
 
16
16
  --pf-radius-xs: 4px;
17
+ --pf-scroll-shadow: rgba(16, 18, 22, .16);
17
18
  --pf-radius-sm: 6px;
18
19
  --pf-radius: 8px;
19
20
  --pf-radius-lg: 12px;
21
+ --pf-sidebar-width: 248px;
20
22
 
21
23
  --pf-space-1: 4px;
22
24
  --pf-space-2: 8px;
@@ -70,6 +72,7 @@
70
72
  --pf-bg: #111214;
71
73
  --pf-surface: #18191c;
72
74
  --pf-surface-2: #212327;
75
+ --pf-scroll-shadow: rgba(0, 0, 0, .55);
73
76
  --pf-border: #2c2e33;
74
77
  --pf-border-strong: #3c3f45;
75
78
  --pf-text: #ececee;
@@ -105,6 +108,7 @@
105
108
  --pf-bg: #111214;
106
109
  --pf-surface: #18191c;
107
110
  --pf-surface-2: #212327;
111
+ --pf-scroll-shadow: rgba(0, 0, 0, .55);
108
112
  --pf-border: #2c2e33;
109
113
  --pf-border-strong: #3c3f45;
110
114
  --pf-text: #ececee;
@@ -212,7 +216,7 @@
212
216
 
213
217
  /* ------------------------------------------------------------------ layout */
214
218
  .pf-app { min-height: 100vh; display: flex; flex-wrap: wrap; align-content: flex-start; background: var(--pf-bg); }
215
- .pf-sidebar { flex: 0 0 248px; align-self: stretch; border-right: 1px solid var(--pf-border); background: var(--pf-surface); }
219
+ .pf-sidebar { flex: 0 0 var(--pf-sidebar-width); align-self: stretch; border-right: 1px solid var(--pf-border); background: var(--pf-surface); }
216
220
  .pf-sidebar__inner { position: sticky; top: 0; height: 100vh; overflow: auto; display: flex; flex-direction: column; gap: 2px; padding: 20px 12px; }
217
221
  .pf-brand { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; padding: 4px 8px 18px; }
218
222
  .pf-brand__home { display: inline-block; }
@@ -306,11 +310,17 @@
306
310
  .pf-root .pf-input:focus, .pf-root .pf-textarea:focus { outline: none; border-color: var(--pf-accent); box-shadow: 0 0 0 3px var(--pf-accent-soft); }
307
311
  .pf-input--sm, .pf-select--sm { padding: 6px 10px; font-size: 13px; }
308
312
  .pf-input--lg { padding: 10px 12px; font-size: 15px; }
309
- .pf-input--auto, .pf-select--auto { display: inline-block; width: auto; max-width: 100%; }
313
+ .pf-input--auto, .pf-select--auto { display: inline-block; width: auto; min-width: 0; max-width: 100%; }
310
314
  .pf-textarea { min-height: 72px; resize: vertical; }
311
315
  .pf-textarea--mono { font-family: var(--pf-mono); font-size: 13px; max-width: 360px; }
312
316
  .pf-input[aria-invalid="true"], .pf-select[aria-invalid="true"], .pf-textarea[aria-invalid="true"] { border-color: var(--pf-block); }
313
317
  .pf-input::placeholder, .pf-textarea::placeholder { color: var(--pf-text-3); }
318
+ /* Read-only (a locked record): a filled box that does not light up on hover, so it
319
+ does not read as editable. It stays focusable (announced "read only") with a
320
+ visible, neutral focus ring. */
321
+ .pf-root .pf-input[readonly], .pf-root .pf-textarea[readonly] { background: var(--pf-surface-2); border-color: var(--pf-border); color: var(--pf-text-2); cursor: default; resize: none; }
322
+ .pf-root .pf-input[readonly]:hover, .pf-root .pf-textarea[readonly]:hover { border-color: var(--pf-border); }
323
+ .pf-root .pf-input[readonly]:focus, .pf-root .pf-textarea[readonly]:focus { border-color: var(--pf-border-strong); box-shadow: 0 0 0 3px var(--pf-border); }
314
324
  .pf-label { display: block; font-size: 13px; font-weight: 600; color: var(--pf-text); margin-bottom: 6px; }
315
325
  .pf-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 12px 16px; align-items: end; }
316
326
  .pf-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
@@ -322,9 +332,19 @@
322
332
  .pf-title-input { width: 100%; font-size: 24px; line-height: 1.25; font-weight: 600; letter-spacing: -0.01em; border: 1px solid transparent; border-radius: var(--pf-radius-sm); background: transparent; margin-left: -8px; padding: 4px 8px; }
323
333
  .pf-title-input:hover { border-color: var(--pf-border); }
324
334
  .pf-title-input:focus { border-color: var(--pf-border-strong); background: var(--pf-surface); }
325
- .pf-desc-input { width: 100%; font-size: 14px; color: var(--pf-text-2); border: 1px solid transparent; border-radius: var(--pf-radius-sm); background: transparent; margin-left: -8px; padding: 3px 8px; }
335
+ .pf-desc-input { width: 100%; font-size: 14px; color: var(--pf-text-2); border: 1px solid transparent; border-radius: var(--pf-radius-sm); background: transparent; margin-left: -8px; padding: 3px 8px; text-overflow: ellipsis; }
326
336
  .pf-desc-input:hover { border-color: var(--pf-border); }
327
337
  .pf-desc-input:focus { border-color: var(--pf-border-strong); background: var(--pf-surface); color: var(--pf-text); }
338
+ /* A locked record's name and description: plain text that does not invite an edit. */
339
+ /* The title shows "Untitled …" as its placeholder only while the name is blank; on a phone the
340
+ title is smaller, and a name or placeholder longer than the box ends in an ellipsis. */
341
+ .pf-title-input { text-overflow: ellipsis; }
342
+ @media (max-width: 640px) { .pf-title-input { font-size: 20px; } }
343
+ .pf-title-input[readonly], .pf-desc-input[readonly] { cursor: default; }
344
+ .pf-title-input[readonly]:hover, .pf-desc-input[readonly]:hover { border-color: transparent; }
345
+ .pf-title-input[readonly]:focus, .pf-desc-input[readonly]:focus { background: transparent; }
346
+ .pf-desc-input[readonly]:focus { color: var(--pf-text-2); }
347
+ .pf-desc-input[readonly]::placeholder { color: transparent; }
328
348
 
329
349
  /* switch: <label class="pf-switch"><input type="checkbox" role="switch"><span class="pf-switch__track"></span>Text</label> */
330
350
  .pf-switch { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border: 1px solid var(--pf-border-strong); border-radius: var(--pf-radius-sm); background: var(--pf-surface); font-size: 13px; font-weight: 500; cursor: pointer; white-space: nowrap; }
@@ -363,8 +383,19 @@
363
383
  .pf-masonry__col { flex: 1 1 min(100%, 460px); min-width: 0; display: flex; flex-direction: column; gap: 16px; }
364
384
  .pf-grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: 16px; align-items: start; }
365
385
  .pf-grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); gap: 12px; align-items: start; }
366
- .pf-scroll-x { overflow-x: auto; }
367
- .pf-scroll-y { max-height: 260px; overflow-y: auto; }
386
+ /* A frame that scrolls sideways when a grid is wider than the page (pf_scroll_frame: a tab
387
+ stop with a name). It is the containing block of what it clips (position: relative): an
388
+ absolutely positioned descendant (a .pf-sr-only header cell) would otherwise sit at its
389
+ static position in the page, outside the frame's clipping, and widen the document instead
390
+ of scrolling with the grid. The shadows at its edges are the cue that there is more: covers
391
+ that move with the content (`local`) hide them on a side with nothing left to scroll to.
392
+ Every scroll container is positioned for the same reason (layout_lint_test.rb). */
393
+ .pf-scroll-x { position: relative; overflow-x: auto; background-color: var(--pf-surface);
394
+ background-image: linear-gradient(90deg, var(--pf-surface) 50%, transparent), linear-gradient(270deg, var(--pf-surface) 50%, transparent),
395
+ linear-gradient(90deg, var(--pf-scroll-shadow), transparent), linear-gradient(270deg, var(--pf-scroll-shadow), transparent);
396
+ background-position: left center, right center, left center, right center; background-repeat: no-repeat;
397
+ background-size: 32px 100%, 32px 100%, 12px 100%, 12px 100%; background-attachment: local, local, scroll, scroll; }
398
+ .pf-scroll-y { position: relative; max-height: 260px; overflow-y: auto; }
368
399
  .pf-count-pill { display: inline-flex; align-items: center; min-width: 22px; padding: 0 8px; border-radius: 999px; font-size: 12px; font-weight: 600; line-height: 20px; justify-content: center; background: var(--pf-surface-2); color: var(--pf-text-2); border: 1px solid var(--pf-border); }
369
400
  .pf-count-pill--warn { background: var(--pf-warn-soft); color: var(--pf-warn); border-color: var(--pf-warn-border); }
370
401
 
@@ -425,15 +456,44 @@
425
456
  .pf-table--sets { min-width: 560px; }
426
457
  .pf-table--sets .pf-table__head, .pf-table--sets .pf-table__row { grid-template-columns: minmax(0, 2fr) 56px 76px minmax(0, .8fr) auto; }
427
458
  .pf-table--assignments { min-width: 640px; }
428
- .pf-table--assignments .pf-table__head, .pf-table--assignments .pf-table__row { grid-template-columns: 24px minmax(0, 1.5fr) minmax(0, 1.2fr) minmax(0, 1fr) auto; }
459
+ .pf-table--assignments .pf-table__head, .pf-table--assignments .pf-table__row { grid-template-columns: auto minmax(0, 1.5fr) minmax(0, 1.2fr) minmax(0, 1fr) auto; }
429
460
  .pf-table--audit { min-width: 640px; }
430
461
  .pf-table--audit .pf-table__head, .pf-table--audit .pf-table__row { grid-template-columns: 110px minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr); }
462
+ /* A table with an `auto` column (sized by its content) shares one grid between
463
+ its head and rows: each row on its own grid would size that column to its own
464
+ content, and the headers would drift off their cells. The edge tracks are
465
+ `auto` or `fr` (never a fixed width): a subgrid adds its padding to them. */
466
+ @supports (grid-template-columns: subgrid) {
467
+ .pf-table--sets, .pf-table--assignments { display: grid; column-gap: 12px; }
468
+ .pf-table--sets { grid-template-columns: minmax(0, 2fr) 56px 76px minmax(0, .8fr) auto; }
469
+ .pf-table--assignments { grid-template-columns: auto minmax(0, 1.5fr) minmax(0, 1.2fr) minmax(0, 1fr) fit-content(40%); }
470
+ .pf-table--sets > .pf-table__head, .pf-table--sets > .pf-table__row,
471
+ .pf-table--assignments > .pf-table__head, .pf-table--assignments > .pf-table__row { grid-column: 1 / -1; grid-template-columns: subgrid; }
472
+ }
473
+ /* A narrow card (a phone) stacks each Assignments row instead of scrolling it
474
+ sideways: the checkbox, then the person, the dates and the actions one under
475
+ another, so the end-date menu, Reason and Remove wrap inside the row. The
476
+ card's width decides, not the window's (the sidebar takes room at desktop
477
+ widths); without container queries the card still scrolls. The head keeps
478
+ only its first two columns on screen (the rest stay for screen readers). */
479
+ .pf-table-frame { container-type: inline-size; }
480
+ @container (max-width: 639px) {
481
+ .pf-table-frame .pf-table--assignments { display: block; min-width: 0; }
482
+ .pf-table-frame .pf-table--assignments > .pf-table__head,
483
+ .pf-table-frame .pf-table--assignments > .pf-table__row { grid-template-columns: auto minmax(0, 1fr); padding: 10px 16px; }
484
+ .pf-table-frame .pf-table--assignments > * > :first-child { grid-column: 1; grid-row: 1; }
485
+ .pf-table-frame .pf-table--assignments > * > :not(:first-child) { grid-column: 2; }
486
+ .pf-table-frame .pf-table--assignments > .pf-table__head > :nth-child(n+3) {
487
+ position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap;
488
+ }
489
+ .pf-table-frame .pf-table--assignments .pf-reason-form { max-width: 100%; }
490
+ }
431
491
  .pf-cell-person { display: flex; align-items: center; gap: 10px; min-width: 0; }
432
492
  .pf-avatar { display: inline-grid; place-items: center; width: 30px; height: 30px; flex: 0 0 30px; border-radius: 999px; background: var(--pf-surface-2); border: 1px solid var(--pf-border); font-size: 11px; font-weight: 600; color: var(--pf-text-2); }
433
493
  .pf-avatar--sm { width: 28px; height: 28px; flex-basis: 28px; }
434
494
  .pf-avatar--lg { width: 48px; height: 48px; flex-basis: 48px; font-size: 16px; }
435
495
  .pf-person-name { font-weight: 600; }
436
- .pf-person-email { font-size: 12px; color: var(--pf-text-3); }
496
+ .pf-person-email { font-size: 12px; color: var(--pf-text-3); overflow-wrap: anywhere; }
437
497
 
438
498
  /* ------------------------------------------------------------- matrices */
439
499
  .pf-matrix { min-width: 620px; }
@@ -441,7 +501,7 @@
441
501
  .pf-matrix__head { font-size: 12px; font-weight: 600; color: var(--pf-text-3); border-bottom: 1px solid var(--pf-border); }
442
502
  .pf-matrix__row { border-top: 1px solid var(--pf-border); }
443
503
  .pf-matrix__head + .pf-matrix__row { border-top: 0; }
444
- .pf-matrix__resource { display: flex; flex-direction: column; min-width: 0; }
504
+ .pf-matrix__resource { display: flex; flex-direction: column; min-width: 0; overflow-wrap: anywhere; }
445
505
  .pf-matrix__resource a { font-weight: 600; color: var(--pf-text) !important; }
446
506
  .pf-matrix__chev { color: var(--pf-text-3) !important; font-size: 16px; text-align: center; text-decoration: none !important; }
447
507
  .pf-effective { min-width: 540px; }
@@ -485,6 +545,7 @@
485
545
  .pf-state[aria-current="true"], .pf-effective__field[aria-current="true"], .pf-action-pill[aria-current="true"] { box-shadow: 0 0 0 2px var(--pf-accent); }
486
546
  .pf-state .pf-cell__glyph { font-weight: 700; }
487
547
  .pf-state--records { flex-direction: column; align-items: flex-start; justify-content: center; gap: 0; min-width: 0; width: 100%; overflow: hidden; text-align: left; }
548
+ .pf-state__summary { overflow-wrap: anywhere; }
488
549
 
489
550
  /* 4-level field control */
490
551
  .pf-levels { display: inline-flex; flex-wrap: wrap; gap: 2px; padding: 2px; border-radius: var(--pf-radius-sm); background: var(--pf-surface-2); border: 1px solid var(--pf-border); }
@@ -515,6 +576,10 @@
515
576
  .pf-root a.pf-fields__name:hover .pf-fields__label { text-decoration: underline; }
516
577
  .pf-fields__label { font-weight: 500; }
517
578
  .pf-mask-preview { font-size: 12px; color: var(--pf-mask); font-family: var(--pf-mono); white-space: nowrap; }
579
+ /* The level control and, under it, the mask preview: the control alone sets the
580
+ width (the preview's own width does not count), so controls line up across rows. */
581
+ .pf-fields__level { display: flex; flex-direction: column; align-items: stretch; gap: 2px; max-width: 100%; }
582
+ .pf-fields__level > .pf-mask-preview { contain: inline-size; overflow: hidden; text-overflow: ellipsis; text-align: right; }
518
583
  .pf-bulk-bar { position: sticky; top: 8px; z-index: 5; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 10px 14px; border-radius: var(--pf-radius); background: var(--pf-text); color: var(--pf-surface); box-shadow: var(--pf-shadow); }
519
584
  .pf-bulk-bar .pf-btn { background: transparent; color: var(--pf-surface) !important; border-color: rgba(127, 127, 127, .55); }
520
585
  .pf-bulk-bar .pf-btn:hover { background: rgba(127, 127, 127, .25); }
@@ -562,7 +627,7 @@
562
627
 
563
628
  /* person picker / listbox rows */
564
629
  .pf-listbox { display: flex; flex-direction: column; border: 1px solid var(--pf-border); border-radius: var(--pf-radius); overflow: hidden; }
565
- .pf-listbox__scroll { max-height: 300px; overflow-y: auto; }
630
+ .pf-listbox__scroll { position: relative; max-height: 300px; overflow-y: auto; }
566
631
  .pf-listbox--short .pf-listbox__scroll { max-height: 240px; }
567
632
  .pf-option { position: relative; display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-top: 1px solid var(--pf-border); color: inherit !important; text-decoration: none !important; cursor: pointer; }
568
633
  .pf-option:first-child { border-top: 0; }
@@ -572,11 +637,14 @@
572
637
  .pf-option:has(input:disabled) { opacity: .6; cursor: not-allowed; }
573
638
  .pf-option__main { display: flex; flex-direction: column; min-width: 0; flex: 1 1 auto; }
574
639
  .pf-option__aside { font-size: 12px; color: var(--pf-text-3); white-space: nowrap; }
640
+ /* On a phone a row's badge and count go to a second line rather than past the edge. */
641
+ @media (max-width: 640px) { .pf-option { flex-wrap: wrap; row-gap: 4px; } }
575
642
  .pf-listbox__footer { padding: 8px 12px; background: var(--pf-surface-2); border-top: 1px solid var(--pf-border); font-size: 12px; color: var(--pf-text-2); }
576
643
  .pf-picked { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--pf-border); border-radius: var(--pf-radius); background: var(--pf-surface-2); }
577
644
 
578
645
  /* home search toolbar */
579
646
  .pf-toolbar { display: flex; flex-wrap: wrap; align-items: stretch; gap: 8px; }
647
+ .pf-toolbar__help { flex: 1 1 100%; }
580
648
  .pf-search-field { position: relative; flex: 1 1 320px; min-width: 0; }
581
649
  .pf-search-field__icon { position: absolute; left: 12px; top: 50%; width: 16px; height: 16px; transform: translateY(-50%); color: var(--pf-text-3); pointer-events: none; }
582
650
  .pf-root .pf-search-field__input { padding: 9px 12px 9px 36px; font-size: 14px; }
@@ -616,21 +684,23 @@
616
684
  /* ------------------------------------------------------------- settings */
617
685
  .pf-setting { display: flex; flex-wrap: wrap; gap: 10px 20px; padding: 12px 16px; border-top: 1px solid var(--pf-border); scroll-margin-top: 80px; }
618
686
  .pf-setting:target, .pf-setting[data-pf-focus="true"] { background: var(--pf-accent-soft); }
619
- .pf-setting__about { flex: 2 1 300px; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
620
- .pf-setting__value { flex: 1 1 240px; display: flex; flex-direction: column; align-items: flex-start; gap: 6px; min-width: 0; }
687
+ .pf-setting__about { flex: 2 1 300px; display: flex; flex-direction: column; gap: 4px; min-width: 0; overflow-wrap: anywhere; }
688
+ .pf-setting__value { flex: 1 1 240px; display: flex; flex-direction: column; align-items: flex-start; gap: 6px; min-width: 0; overflow-wrap: anywhere; }
689
+ /* A value's pill wraps inside its column (a long translation must not widen the page). */
690
+ .pf-setting__value .pf-pill { white-space: normal; max-width: 100%; }
621
691
  .pf-setting__how { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end; gap: 10px; }
622
- .pf-setting__how[open] { flex: 1 1 100%; }
692
+ .pf-setting__how[open] { flex: 1 1 100%; min-width: 0; }
623
693
  .pf-setting__how > summary { list-style: none; }
624
694
  .pf-setting__how > summary::-webkit-details-marker { display: none; }
625
- .pf-setting__panel { flex: 1 1 100%; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: var(--pf-radius); background: var(--pf-surface-2); border: 1px solid var(--pf-border); }
695
+ .pf-setting__panel { flex: 1 1 100%; align-self: stretch; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: var(--pf-radius); background: var(--pf-surface-2); border: 1px solid var(--pf-border); }
626
696
  .pf-setting-section > summary { list-style: none; display: flex; align-items: center; gap: 10px; padding: 14px 16px; cursor: pointer; }
627
697
  .pf-setting-section > summary::-webkit-details-marker { display: none; }
628
698
  .pf-setting-section + .pf-setting-section { border-top: 1px solid var(--pf-border); }
629
699
  .pf-pill { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; border: 1px solid var(--pf-border); background: var(--pf-surface-2); color: var(--pf-text-2); white-space: nowrap; }
630
700
  .pf-pill--on { background: var(--pf-grant-soft); color: var(--pf-grant); border-color: var(--pf-grant-border); }
631
- .pf-pill--opt { font-family: var(--pf-mono); font-weight: 500; background: transparent; }
701
+ .pf-pill--opt { font-family: var(--pf-mono); font-weight: 500; background: transparent; white-space: normal; overflow-wrap: anywhere; }
632
702
  .pf-pill--current { background: var(--pf-text); color: var(--pf-surface); border-color: var(--pf-text); }
633
- .pf-code { margin: 0; padding: 12px 14px; border-radius: var(--pf-radius); background: var(--pf-surface); border: 1px solid var(--pf-border); font-family: var(--pf-mono); font-size: 12.5px; line-height: 1.6; overflow-x: auto; white-space: pre; }
703
+ .pf-code { position: relative; margin: 0; padding: 12px 14px; border-radius: var(--pf-radius); background: var(--pf-surface); border: 1px solid var(--pf-border); font-family: var(--pf-mono); font-size: 12.5px; line-height: 1.6; overflow-x: auto; white-space: pre; }
634
704
  .pf-ladder { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); gap: 10px; padding: 0 16px 14px; }
635
705
  .pf-ladder__tile { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; border-radius: var(--pf-radius); border: 1px solid var(--pf-border); background: var(--pf-surface-2); }
636
706
  .pf-ladder__tile--on { background: var(--pf-grant-soft); border-color: var(--pf-grant-border); color: var(--pf-grant); }
@@ -640,6 +710,7 @@
640
710
  .pf-audit-footer { font-size: 12px; color: var(--pf-text-3); padding-top: 12px; border-top: 1px solid var(--pf-border); }
641
711
  /* A link inside a line of text is told apart by more than its colour. */
642
712
  .pf-audit-footer a, .pf-hint a { text-decoration: underline; }
713
+ .pf-audit-footer__hint { display: block; margin-top: 4px; }
643
714
  .pf-tech > summary { font-size: 12px; color: var(--pf-text-3); cursor: pointer; width: max-content; }
644
715
  .pf-tech__body { display: flex; flex-direction: column; gap: 8px; padding-top: 10px; font-size: 12px; color: var(--pf-text-2); }
645
716
  .pf-copy { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
@@ -651,6 +722,7 @@
651
722
  .pf-savebar__count { font-weight: 600; }
652
723
  .pf-savebar__note { font-size: 12px; opacity: .8; }
653
724
  .pf-savebar[data-pf-dirty="false"] .pf-savebar__count--dirty, .pf-savebar[data-pf-dirty="true"] .pf-savebar__count--clean { display: none; }
725
+ .pf-js .pf-savebar--quiet[data-pf-dirty="false"] { display: none; }
654
726
 
655
727
  /* --------------------------------------------------------------- menus */
656
728
  .pf-menu { position: relative; display: inline-block; }
@@ -688,9 +760,15 @@
688
760
  .pf-close:hover { background: var(--pf-surface-2); }
689
761
  .pf-confirm { display: flex; flex-direction: column; gap: 14px; padding: 22px; }
690
762
  .pf-confirm__title { font-size: 17px; font-weight: 600; text-wrap: balance; }
691
- .pf-confirm__list { max-height: 200px; overflow-y: auto; padding: 10px 12px; border-radius: var(--pf-radius); background: var(--pf-surface-2); font-size: 13px; display: flex; flex-direction: column; gap: 4px; }
763
+ .pf-confirm__list { position: relative; max-height: 200px; overflow-y: auto; padding: 10px 12px; border-radius: var(--pf-radius); background: var(--pf-surface-2); font-size: 13px; display: flex; flex-direction: column; gap: 4px; }
692
764
  .pf-confirm--danger { border-top: 3px solid var(--pf-block); }
693
765
  .pf-toasts { position: fixed; left: 50%; bottom: 24px; z-index: 60; transform: translateX(-50%); display: flex; flex-direction: column; gap: 8px; width: max-content; max-width: min(640px, calc(100% - 32px)); }
766
+ /* A page with a sticky savebar keeps the bottom edge for it: toasts go to the top, so neither hides the other. */
767
+ .pf-root:has(.pf-savebar) .pf-toasts { top: 16px; bottom: auto; }
768
+ /* Beside the sidebar (desktop widths) toasts centre over the main column, never over the sidebar's footer (language, theme). */
769
+ @media (min-width: 1024px) {
770
+ .pf-root:has(.pf-sidebar) .pf-toasts { left: calc(50% + var(--pf-sidebar-width) / 2); max-width: min(640px, calc(100% - var(--pf-sidebar-width) - 32px)); }
771
+ }
694
772
  .pf-toast { display: flex; align-items: center; gap: 12px; padding: 10px 12px 10px 16px; border-radius: var(--pf-radius); background: var(--pf-text); color: var(--pf-surface); box-shadow: var(--pf-shadow); font-size: 13px; }
695
773
  .pf-toast--alert { background: var(--pf-block); color: #fff; }
696
774
  .pf-toast__text { flex: 1; min-width: 0; }
@@ -712,6 +790,8 @@
712
790
  .pf-sync > summary { list-style: none; cursor: pointer; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 12px 16px; color: var(--pf-warn); font-weight: 600; font-size: 13px; }
713
791
  .pf-sync > summary::-webkit-details-marker { display: none; }
714
792
  .pf-sync__body { padding: 0 16px 16px; }
793
+ .pf-sync--info { border-color: var(--pf-border); background: var(--pf-surface); }
794
+ .pf-sync--info > summary { color: var(--pf-text-2); }
715
795
  .pf-crud-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
716
796
  .pf-crud-grid__col { display: flex; flex-direction: column; gap: 6px; }
717
797
  .pf-scope-option { display: flex; gap: 8px; align-items: flex-start; }
@@ -762,5 +842,7 @@
762
842
  .pf-reason__body { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; max-width: 420px; }
763
843
  .pf-reason--compact .pf-reason__summary { font-size: 12px; }
764
844
  .pf-reason--compact .pf-reason__body { margin-top: 6px; min-width: 200px; }
765
- .pf-reason-form { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; }
845
+ /* min-width: 0 — as a flex item (a card header, .pf-actions) the form may shrink to the line, so a
846
+ people <select> with long labels shrinks with it instead of pushing the form past the page. */
847
+ .pf-reason-form { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; min-width: 0; }
766
848
  .pf-reason-form .pf-reason--compact[open] { flex-basis: 100%; }