permission_forge 0.14.1

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 (406) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +1138 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +356 -0
  5. data/app/controllers/concerns/permission_forge/acting_admin.rb +17 -0
  6. data/app/controllers/concerns/permission_forge/api/crud_actions.rb +84 -0
  7. data/app/controllers/concerns/permission_forge/api/nested_resource_lookup.rb +18 -0
  8. data/app/controllers/concerns/permission_forge/api/pagination.rb +50 -0
  9. data/app/controllers/concerns/permission_forge/api/response_renderer.rb +65 -0
  10. data/app/controllers/concerns/permission_forge/api/serializer_resolver.rb +30 -0
  11. data/app/controllers/concerns/permission_forge/api_authentication.rb +40 -0
  12. data/app/controllers/concerns/permission_forge/assignment_authorization.rb +97 -0
  13. data/app/controllers/concerns/permission_forge/audit_log_gate.rb +55 -0
  14. data/app/controllers/concerns/permission_forge/basic_authentication.rb +77 -0
  15. data/app/controllers/concerns/permission_forge/cache_confirmation.rb +32 -0
  16. data/app/controllers/concerns/permission_forge/forgery_protection.rb +29 -0
  17. data/app/controllers/concerns/permission_forge/html_format.rb +40 -0
  18. data/app/controllers/concerns/permission_forge/param_shapes.rb +87 -0
  19. data/app/controllers/concerns/permission_forge/tenant_required.rb +42 -0
  20. data/app/controllers/permission_forge/api/audit_events_controller.rb +33 -0
  21. data/app/controllers/permission_forge/api/base_controller.rb +220 -0
  22. data/app/controllers/permission_forge/api/dashboard_controller.rb +30 -0
  23. data/app/controllers/permission_forge/api/permission_set_group_users_controller.rb +51 -0
  24. data/app/controllers/permission_forge/api/permission_set_groups_controller.rb +71 -0
  25. data/app/controllers/permission_forge/api/permission_set_rules_controller.rb +37 -0
  26. data/app/controllers/permission_forge/api/permission_set_users_controller.rb +105 -0
  27. data/app/controllers/permission_forge/api/permission_sets_controller.rb +87 -0
  28. data/app/controllers/permission_forge/api/resource_actions_controller.rb +58 -0
  29. data/app/controllers/permission_forge/api/resource_fields_controller.rb +30 -0
  30. data/app/controllers/permission_forge/api/resources_controller.rb +56 -0
  31. data/app/controllers/permission_forge/application_controller.rb +198 -0
  32. data/app/controllers/permission_forge/assets_controller.rb +97 -0
  33. data/app/controllers/permission_forge/assignments_controller.rb +266 -0
  34. data/app/controllers/permission_forge/audit_events_controller.rb +94 -0
  35. data/app/controllers/permission_forge/bulk_assignments_controller.rb +108 -0
  36. data/app/controllers/permission_forge/field_lookups_controller.rb +38 -0
  37. data/app/controllers/permission_forge/home_controller.rb +24 -0
  38. data/app/controllers/permission_forge/maintenance_controller.rb +13 -0
  39. data/app/controllers/permission_forge/people_controller.rb +190 -0
  40. data/app/controllers/permission_forge/permission_set_editor.rb +71 -0
  41. data/app/controllers/permission_forge/permission_set_group_users_controller.rb +57 -0
  42. data/app/controllers/permission_forge/permission_set_groups_controller.rb +111 -0
  43. data/app/controllers/permission_forge/permission_set_rules_controller.rb +97 -0
  44. data/app/controllers/permission_forge/permission_sets_controller.rb +227 -0
  45. data/app/controllers/permission_forge/preferences_controller.rb +26 -0
  46. data/app/controllers/permission_forge/resource_actions_controller.rb +106 -0
  47. data/app/controllers/permission_forge/resource_fields_controller.rb +129 -0
  48. data/app/controllers/permission_forge/resources_controller.rb +189 -0
  49. data/app/controllers/permission_forge/settings_controller.rb +33 -0
  50. data/app/frontend/permission_forge/brand/apple-touch-icon.png +0 -0
  51. data/app/frontend/permission_forge/brand/chevron.svg +1 -0
  52. data/app/frontend/permission_forge/brand/favicon.ico +0 -0
  53. data/app/frontend/permission_forge/brand/favicon.svg +1 -0
  54. data/app/frontend/permission_forge/brand/logo-horizontal-dark.svg +1 -0
  55. data/app/frontend/permission_forge/brand/logo-horizontal-light.svg +1 -0
  56. data/app/frontend/permission_forge/brand/mark-small.svg +1 -0
  57. data/app/frontend/permission_forge/brand/mark.svg +1 -0
  58. data/app/frontend/permission_forge/pf_admin.css +766 -0
  59. data/app/frontend/permission_forge/pf_admin.js +482 -0
  60. data/app/helpers/permission_forge/application_helper.rb +167 -0
  61. data/app/helpers/permission_forge/audit_helper.rb +230 -0
  62. data/app/helpers/permission_forge/audit_sentences_helper.rb +84 -0
  63. data/app/helpers/permission_forge/badges_helper.rb +134 -0
  64. data/app/helpers/permission_forge/changes_helper.rb +117 -0
  65. data/app/helpers/permission_forge/people_helper.rb +118 -0
  66. data/app/helpers/permission_forge/sentences_helper.rb +175 -0
  67. data/app/helpers/permission_forge/settings_helper.rb +152 -0
  68. data/app/helpers/permission_forge/ui_helper.rb +184 -0
  69. data/app/models/permission_forge/audit_event.rb +159 -0
  70. data/app/models/permission_forge/permission_set.rb +100 -0
  71. data/app/models/permission_forge/permission_set_group.rb +83 -0
  72. data/app/models/permission_forge/permission_set_group_user.rb +43 -0
  73. data/app/models/permission_forge/permission_set_rule.rb +99 -0
  74. data/app/models/permission_forge/permission_set_user.rb +158 -0
  75. data/app/models/permission_forge/resource.rb +100 -0
  76. data/app/models/permission_forge/resource_action.rb +58 -0
  77. data/app/models/permission_forge/resource_field.rb +69 -0
  78. data/app/views/layouts/permission_forge/application.html.erb +30 -0
  79. data/app/views/layouts/permission_forge/fragment.html.erb +2 -0
  80. data/app/views/permission_forge/assignments/_person_options.html.erb +26 -0
  81. data/app/views/permission_forge/assignments/confirm_destroy.html.erb +24 -0
  82. data/app/views/permission_forge/assignments/new.html.erb +99 -0
  83. data/app/views/permission_forge/audit_events/index.html.erb +161 -0
  84. data/app/views/permission_forge/audit_events/show.html.erb +146 -0
  85. data/app/views/permission_forge/bulk_assignments/confirm_revoke.html.erb +21 -0
  86. data/app/views/permission_forge/bulk_assignments/new.html.erb +69 -0
  87. data/app/views/permission_forge/field_lookups/show.html.erb +32 -0
  88. data/app/views/permission_forge/home/_audit_card.html.erb +19 -0
  89. data/app/views/permission_forge/home/_ending_card.html.erb +32 -0
  90. data/app/views/permission_forge/home/_expired_card.html.erb +34 -0
  91. data/app/views/permission_forge/home/_privacy_card.html.erb +67 -0
  92. data/app/views/permission_forge/home/_rules_card.html.erb +23 -0
  93. data/app/views/permission_forge/home/_stale_card.html.erb +39 -0
  94. data/app/views/permission_forge/home/_tidy_card.html.erb +18 -0
  95. data/app/views/permission_forge/home/index.html.erb +69 -0
  96. data/app/views/permission_forge/people/_access_tab.html.erb +58 -0
  97. data/app/views/permission_forge/people/_audit_tab.html.erb +93 -0
  98. data/app/views/permission_forge/people/_effective_field_source.html.erb +11 -0
  99. data/app/views/permission_forge/people/_effective_tab.html.erb +118 -0
  100. data/app/views/permission_forge/people/_effective_why.html.erb +48 -0
  101. data/app/views/permission_forge/people/_why_item.html.erb +19 -0
  102. data/app/views/permission_forge/people/confirm_remove_all.html.erb +27 -0
  103. data/app/views/permission_forge/people/consent_preview.html.erb +39 -0
  104. data/app/views/permission_forge/people/erasure_preview.html.erb +28 -0
  105. data/app/views/permission_forge/people/index.html.erb +78 -0
  106. data/app/views/permission_forge/people/show.html.erb +54 -0
  107. data/app/views/permission_forge/permission_set_groups/confirm_destroy.html.erb +12 -0
  108. data/app/views/permission_forge/permission_set_groups/index.html.erb +55 -0
  109. data/app/views/permission_forge/permission_set_groups/show.html.erb +100 -0
  110. data/app/views/permission_forge/permission_set_rules/_builder.html.erb +145 -0
  111. data/app/views/permission_forge/permission_set_rules/_value_input.html.erb +42 -0
  112. data/app/views/permission_forge/permission_set_rules/confirm_destroy.html.erb +13 -0
  113. data/app/views/permission_forge/permission_set_rules/edit.html.erb +3 -0
  114. data/app/views/permission_forge/permission_set_rules/new.html.erb +3 -0
  115. data/app/views/permission_forge/permission_sets/_assignments_tab.html.erb +74 -0
  116. data/app/views/permission_forge/permission_sets/_audit_tab.html.erb +80 -0
  117. data/app/views/permission_forge/permission_sets/_conditions_tab.html.erb +83 -0
  118. data/app/views/permission_forge/permission_sets/_header.html.erb +36 -0
  119. data/app/views/permission_forge/permission_sets/_records_summary.html.erb +16 -0
  120. data/app/views/permission_forge/permission_sets/_resource_summary.html.erb +11 -0
  121. data/app/views/permission_forge/permission_sets/_resources_tab.html.erb +49 -0
  122. data/app/views/permission_forge/permission_sets/confirm_destroy.html.erb +23 -0
  123. data/app/views/permission_forge/permission_sets/confirm_switch_mode.html.erb +15 -0
  124. data/app/views/permission_forge/permission_sets/index.html.erb +83 -0
  125. data/app/views/permission_forge/permission_sets/new.html.erb +63 -0
  126. data/app/views/permission_forge/permission_sets/review.html.erb +26 -0
  127. data/app/views/permission_forge/permission_sets/show.html.erb +50 -0
  128. data/app/views/permission_forge/resource_actions/_across_sets.html.erb +24 -0
  129. data/app/views/permission_forge/resource_actions/_add_select.html.erb +12 -0
  130. data/app/views/permission_forge/resource_actions/_effect.html.erb +25 -0
  131. data/app/views/permission_forge/resource_actions/_endpoints.html.erb +16 -0
  132. data/app/views/permission_forge/resource_actions/_form.html.erb +44 -0
  133. data/app/views/permission_forge/resource_actions/_managed_note.html.erb +11 -0
  134. data/app/views/permission_forge/resource_actions/confirm_destroy.html.erb +13 -0
  135. data/app/views/permission_forge/resource_actions/new.html.erb +31 -0
  136. data/app/views/permission_forge/resource_actions/show.html.erb +39 -0
  137. data/app/views/permission_forge/resource_fields/_explain.html.erb +24 -0
  138. data/app/views/permission_forge/resource_fields/show.html.erb +186 -0
  139. data/app/views/permission_forge/resources/_actions_tab.html.erb +88 -0
  140. data/app/views/permission_forge/resources/_fields_tab.html.erb +89 -0
  141. data/app/views/permission_forge/resources/_rules_tab.html.erb +55 -0
  142. data/app/views/permission_forge/resources/_sync.html.erb +57 -0
  143. data/app/views/permission_forge/resources/confirm_destroy.html.erb +17 -0
  144. data/app/views/permission_forge/resources/new.html.erb +57 -0
  145. data/app/views/permission_forge/resources/show.html.erb +117 -0
  146. data/app/views/permission_forge/settings/_ladder.html.erb +15 -0
  147. data/app/views/permission_forge/settings/_row.html.erb +38 -0
  148. data/app/views/permission_forge/settings/show.html.erb +67 -0
  149. data/app/views/permission_forge/shared/_across_sets.html.erb +31 -0
  150. data/app/views/permission_forge/shared/_audit_events.html.erb +36 -0
  151. data/app/views/permission_forge/shared/_audit_footer.html.erb +16 -0
  152. data/app/views/permission_forge/shared/_audit_off_note.html.erb +15 -0
  153. data/app/views/permission_forge/shared/_audit_pagination.html.erb +19 -0
  154. data/app/views/permission_forge/shared/_audit_refused_note.html.erb +7 -0
  155. data/app/views/permission_forge/shared/_audit_timeline.html.erb +29 -0
  156. data/app/views/permission_forge/shared/_crud_cell.html.erb +18 -0
  157. data/app/views/permission_forge/shared/_dialog_frame.html.erb +11 -0
  158. data/app/views/permission_forge/shared/_expiry_picker.html.erb +27 -0
  159. data/app/views/permission_forge/shared/_head_extra.html.erb +3 -0
  160. data/app/views/permission_forge/shared/_help_banner.html.erb +10 -0
  161. data/app/views/permission_forge/shared/_level_control.html.erb +18 -0
  162. data/app/views/permission_forge/shared/_mode_banners.html.erb +22 -0
  163. data/app/views/permission_forge/shared/_nav.html.erb +12 -0
  164. data/app/views/permission_forge/shared/_preferences.html.erb +24 -0
  165. data/app/views/permission_forge/shared/_reason_field.html.erb +20 -0
  166. data/app/views/permission_forge/shared/_savebar.html.erb +13 -0
  167. data/app/views/permission_forge/shared/_sheet_frame.html.erb +22 -0
  168. data/app/views/permission_forge/shared/_sidebar.html.erb +18 -0
  169. data/app/views/permission_forge/shared/_tech_details.html.erb +20 -0
  170. data/app/views/permission_forge/shared/_toasts.html.erb +27 -0
  171. data/app/views/permission_forge/shared/_topbar.html.erb +15 -0
  172. data/app/views/permission_forge/shared/_user_select.html.erb +16 -0
  173. data/app/views/permission_forge/shared/audit_refused.html.erb +7 -0
  174. data/config/locales/permission_forge.en.yml +2115 -0
  175. data/config/locales/permission_forge.mk.yml +2166 -0
  176. data/config/locales/permission_forge.sq.yml +2164 -0
  177. data/config/routes.rb +143 -0
  178. data/lib/generators/permission_forge/install/install_generator.rb +105 -0
  179. data/lib/generators/permission_forge/install/templates/initializer.rb.tt +148 -0
  180. data/lib/generators/permission_forge/install/templates/migration.rb.tt +198 -0
  181. data/lib/generators/permission_forge/rls/rls_generator.rb +57 -0
  182. data/lib/generators/permission_forge/rls/templates/enable_permission_forge_rls.rb.tt +15 -0
  183. data/lib/generators/permission_forge/upgrade/templates/add_admin_ui_columns.rb.tt +8 -0
  184. data/lib/generators/permission_forge/upgrade/templates/add_audit_events.rb.tt +98 -0
  185. data/lib/generators/permission_forge/upgrade/templates/add_resource_fields_unique_index.rb.tt +69 -0
  186. data/lib/generators/permission_forge/upgrade/upgrade_generator.rb +66 -0
  187. data/lib/permission_forge/admin/action_across_sets.rb +76 -0
  188. data/lib/permission_forge/admin/action_rows.rb +156 -0
  189. data/lib/permission_forge/admin/attention_items.rb +207 -0
  190. data/lib/permission_forge/admin/audit_event_detail.rb +75 -0
  191. data/lib/permission_forge/admin/audit_log.rb +193 -0
  192. data/lib/permission_forge/admin/audit_log_days.rb +41 -0
  193. data/lib/permission_forge/admin/audit_lookups.rb +105 -0
  194. data/lib/permission_forge/admin/audit_name_history.rb +85 -0
  195. data/lib/permission_forge/admin/audit_trail.rb +169 -0
  196. data/lib/permission_forge/admin/bulk_people_matcher.rb +109 -0
  197. data/lib/permission_forge/admin/change_set.rb +123 -0
  198. data/lib/permission_forge/admin/change_set_parts/actions.rb +27 -0
  199. data/lib/permission_forge/admin/change_set_parts/base.rb +58 -0
  200. data/lib/permission_forge/admin/change_set_parts/context.rb +50 -0
  201. data/lib/permission_forge/admin/change_set_parts/field_edit.rb +67 -0
  202. data/lib/permission_forge/admin/change_set_parts/field_levels.rb +71 -0
  203. data/lib/permission_forge/admin/change_set_parts/new_actions.rb +91 -0
  204. data/lib/permission_forge/admin/change_set_parts/new_fields.rb +63 -0
  205. data/lib/permission_forge/admin/change_set_parts/resource_edit.rb +55 -0
  206. data/lib/permission_forge/admin/change_set_parts/resource_flags.rb +19 -0
  207. data/lib/permission_forge/admin/change_set_parts/set_attrs.rb +45 -0
  208. data/lib/permission_forge/admin/change_set_parts/values.rb +20 -0
  209. data/lib/permission_forge/admin/column_fields.rb +48 -0
  210. data/lib/permission_forge/admin/conditions_form.rb +78 -0
  211. data/lib/permission_forge/admin/controller_catalog.rb +166 -0
  212. data/lib/permission_forge/admin/csv.rb +41 -0
  213. data/lib/permission_forge/admin/editable_lock.rb +157 -0
  214. data/lib/permission_forge/admin/effective_access.rb +158 -0
  215. data/lib/permission_forge/admin/effective_fields.rb +176 -0
  216. data/lib/permission_forge/admin/expiry_presets.rb +51 -0
  217. data/lib/permission_forge/admin/exports/document.rb +89 -0
  218. data/lib/permission_forge/admin/exports/events.rb +51 -0
  219. data/lib/permission_forge/admin/exports/holdings.rb +118 -0
  220. data/lib/permission_forge/admin/exports.rb +37 -0
  221. data/lib/permission_forge/admin/field_across_sets.rb +51 -0
  222. data/lib/permission_forge/admin/field_detail.rb +112 -0
  223. data/lib/permission_forge/admin/field_level.rb +74 -0
  224. data/lib/permission_forge/admin/field_rows.rb +137 -0
  225. data/lib/permission_forge/admin/last_changes.rb +94 -0
  226. data/lib/permission_forge/admin/model_catalog.rb +216 -0
  227. data/lib/permission_forge/admin/people_candidates.rb +108 -0
  228. data/lib/permission_forge/admin/people_directory.rb +158 -0
  229. data/lib/permission_forge/admin/permission_set_factory.rb +48 -0
  230. data/lib/permission_forge/admin/person_label.rb +95 -0
  231. data/lib/permission_forge/admin/record_id.rb +19 -0
  232. data/lib/permission_forge/admin/rule_builder.rb +58 -0
  233. data/lib/permission_forge/admin/rule_form.rb +175 -0
  234. data/lib/permission_forge/admin/rule_form_input.rb +74 -0
  235. data/lib/permission_forge/admin/sensitive_columns.rb +68 -0
  236. data/lib/permission_forge/admin/settings_catalog.rb +261 -0
  237. data/lib/permission_forge/admin/settings_values.rb +130 -0
  238. data/lib/permission_forge/api/serializers/audit_event_serializer.rb +27 -0
  239. data/lib/permission_forge/api/serializers/base_serializer.rb +36 -0
  240. data/lib/permission_forge/api/serializers/permission_set_group_serializer.rb +12 -0
  241. data/lib/permission_forge/api/serializers/permission_set_group_user_serializer.rb +11 -0
  242. data/lib/permission_forge/api/serializers/permission_set_rule_serializer.rb +25 -0
  243. data/lib/permission_forge/api/serializers/permission_set_serializer.rb +13 -0
  244. data/lib/permission_forge/api/serializers/permission_set_user_serializer.rb +11 -0
  245. data/lib/permission_forge/api/serializers/resource_action_serializer.rb +13 -0
  246. data/lib/permission_forge/api/serializers/resource_field_serializer.rb +14 -0
  247. data/lib/permission_forge/api/serializers/resource_serializer.rb +15 -0
  248. data/lib/permission_forge/audit/actor.rb +144 -0
  249. data/lib/permission_forge/audit/actor_resolver.rb +103 -0
  250. data/lib/permission_forge/audit/audited_views.rb +208 -0
  251. data/lib/permission_forge/audit/boot_warnings.rb +58 -0
  252. data/lib/permission_forge/audit/column_values.rb +56 -0
  253. data/lib/permission_forge/audit/context.rb +98 -0
  254. data/lib/permission_forge/audit/detached.rb +62 -0
  255. data/lib/permission_forge/audit/event_catalog.rb +110 -0
  256. data/lib/permission_forge/audit/event_query.rb +192 -0
  257. data/lib/permission_forge/audit/failure.rb +47 -0
  258. data/lib/permission_forge/audit/field_access.rb +186 -0
  259. data/lib/permission_forge/audit/gate.rb +167 -0
  260. data/lib/permission_forge/audit/job_context.rb +32 -0
  261. data/lib/permission_forge/audit/log_access.rb +36 -0
  262. data/lib/permission_forge/audit/recorder.rb +168 -0
  263. data/lib/permission_forge/audit/refusals.rb +61 -0
  264. data/lib/permission_forge/audit/request_context.rb +152 -0
  265. data/lib/permission_forge/audit/runtime.rb +178 -0
  266. data/lib/permission_forge/audit/runtime_emission.rb +135 -0
  267. data/lib/permission_forge/audit/settings_observer.rb +70 -0
  268. data/lib/permission_forge/audit/source.rb +54 -0
  269. data/lib/permission_forge/audit/subscribers/base.rb +69 -0
  270. data/lib/permission_forge/audit/subscribers/legacy_hooks.rb +97 -0
  271. data/lib/permission_forge/audit/subscribers/log.rb +45 -0
  272. data/lib/permission_forge/audit/subscribers.rb +58 -0
  273. data/lib/permission_forge/audit.rb +302 -0
  274. data/lib/permission_forge/boot_checks.rb +184 -0
  275. data/lib/permission_forge/cache/backlog.rb +142 -0
  276. data/lib/permission_forge/cache/base.rb +200 -0
  277. data/lib/permission_forge/cache/bulk_invalidation.rb +131 -0
  278. data/lib/permission_forge/cache/failure.rb +171 -0
  279. data/lib/permission_forge/cache/handled_errors.rb +82 -0
  280. data/lib/permission_forge/cache/holder_invalidation.rb +85 -0
  281. data/lib/permission_forge/cache/invalidation.rb +180 -0
  282. data/lib/permission_forge/cache/memory_cache.rb +77 -0
  283. data/lib/permission_forge/cache/null_cache.rb +40 -0
  284. data/lib/permission_forge/cache/rails_cache.rb +231 -0
  285. data/lib/permission_forge/cache/rebuild.rb +149 -0
  286. data/lib/permission_forge/cache/settlement.rb +63 -0
  287. data/lib/permission_forge/cache/user_keys.rb +206 -0
  288. data/lib/permission_forge/cache/variant_removal.rb +55 -0
  289. data/lib/permission_forge/cache/withheld_notice.rb +60 -0
  290. data/lib/permission_forge/concerns/audited_collection.rb +34 -0
  291. data/lib/permission_forge/concerns/auto_permission_scoping.rb +158 -0
  292. data/lib/permission_forge/concerns/controller_action_guard.rb +106 -0
  293. data/lib/permission_forge/concerns/field_mask_checker.rb +45 -0
  294. data/lib/permission_forge/concerns/has_active_scope.rb +12 -0
  295. data/lib/permission_forge/concerns/has_audit_trail.rb +59 -0
  296. data/lib/permission_forge/concerns/has_permission_set_users.rb +115 -0
  297. data/lib/permission_forge/concerns/has_tenant_scoping.rb +86 -0
  298. data/lib/permission_forge/concerns/has_uuid.rb +24 -0
  299. data/lib/permission_forge/concerns/invalidates_in_batch.rb +207 -0
  300. data/lib/permission_forge/concerns/invalidates_managed_actions.rb +80 -0
  301. data/lib/permission_forge/concerns/masked_attributes/alias_access.rb +280 -0
  302. data/lib/permission_forge/concerns/masked_attributes/store_access.rb +100 -0
  303. data/lib/permission_forge/concerns/masked_attributes/validation_reads.rb +153 -0
  304. data/lib/permission_forge/concerns/masked_attributes/virtual_readers.rb +242 -0
  305. data/lib/permission_forge/concerns/masked_attributes.rb +576 -0
  306. data/lib/permission_forge/concerns/permission_cache_accessor.rb +416 -0
  307. data/lib/permission_forge/concerns/permission_scoped_findable.rb +337 -0
  308. data/lib/permission_forge/concerns/permissioned_user.rb +78 -0
  309. data/lib/permission_forge/concerns/permissions_memo.rb +160 -0
  310. data/lib/permission_forge/concerns/permissions_per_context.rb +288 -0
  311. data/lib/permission_forge/concerns/permissions_regenerator.rb +43 -0
  312. data/lib/permission_forge/concerns/records_audit_events.rb +286 -0
  313. data/lib/permission_forge/concerns/resource_permission_checker.rb +116 -0
  314. data/lib/permission_forge/concerns/validates_column_lengths.rb +69 -0
  315. data/lib/permission_forge/concerns/validates_dependency.rb +54 -0
  316. data/lib/permission_forge/conditional_sets.rb +361 -0
  317. data/lib/permission_forge/configuration/admin_ui_settings.rb +108 -0
  318. data/lib/permission_forge/configuration/audit_settings.rb +184 -0
  319. data/lib/permission_forge/configuration/cache_settings.rb +76 -0
  320. data/lib/permission_forge/configuration/controller_action_settings.rb +112 -0
  321. data/lib/permission_forge/configuration/gdpr_settings.rb +70 -0
  322. data/lib/permission_forge/configuration/masked_attributes_settings.rb +83 -0
  323. data/lib/permission_forge/configuration/permits_attributes_settings.rb +26 -0
  324. data/lib/permission_forge/configuration/presets.rb +139 -0
  325. data/lib/permission_forge/configuration/query_scoping_settings.rb +107 -0
  326. data/lib/permission_forge/configuration/strategy_values.rb +57 -0
  327. data/lib/permission_forge/configuration/virtual_attributes_settings.rb +65 -0
  328. data/lib/permission_forge/configuration.rb +624 -0
  329. data/lib/permission_forge/controller_actions/authorizer.rb +171 -0
  330. data/lib/permission_forge/controller_actions/managed_index.rb +213 -0
  331. data/lib/permission_forge/controller_actions/mapping.rb +167 -0
  332. data/lib/permission_forge/controller_actions.rb +122 -0
  333. data/lib/permission_forge/deprecation.rb +86 -0
  334. data/lib/permission_forge/engine.rb +113 -0
  335. data/lib/permission_forge/execution_state.rb +318 -0
  336. data/lib/permission_forge/invalidation_batch.rb +206 -0
  337. data/lib/permission_forge/masked_models.rb +226 -0
  338. data/lib/permission_forge/masking_sync.rb +271 -0
  339. data/lib/permission_forge/model_bindings.rb +90 -0
  340. data/lib/permission_forge/open_transaction.rb +303 -0
  341. data/lib/permission_forge/operators/constants.rb +41 -0
  342. data/lib/permission_forge/operators/registry.rb +300 -0
  343. data/lib/permission_forge/operators/rule_column.rb +120 -0
  344. data/lib/permission_forge/operators/validations.rb +111 -0
  345. data/lib/permission_forge/operators/value_casters/number.rb +68 -0
  346. data/lib/permission_forge/operators/value_casters/temporal.rb +69 -0
  347. data/lib/permission_forge/operators/value_casters.rb +80 -0
  348. data/lib/permission_forge/operators/value_typing.rb +115 -0
  349. data/lib/permission_forge/query_scoping_warnings.rb +68 -0
  350. data/lib/permission_forge/rake_audit.rb +65 -0
  351. data/lib/permission_forge/resource_chain.rb +190 -0
  352. data/lib/permission_forge/rls.rb +209 -0
  353. data/lib/permission_forge/services/attribute_access_guard.rb +138 -0
  354. data/lib/permission_forge/services/audit_event_purger.rb +150 -0
  355. data/lib/permission_forge/services/bulk_permission_set_assignment.rb +108 -0
  356. data/lib/permission_forge/services/concerns/across_tenants.rb +69 -0
  357. data/lib/permission_forge/services/concerns/audited_operation.rb +43 -0
  358. data/lib/permission_forge/services/concerns/clonable.rb +29 -0
  359. data/lib/permission_forge/services/consent_withdrawal_handler.rb +135 -0
  360. data/lib/permission_forge/services/dashboard_stats.rb +43 -0
  361. data/lib/permission_forge/services/data_subject_erasure.rb +306 -0
  362. data/lib/permission_forge/services/expired_assignment_cleaner.rb +152 -0
  363. data/lib/permission_forge/services/extra_field_defaults.rb +71 -0
  364. data/lib/permission_forge/services/field_permission_merger.rb +83 -0
  365. data/lib/permission_forge/services/filters_response_attributes.rb +144 -0
  366. data/lib/permission_forge/services/gdpr_field_auditor/text_report.rb +63 -0
  367. data/lib/permission_forge/services/gdpr_field_auditor.rb +223 -0
  368. data/lib/permission_forge/services/host_key_guard.rb +110 -0
  369. data/lib/permission_forge/services/mask_value.rb +93 -0
  370. data/lib/permission_forge/services/permission_cache_clearer.rb +174 -0
  371. data/lib/permission_forge/services/permission_hash_builder.rb +305 -0
  372. data/lib/permission_forge/services/permission_rule_applier.rb +192 -0
  373. data/lib/permission_forge/services/permission_set_cloner.rb +89 -0
  374. data/lib/permission_forge/services/permission_set_condition_evaluator.rb +102 -0
  375. data/lib/permission_forge/services/permission_set_group_cloner.rb +52 -0
  376. data/lib/permission_forge/services/permits_attributes/nested_records.rb +106 -0
  377. data/lib/permission_forge/services/permits_attributes.rb +270 -0
  378. data/lib/permission_forge/services/resource_field_generator.rb +93 -0
  379. data/lib/permission_forge/services/resource_with_fields_builder.rb +47 -0
  380. data/lib/permission_forge/services/rls_operator_sql.rb +131 -0
  381. data/lib/permission_forge/services/rls_policy_generator.rb +365 -0
  382. data/lib/permission_forge/services/security_audit/action_check.rb +157 -0
  383. data/lib/permission_forge/services/security_audit/assignment_check.rb +120 -0
  384. data/lib/permission_forge/services/security_audit/audit_trail_check.rb +166 -0
  385. data/lib/permission_forge/services/security_audit/base_check.rb +57 -0
  386. data/lib/permission_forge/services/security_audit/config_check.rb +212 -0
  387. data/lib/permission_forge/services/security_audit/integrity_check.rb +105 -0
  388. data/lib/permission_forge/services/security_audit/personal_data_check.rb +79 -0
  389. data/lib/permission_forge/services/security_audit/privilege_check.rb +67 -0
  390. data/lib/permission_forge/services/security_audit/rule_check.rb +141 -0
  391. data/lib/permission_forge/services/security_audit/text_report.rb +46 -0
  392. data/lib/permission_forge/services/security_audit/user_model_check.rb +67 -0
  393. data/lib/permission_forge/services/security_audit.rb +75 -0
  394. data/lib/permission_forge/services/stale_assignment_auditor.rb +68 -0
  395. data/lib/permission_forge/services/unscoped_query_detector.rb +132 -0
  396. data/lib/permission_forge/version.rb +3 -0
  397. data/lib/permission_forge/virtual_fields/discovery.rb +65 -0
  398. data/lib/permission_forge/virtual_fields.rb +102 -0
  399. data/lib/permission_forge.rb +464 -0
  400. data/lib/tasks/permission_forge.rake +31 -0
  401. data/lib/tasks/permission_forge_audit_events.rake +28 -0
  402. data/lib/tasks/permission_forge_cache.rake +26 -0
  403. data/lib/tasks/permission_forge_expiration.rake +30 -0
  404. data/lib/tasks/permission_forge_gdpr.rake +69 -0
  405. data/lib/tasks/permission_forge_rls.rake +38 -0
  406. metadata +515 -0
@@ -0,0 +1,230 @@
1
+ module PermissionForge
2
+ # Labels and links for audit rows (Person → Audit, Permission Set → Audit,
3
+ # CSV) and for audit events (the audit log, the timelines, an event).
4
+ #
5
+ # @api private
6
+ module AuditHelper
7
+ # Every category has a glyph and a word (never colour alone).
8
+ CATEGORY_GLYPHS = { 'change' => '✎', 'admin' => '⚑', 'system' => '⚙', 'access' => '◉' }.freeze
9
+ CATEGORY_VARIANTS = { 'change' => :changed, 'admin' => :warn, 'system' => :default, 'access' => :mask }.freeze
10
+ # The message Audit::EventQuery gives a filter that needs a value (Rails' own).
11
+ QUERY_BLANK_MESSAGE = 'errors.messages.blank'.freeze
12
+ # [record label, where] per audited type; evaluated in the view context.
13
+ AUDIT_LABELS = {
14
+ 'permission_set' => ->(record) { [record.name, nil] },
15
+ 'group' => ->(record) { [record.name, nil] },
16
+ 'resource' => ->(record) { [pf_resource_label(record), record.permission_set&.name] },
17
+ 'field' => ->(record) { [record.label.presence || record.name, pf_audit_resource_where(record.resource)] },
18
+ 'action' => ->(record) { [record.label.presence || record.name, pf_audit_resource_where(record.resource)] },
19
+ 'rule' => ->(record) { [record.name, pf_audit_resource_where(record.resource)] },
20
+ 'assignment' => ->(record) { [pf_person_name(record.user), record.permission_set&.name] },
21
+ 'group_membership' => ->(record) { [pf_person_name(record.user), record.permission_set_group&.name] }
22
+ }.freeze
23
+
24
+ # Link target per audited type; evaluated in the view context.
25
+ AUDIT_PATHS = {
26
+ 'permission_set' => ->(record) { permission_forge.permission_set_path(record) },
27
+ 'resource' => ->(record) { permission_forge.permission_set_resource_path(record.permission_set_id, record.name) },
28
+ 'field' => ->(record) { pf_audit_nested_path(record.resource, :field, record.name) },
29
+ 'action' => ->(record) { pf_audit_nested_path(record.resource, :action, record.id) },
30
+ 'rule' => ->(record) { pf_audit_nested_path(record.resource, :rule, record.id) },
31
+ 'assignment' => ->(record) { permission_forge.person_path(record.user_id) },
32
+ 'group' => ->(record) { permission_forge.group_path(record) },
33
+ 'group_membership' => ->(record) { permission_forge.group_path(record.permission_set_group_id) }
34
+ }.freeze
35
+
36
+ def pf_audit_type_label(type)
37
+ t("permission_forge.ui.audit.types.#{type}")
38
+ end
39
+
40
+ # [type label, record label, where] — also used for CSV export.
41
+ def pf_audit_describe(entry)
42
+ record = entry.record
43
+ label, where = pf_audit_label_and_where(entry.type, record)
44
+ [pf_audit_type_label(entry.type), label, where]
45
+ end
46
+
47
+ def pf_audit_label_and_where(type, record)
48
+ pf_audit_lookup(AUDIT_LABELS, type, record)
49
+ end
50
+
51
+ def pf_audit_resource_where(resource)
52
+ return nil unless resource
53
+
54
+ "#{resource.permission_set&.name} › #{pf_resource_label(resource)}"
55
+ end
56
+
57
+ def pf_audit_path(entry)
58
+ pf_audit_lookup(AUDIT_PATHS, entry.type, entry.record)
59
+ end
60
+
61
+ # Runs the registry entry for `type` in the view context; nil for unknown types.
62
+ def pf_audit_lookup(registry, type, record)
63
+ handler = registry[type]
64
+ instance_exec(record, &handler) if handler
65
+ end
66
+
67
+ def pf_audit_nested_path(resource, kind, key)
68
+ return nil unless resource
69
+
70
+ case kind
71
+ when :field then permission_forge.permission_set_resource_field_path(resource.permission_set_id, resource.name, key)
72
+ when :action then permission_forge.permission_set_resource_action_path(resource.permission_set_id, resource.name, key)
73
+ when :rule then permission_forge.edit_permission_set_resource_rule_path(resource.permission_set_id, resource.name, key)
74
+ end
75
+ end
76
+
77
+ def pf_audit_who_when(person, time, relative: false)
78
+ t('permission_forge.ui.audit.who_when', who: pf_person_name(person),
79
+ when: relative ? pf_relative(time) : pf_date(time))
80
+ end
81
+
82
+ # "Who · when" of an entry's last edit: the latest change event's actor
83
+ # and time when there is one, else updated_by / updated_at.
84
+ def pf_audit_edited(entry)
85
+ return pf_audit_who_when(entry.updated_by, entry.updated_at, relative: true) unless entry.last_event
86
+
87
+ t('permission_forge.ui.audit.who_when', who: pf_audit_actor_name(entry.last_event), when: pf_relative(entry.updated_at))
88
+ end
89
+
90
+ # The record's last edit as an event (who really made it), or nil when
91
+ # the audit log has none for it or the record was edited after it.
92
+ # Uses the page's preloaded lookup when it covers the record.
93
+ def pf_audit_last_change(record)
94
+ lookup = @pf_last_changes if @pf_last_changes&.covers?(record)
95
+ (lookup || PermissionForge::Admin::LastChanges.new([record])).last_edit(record)
96
+ end
97
+
98
+ # One lookup for every record whose footer the page shows (two queries
99
+ # per record type instead of two per footer). Nothing while the audit
100
+ # trail is off: the footers then say so and ask nothing.
101
+ def pf_audit_preload_last_changes(records)
102
+ return unless PermissionForge.configuration.audit_trail_enabled
103
+
104
+ @pf_last_changes = PermissionForge::Admin::LastChanges.new(records)
105
+ nil
106
+ end
107
+
108
+ # [created by, last edited by] names for the audit-trail CSV.
109
+ def pf_audit_people(entry)
110
+ edited_by = entry.last_event ? pf_audit_actor_name(entry.last_event) : pf_person_name(entry.updated_by)
111
+ [pf_person_name(entry.created_by), edited_by]
112
+ end
113
+
114
+ # --- audit events ---------------------------------------------------------
115
+
116
+ # Who acted, as recorded: the name snapshot, with what kind of actor it
117
+ # was when that is not a person ("ops (password prompt)", "API client").
118
+ def pf_audit_actor_name(event)
119
+ type = event.actor_type.to_s
120
+ label = event.actor_label.presence
121
+ return t("permission_forge.audit_log.actor_named.#{type}", name: label, default: label) if label
122
+ return t('permission_forge.audit_log.person_id', id: event.actor_id) if type == 'user' && event.actor_id.present?
123
+
124
+ pf_audit_actor_type_label(type)
125
+ end
126
+
127
+ def pf_audit_actor_type_label(type)
128
+ t("permission_forge.audit_log.actor_types.#{type}", default: type.to_s.humanize)
129
+ end
130
+
131
+ def pf_audit_category_label(category)
132
+ t("permission_forge.audit_log.categories.#{category}", default: category.to_s.humanize)
133
+ end
134
+
135
+ def pf_audit_source_label(source)
136
+ t("permission_forge.audit_log.sources.#{source}", default: source.to_s.humanize)
137
+ end
138
+
139
+ def pf_audit_category_badge(category)
140
+ pf_badge("#{CATEGORY_GLYPHS.fetch(category.to_s, '•')} #{pf_audit_category_label(category)}",
141
+ variant: CATEGORY_VARIANTS.fetch(category.to_s, :default))
142
+ end
143
+
144
+ def pf_audit_event_path(event, **)
145
+ permission_forge.audit_event_path(event.uuid, **)
146
+ end
147
+
148
+ # The audit log, filtered (only the filters given).
149
+ def pf_audit_log_path(**filters)
150
+ permission_forge.audit_events_path(filters.compact_blank)
151
+ end
152
+
153
+ # The person an event is about: the label an assignment's or a
154
+ # membership's event kept (their name then), else the user when they
155
+ # still exist, else their id.
156
+ def pf_audit_person_name(event, lookups)
157
+ id = event.subject_user_id
158
+ return nil if id.blank?
159
+
160
+ return event.record_label if pf_audit_person_record?(event) && event.record_label.present?
161
+
162
+ user = lookups.person(id)
163
+ return pf_person_name(user) if user
164
+
165
+ t('permission_forge.audit_log.person_id', id: id)
166
+ end
167
+
168
+ def pf_audit_person_record?(event)
169
+ %w[PermissionForge::PermissionSetUser PermissionForge::PermissionSetGroupUser].include?(event.record_type)
170
+ end
171
+
172
+ # The Permission Set's name when the event happened (AuditLookups), or
173
+ # what the event says about it.
174
+ def pf_audit_set_name(event, lookups)
175
+ return nil if event.permission_set_id.blank? && event.permission_set_uuid.blank?
176
+ return event.record_label if event.record_type == 'PermissionForge::PermissionSet' && event.record_label.present?
177
+
178
+ name = lookups.permission_set_name(event)
179
+ return name if name
180
+
181
+ t('permission_forge.audit_log.deleted_set', id: event.permission_set_id || event.permission_set_uuid)
182
+ end
183
+
184
+ def pf_audit_group_name(event, lookups)
185
+ name = lookups.group_name(event)
186
+ return name if name
187
+
188
+ id = PermissionForge::Admin::AuditLookups.group_id(event)
189
+ id ? t('permission_forge.audit_log.deleted_group', id: id) : t('permission_forge.audit_log.unknown_group')
190
+ end
191
+
192
+ # What the event is about: its label snapshot, else type and id.
193
+ def pf_audit_record_name(event)
194
+ return event.record_label if event.record_label.present?
195
+ return '' if event.record_type.blank?
196
+
197
+ [event.record_type.to_s.demodulize.titleize, event.record_id || event.record_uuid].compact.join(' #')
198
+ end
199
+
200
+ # A value of a before/after or snapshot table: text as it is, nothing as
201
+ # a dash-word, anything else as JSON.
202
+ def pf_audit_value(value)
203
+ case value
204
+ when nil then t('permission_forge.audit_log.no_value')
205
+ when String then value.empty? ? t('permission_forge.audit_log.empty_text') : value
206
+ when true, false then t("permission_forge.audit_log.boolean.#{value}")
207
+ when Numeric then value.to_s
208
+ else ActiveSupport::JSON.encode(value)
209
+ end
210
+ end
211
+
212
+ # One line per refused filter, in the admin's own words (EventQuery's
213
+ # messages are Rails' errors.messages, which a host may not translate):
214
+ # a value that is needed, or the value that is not valid.
215
+ def pf_audit_filter_error(name, messages, value)
216
+ field = t("permission_forge.audit_events.index.filters.#{name}", default: name.to_s.humanize)
217
+ scope = 'permission_forge.audit_events.index.invalid'
218
+ return t("#{scope}.blank", field: field) if Array(messages).include?(I18n.t(QUERY_BLANK_MESSAGE))
219
+ return t("#{scope}.invalid", field: field) if value.blank?
220
+
221
+ t("#{scope}.invalid_value", field: field, value: value.truncate(80))
222
+ end
223
+
224
+ # The first scalar of a filter parameter (a list or a hash shows nothing).
225
+ def pf_audit_filter_value(filters, key)
226
+ value = filters[key]
227
+ value.is_a?(String) ? value : nil
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # One plain-language sentence per audit event ("Ana gave Bo the Permission
5
+ # Set Sales.").
6
+ #
7
+ # @api private
8
+ module AuditSentencesHelper
9
+ AUDIT_SENTENCES = 'permission_forge.audit_log.sentences'
10
+
11
+ # Events whose sentence depends on what they recorded: event name →
12
+ # a block (run in the view) that returns the sentence, or nil for the
13
+ # event's own one. Adding a variant is one entry here plus its key.
14
+ AUDIT_SENTENCE_VARIANTS = {
15
+ # Names the fields that were hidden or masked; a read with none of
16
+ # them is the plain "read X." sentence.
17
+ 'field.access' => lambda { |event, parts|
18
+ fields = pf_audit_limited_fields(event)
19
+ next if fields.empty?
20
+
21
+ t('permission_forge.audit_log.sentence_variants.field_access_limited', **parts, fields: fields.join(', '))
22
+ },
23
+ # A revocation of an assignment that had already ended (the clean-up).
24
+ 'assignment.revoked' => lambda { |event, parts|
25
+ ended = pf_audit_expired_at(event)
26
+ next unless ended
27
+
28
+ t('permission_forge.audit_log.sentence_variants.assignment_revoked_expired', **parts, date: pf_date(ended))
29
+ }
30
+ }.freeze
31
+
32
+ # One sentence per event, from the locale key named after the event
33
+ # (permission_forge.audit_log.sentences.<event>): a new event needs one
34
+ # key in each locale, and an event without one (a host's own) still reads
35
+ # as "<actor>: <event name>". Interpolations: actor, record, set, person,
36
+ # group, event, model (a runtime event's model).
37
+ def pf_audit_sentence(event, lookups)
38
+ parts = pf_audit_sentence_parts(event, lookups)
39
+ variant = AUDIT_SENTENCE_VARIANTS[event.event]
40
+ variant &&= instance_exec(event, parts, &variant)
41
+ return variant if variant
42
+
43
+ fallback = t("#{AUDIT_SENTENCES}.fallback", **parts)
44
+ sentence = t("#{AUDIT_SENTENCES}.#{event.event}", **parts, default: fallback)
45
+ sentence.is_a?(String) ? sentence : fallback
46
+ end
47
+
48
+ # The hidden and masked field names a field.access event lists.
49
+ def pf_audit_limited_fields(event)
50
+ data = event.data || {}
51
+ %w[hidden masked].flat_map { |key| Array(data[key]) }.map(&:to_s).uniq
52
+ end
53
+
54
+ # When the revoked assignment had ended, if it had ended before the
55
+ # revocation (its snapshot's expires_at).
56
+ def pf_audit_expired_at(event)
57
+ snapshot = event.data.is_a?(Hash) ? event.data['snapshot'] : nil
58
+ ended = pf_audit_time(snapshot['expires_at']) if snapshot.is_a?(Hash)
59
+ ended if ended && event.occurred_at && ended <= event.occurred_at
60
+ end
61
+
62
+ def pf_audit_time(value)
63
+ Time.zone.parse(value.to_s) if value.present?
64
+ rescue ArgumentError
65
+ nil
66
+ end
67
+
68
+ def pf_audit_sentence_parts(event, lookups)
69
+ unknown = t('permission_forge.audit_log.unknown')
70
+ {
71
+ actor: pf_audit_actor_name(event), record: pf_audit_record_name(event).presence || unknown,
72
+ set: pf_audit_set_name(event, lookups) || unknown, person: pf_audit_person_name(event, lookups) || unknown,
73
+ group: pf_audit_person_record?(event) ? pf_audit_group_name(event, lookups) : unknown, event: event.event,
74
+ model: pf_audit_model_name(event).presence || unknown
75
+ }
76
+ end
77
+
78
+ # The model a runtime event is about: data['model'], else the record type.
79
+ def pf_audit_model_name(event)
80
+ model = event.data.is_a?(Hash) ? event.data['model'] : nil
81
+ (model.is_a?(String) ? model : event.record_type).to_s.demodulize
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,134 @@
1
+ module PermissionForge
2
+ # Badges, Permission Set chips, field level badges and mask previews.
3
+ # Every class is pf- prefixed; no inline styles (strict-CSP friendly).
4
+ #
5
+ # @api private
6
+ module BadgesHelper
7
+ LEVEL_GLYPHS = { 'hidden' => '∅', 'masked' => '••', 'read' => '◉', 'edit' => '✎', 'leave' => '–',
8
+ 'mask' => '••', 'hide' => '⊘', 'muted' => '⊘', 'none' => '–' }.freeze
9
+
10
+ MASK_SAMPLES = [
11
+ [/email/, 'james.walker@acme.com'],
12
+ [/iban|bank|account|card/, 'DE89 3704 0044 0532 0130 00'],
13
+ [/phone|mobile|fax|tel/, '+383 44 123 456'],
14
+ [/cents|salary|amount|price|value/, '4 200,00 €'],
15
+ [/national|ssn|social/, '1234567890'],
16
+ [/birth|dob/, '1988-04-17'],
17
+ [/vat|tax/, 'XK810123456'],
18
+ [/address|street|city|zip|postal/, 'Rruga Agim Ramadani 15']
19
+ ].freeze
20
+ DEFAULT_MASK_SAMPLE = 'James Walker'.freeze
21
+
22
+ CHIP_PREFIXES = { muting: '⊘ ', inactive: '○ ' }.freeze
23
+ CHIP_SUFFIX_KEYS = { inactive: 'badges.off', expired: 'badges.expired' }.freeze
24
+
25
+ # --- badges ---------------------------------------------------------------
26
+
27
+ # Options: title:, href:, square: (bool), extra: (additional classes).
28
+ def pf_badge(text, variant: :default, **opts)
29
+ classes = ['pf-badge', ("pf-badge--#{variant}" unless variant.to_s == 'default'),
30
+ ('pf-badge--square' if opts[:square]), opts[:extra]].compact.join(' ')
31
+ content = safe_join(Array(text))
32
+ href = opts[:href]
33
+ return link_to(content, href, class: classes, title: opts[:title]) if href
34
+
35
+ content_tag(:span, content, class: classes, title: opts[:title])
36
+ end
37
+
38
+ def pf_lock_badge(title: pf_ui('locked.title'))
39
+ pf_badge([pf_lock_icon, pf_ui('badges.locked')], title: title)
40
+ end
41
+
42
+ def pf_off_badge
43
+ content_tag(:span, "○ #{pf_ui('badges.off')}", class: 'pf-badge pf-badge--off')
44
+ end
45
+
46
+ def pf_fail_open?
47
+ !PermissionForge.configuration.conditional_sets_strict_mode
48
+ end
49
+
50
+ def pf_fail_open_badge(long: false)
51
+ text = long ? pf_ui('badges.fail_open_long') : pf_ui('badges.fail_open')
52
+ pf_badge("⚠ #{text}", variant: :failopen, href: pf_setting_path('conditional_sets_strict_mode'),
53
+ title: pf_ui('badges.fail_open_title'))
54
+ end
55
+
56
+ def pf_set_badges(permission_set)
57
+ badges = []
58
+ badges << pf_badge("⊘ #{pf_ui('badges.muting')}", variant: :muting) if permission_set.is_muting
59
+ if permission_set.conditional?
60
+ badges << pf_badge("◷ #{pf_ui('badges.conditional')}", variant: :conditional,
61
+ title: pf_condition_sentence(permission_set))
62
+ badges << pf_fail_open_badge if pf_fail_open?
63
+ end
64
+ badges << pf_lock_badge if pf_locked_record?(permission_set)
65
+ safe_join(badges, ' ')
66
+ end
67
+
68
+ def pf_locked_record?(record)
69
+ record.respond_to?(:is_editable) && record.is_editable == false
70
+ end
71
+
72
+ # Permission Set chip. state: :normal / :inactive / :expired (auto from set).
73
+ def pf_set_chip(permission_set, large: false, href: :auto, expired: false)
74
+ state = pf_set_chip_state(permission_set, expired)
75
+ classes = ['pf-chip', ('pf-chip--lg' if large), ("pf-chip--#{state}" unless state == :normal)].compact.join(' ')
76
+ body = pf_set_chip_body(permission_set, state)
77
+ href = permission_forge.permission_set_path(permission_set) if href == :auto
78
+ href ? link_to(body, href, class: classes) : content_tag(:span, body, class: classes)
79
+ end
80
+
81
+ def pf_set_chip_state(permission_set, expired)
82
+ return :expired if expired
83
+ return :inactive unless permission_set.is_active
84
+
85
+ permission_set.is_muting ? :muting : :normal
86
+ end
87
+
88
+ def pf_set_chip_body(permission_set, state)
89
+ suffix_key = CHIP_SUFFIX_KEYS[state]
90
+ safe_join([content_tag(:span, "#{CHIP_PREFIXES[state]}#{permission_set.name}"),
91
+ (content_tag(:span, pf_ui(suffix_key), class: 'pf-chip__suffix') if suffix_key)].compact, ' ')
92
+ end
93
+
94
+ # Effective/configured field level badge ("•• Masked · j***@acme.com").
95
+ def pf_level_badge(level, strategy: nil, sample_for: nil, label: nil)
96
+ level = level.to_s
97
+ text = label || pf_level_label(level)
98
+ if %w[masked mask].include?(level) && strategy.present?
99
+ text = "#{text} (#{pf_mask_strategy_label(strategy)})"
100
+ text = "#{text} · #{pf_mask_preview(sample_for, strategy)}" if sample_for
101
+ end
102
+ content_tag(:span, "#{LEVEL_GLYPHS.fetch(level, '–')} #{text}", class: "pf-level-badge pf-level-badge--#{level}")
103
+ end
104
+
105
+ def pf_level_label(level)
106
+ pf_ui("levels.#{level}")
107
+ end
108
+
109
+ def pf_mask_strategy_label(strategy)
110
+ t("permission_forge.ui.mask_strategies.#{strategy}", default: strategy.to_s)
111
+ end
112
+
113
+ def pf_mask_strategies
114
+ PermissionForge::Services::MaskValue.strategies.keys.map(&:to_s)
115
+ end
116
+
117
+ def pf_custom_mask_strategy?(strategy)
118
+ !PermissionForge::Services::MaskValue::BUILT_IN_STRATEGIES.key?(strategy.to_s)
119
+ end
120
+
121
+ def pf_mask_sample(column)
122
+ name = column.to_s
123
+ MASK_SAMPLES.find { |pattern, _| name.match?(pattern) }&.last || DEFAULT_MASK_SAMPLE
124
+ end
125
+
126
+ # Runs the real MaskValue strategy on a sample value, so previews always
127
+ # match what people will see.
128
+ def pf_mask_preview(column, strategy)
129
+ PermissionForge::Services::MaskValue.call(pf_mask_sample(column), strategy: strategy.to_s)
130
+ rescue StandardError
131
+ '••••••••'
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,117 @@
1
+ module PermissionForge
2
+ # One plain-language line per ChangeSet change (the "Review and save" list).
3
+ #
4
+ # @api private
5
+ module ChangesHelper
6
+ # ChangeSet::Change#kind => the helper that words it. A new kind is one
7
+ # entry here plus its helper.
8
+ CHANGE_DESCRIBERS = {
9
+ set_attr: :pf_set_attr_change, resource_flag: :pf_resource_flag_change, resource_text: :pf_resource_text_change,
10
+ field_level: :pf_field_level_change, new_field: :pf_field_level_change, field_attr: :pf_field_attr_change,
11
+ action: :pf_action_change, new_action: :pf_new_action_change
12
+ }.freeze
13
+
14
+ def pf_change_description(change)
15
+ describer = CHANGE_DESCRIBERS[change.kind]
16
+ send(describer, change) if describer
17
+ end
18
+
19
+ def pf_resource_text_change(change)
20
+ pf_ui('changes.resource_text', resource: pf_resource_label(change.record),
21
+ attribute: pf_ui("changes.attributes.#{change.attribute}"))
22
+ end
23
+
24
+ def pf_action_change(change)
25
+ muting = change.resource&.permission_set&.is_muting
26
+ key = if muting then change.to ? 'changes.action_muted' : 'changes.action_unmuted'
27
+ else change.to ? 'changes.action_on' : 'changes.action_off'
28
+ end
29
+ pf_ui(key, resource: pf_resource_label(change.resource), action: change.record.display_name)
30
+ end
31
+
32
+ # A discovered controller action added to the set, followed by what that
33
+ # means for everyone: "Customer: Export added, permitted (this makes it
34
+ # managed: ...)".
35
+ def pf_new_action_change(change)
36
+ resource = change.resource
37
+ action = change.record
38
+ key = if resource.permission_set.is_muting then change.to ? 'changes.action_new_muted' : 'changes.action_new_unmuted'
39
+ else change.to ? 'changes.action_new_on' : 'changes.action_new_off'
40
+ end
41
+ text = pf_ui(key, resource: pf_resource_label(resource), action: action.display_name)
42
+ "#{text} #{pf_action_managed_note(resource, action.name)}"
43
+ end
44
+
45
+ # The first row for an action makes it managed for everyone; later rows
46
+ # join the sets that already manage it.
47
+ def pf_action_managed_note(resource, key)
48
+ return pf_ui('changes.action_managed_later') unless resource.in_effect?
49
+
50
+ across = pf_action_across(resource.name)
51
+ return pf_ui('changes.action_managed_first') unless across.managed?(key)
52
+
53
+ sets = across.permitting_sets(key).map(&:name)
54
+ sets.any? ? pf_ui('changes.action_managed_already', sets: sets.join(', ')) : pf_ui('changes.action_managed_nobody')
55
+ end
56
+
57
+ # One lookup per resource name per page.
58
+ def pf_action_across(resource_name)
59
+ (@pf_action_across ||= {})[resource_name] ||= PermissionForge::Admin::ActionAcrossSets.new(resource_name)
60
+ end
61
+
62
+ def pf_set_attr_change(change)
63
+ case change.attribute
64
+ when 'is_active' then pf_ui(change.to ? 'changes.set_on' : 'changes.set_off')
65
+ when 'permission_set_group_id'
66
+ group = change.to && PermissionForge::PermissionSetGroup.find_by(id: change.to)
67
+ group ? pf_ui('changes.set_group', group: group.name) : pf_ui('changes.set_no_group')
68
+ else pf_ui('changes.set_text', attribute: pf_ui("changes.attributes.#{change.attribute}"))
69
+ end
70
+ end
71
+
72
+ def pf_resource_flag_change(change)
73
+ key = change.to ? 'changes.flag_on' : 'changes.flag_off'
74
+ key = "#{key}_muting" if change.resource&.permission_set&.is_muting
75
+ text = pf_ui(key, resource: pf_resource_label(change.resource), flag: pf_flag_name(change.attribute))
76
+ change.cascaded ? "#{text} #{pf_ui('changes.cascaded')}" : text
77
+ end
78
+
79
+ def pf_flag_name(flag)
80
+ pf_ui("flags_short.#{flag}")
81
+ end
82
+
83
+ def pf_field_level_change(change)
84
+ from_level, = Array(change.from)
85
+ args = { resource: pf_resource_label(change.resource), field: pf_change_field_name(change.record),
86
+ to: pf_change_level_text(change.to) }
87
+ text = if from_level
88
+ pf_ui('changes.field_level', **args, from: pf_level_label(from_level))
89
+ else
90
+ pf_ui('changes.field_new', **args)
91
+ end
92
+ change.cascaded ? "#{text} #{pf_ui('changes.cascaded_field')}" : text
93
+ end
94
+
95
+ # "Masked (email)" for [level, strategy].
96
+ def pf_change_level_text(level_and_strategy)
97
+ level, strategy = Array(level_and_strategy)
98
+ text = pf_level_label(level)
99
+ %w[masked mask].include?(level) && strategy ? "#{text} (#{pf_mask_strategy_label(strategy)})" : text
100
+ end
101
+
102
+ def pf_change_field_name(field)
103
+ field.label.presence || field.name
104
+ end
105
+
106
+ def pf_field_attr_change(change)
107
+ field = change.record
108
+ if change.attribute == 'is_active'
109
+ return pf_ui(change.to ? 'changes.field_on' : 'changes.field_off', resource: pf_resource_label(change.resource),
110
+ field: field.label.presence || field.name)
111
+ end
112
+
113
+ pf_ui('changes.field_text', resource: pf_resource_label(change.resource), field: field.name,
114
+ attribute: pf_ui("changes.attributes.#{change.attribute}"))
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,118 @@
1
+ module PermissionForge
2
+ # Person labels, avatars and assignment expiry/review cells.
3
+ #
4
+ # @api private
5
+ module PeopleHelper
6
+ def pf_person(user)
7
+ @pf_person_labels ||= {}
8
+ @pf_person_labels[[user.class.name, user.id]] ||= PermissionForge::Admin::PersonLabel.new(user)
9
+ end
10
+
11
+ def pf_person_name(user)
12
+ user ? pf_person(user).name : pf_ui('audit.system')
13
+ end
14
+
15
+ def pf_avatar(user, size: nil)
16
+ classes = ['pf-avatar', ("pf-avatar--#{size}" if size)].compact.join(' ')
17
+ content_tag(:span, pf_person(user).initials, class: classes, 'aria-hidden': true)
18
+ end
19
+
20
+ # Avatar + name + email block.
21
+ def pf_person_cell(user, href: nil)
22
+ label = pf_person(user)
23
+ body = safe_join([pf_avatar(user),
24
+ content_tag(:span, class: 'pf-stack pf-stack--tight pf-ellipsis') do
25
+ safe_join([content_tag(:span, label.name, class: 'pf-person-name pf-ellipsis'),
26
+ (content_tag(:span, label.secondary, class: 'pf-person-email pf-ellipsis') if label.secondary)].compact)
27
+ end])
28
+ href ? link_to(body, href, class: 'pf-cell-person') : content_tag(:span, body, class: 'pf-cell-person')
29
+ end
30
+
31
+ # --- assignments --------------------------------------------------------
32
+
33
+ # [text, css tone class] for an assignment end date.
34
+ def pf_expiry(expires_at)
35
+ days = PermissionForge::Admin::ExpiryPresets.days_left(expires_at)
36
+ return [pf_ui('expiry.none'), 'pf-text-2'] if days.nil?
37
+ return [pf_ui('expiry.expired', date: pf_date(expires_at, year: false)), 'pf-text-3'] if expires_at < Time.current
38
+ return [pf_ui('expiry.today'), 'pf-text-warn'] if days.zero?
39
+ return [pf_ui('expiry.ends_in', count: days, date: pf_date(expires_at, year: false)), 'pf-text-warn'] if days <= 7
40
+
41
+ [pf_ui('expiry.until', date: pf_date(expires_at, year: false)), 'pf-text-2']
42
+ end
43
+
44
+ def pf_expiry_tag(expires_at)
45
+ text, tone = pf_expiry(expires_at)
46
+ content_tag(:span, text, class: tone)
47
+ end
48
+
49
+ # "✓ Reviewed by Ana on 3 Oct 2026" / "– Never reviewed"; a warning
50
+ # (⚠, warn tone) when the assignment is due for review (pf_stale?).
51
+ def pf_reviewed(assignment)
52
+ stale = pf_stale?(assignment)
53
+ reviewed = pf_review_known?(assignment) && assignment.reviewed_at.present?
54
+ glyph = content_tag(:span, pf_review_glyph(stale, reviewed), 'aria-hidden': true)
55
+ content_tag(:span, safe_join([glyph, pf_review_text(assignment)], ' '), class: "pf-fs-12 #{stale ? 'pf-text-warn' : 'pf-text-3'}")
56
+ end
57
+
58
+ def pf_review_glyph(stale, reviewed)
59
+ return '⚠' if stale
60
+
61
+ reviewed ? '✓' : '–'
62
+ end
63
+
64
+ # "Reviewed by <person> on <date>", "Reviewed on <date>" (no person
65
+ # recorded, e.g. a password-prompt login), or "Never reviewed". Without
66
+ # the review columns (upgrade not run) the last update counts, as before.
67
+ def pf_review_text(assignment)
68
+ return pf_ui('assignment.reviewed', when: pf_relative(assignment.last_reviewed_at)) unless pf_review_known?(assignment)
69
+
70
+ reviewed_at = assignment.reviewed_at
71
+ return pf_ui('assignment.never_reviewed') if reviewed_at.nil?
72
+
73
+ reviewer = pf_reviewer(assignment)
74
+ return pf_ui('assignment.reviewed_on', date: pf_date(reviewed_at)) unless reviewer
75
+
76
+ pf_ui('assignment.reviewed_by', who: pf_person_name(reviewer), date: pf_date(reviewed_at))
77
+ end
78
+
79
+ def pf_review_known?(assignment)
80
+ assignment.class.respond_to?(:review_supported?) && assignment.class.review_supported?
81
+ end
82
+
83
+ # The person who last reviewed the assignment (memoized per page), or nil.
84
+ def pf_reviewer(assignment)
85
+ id = assignment.try(:reviewed_by_id)
86
+ return nil if id.blank?
87
+
88
+ @pf_reviewers ||= {}
89
+ return @pf_reviewers[id.to_s] if @pf_reviewers.key?(id.to_s)
90
+
91
+ @pf_reviewers[id.to_s] = PermissionForge.configuration.user_model.find_by(id: id)
92
+ end
93
+
94
+ # Who granted an assignment: the actor of its assignment.granted event
95
+ # when the audit log has one (an HTTP Basic login or an API client stamps
96
+ # no created_by), else created_by. `granters` is an Admin::LastChanges
97
+ # over the assignments of the page (events: %w[assignment.granted]).
98
+ def pf_granted_by(assignment, granters = nil)
99
+ event = granters && granters[assignment]
100
+ event ? pf_audit_actor_name(event) : pf_person_name(assignment.try(:created_by))
101
+ end
102
+
103
+ def pf_granters(assignments)
104
+ PermissionForge::Admin::LastChanges.new(assignments, events: %w[assignment.granted])
105
+ end
106
+
107
+ def pf_stale?(assignment)
108
+ assignment.expires_at.nil? &&
109
+ assignment.last_reviewed_at < PermissionForge.configuration.admin_stale_assignment_days.to_i.days.ago
110
+ end
111
+
112
+ # Calendar date (not time) for each expiry preset, for labels.
113
+ def pf_expiry_preset_date(key)
114
+ date = PermissionForge::Admin::ExpiryPresets.date_for(key)
115
+ date ? pf_date(date) : nil
116
+ end
117
+ end
118
+ end