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,175 @@
1
+ module PermissionForge
2
+ # Plain-language sentences for activation conditions and record rules
3
+ # (audit events: AuditSentencesHelper; "Review and save" change
4
+ # descriptions: ChangesHelper).
5
+ #
6
+ # @api private
7
+ module SentencesHelper
8
+ NULLABLE_OPERATORS = %w[equal not_equal in not_in].freeze
9
+ LIST_OPERATORS = %w[in not_in].freeze
10
+ NO_VALUE_OPERATORS = %w[is_null is_not_null].freeze
11
+ USER_OPERATORS = %w[equal not_equal in not_in is_null is_not_null].freeze
12
+ CURRENT_USER = 'current_user'.freeze
13
+
14
+ # --- activation conditions ------------------------------------------------
15
+
16
+ def pf_condition_parts(permission_set)
17
+ [pf_condition_ips(permission_set), pf_condition_hours(permission_set), pf_condition_flag(permission_set)].compact
18
+ end
19
+
20
+ def pf_condition_ips(permission_set)
21
+ ips = pf_ip_list(permission_set.allowed_ips)
22
+ return if ips.empty?
23
+
24
+ connector = pf_ui('conditions.or')
25
+ pf_ui('conditions.from_ips', ips: ips.to_sentence(two_words_connector: connector, last_word_connector: connector))
26
+ end
27
+
28
+ def pf_condition_hours(permission_set)
29
+ from = permission_set.allowed_hours_start
30
+ to = permission_set.allowed_hours_end
31
+ return unless from.present? && to.present?
32
+
33
+ key = to.to_i <= from.to_i ? 'hours_overnight' : 'hours'
34
+ pf_ui("conditions.#{key}", from: pf_hour(from), to: pf_hour(to))
35
+ end
36
+
37
+ def pf_condition_flag(permission_set)
38
+ flag = permission_set.requires_session_flag
39
+ pf_flag_phrase(flag) if flag.present?
40
+ end
41
+
42
+ def pf_condition_sentence(permission_set)
43
+ parts = pf_condition_parts(permission_set)
44
+ return pf_ui('conditions.always') if parts.empty?
45
+
46
+ pf_ui('conditions.only_works', parts: parts.join(', '))
47
+ end
48
+
49
+ def pf_ip_list(value)
50
+ value.to_s.split(/[,\n]/).map(&:strip).reject(&:empty?)
51
+ end
52
+
53
+ def pf_flag_label(flag)
54
+ configured = PermissionForge.configuration.admin_session_flags || {}
55
+ configured[flag.to_s] || configured[flag.to_sym] ||
56
+ t("permission_forge.ui.flags.#{flag}", default: flag.to_s.humanize)
57
+ end
58
+
59
+ def pf_flag_phrase(flag)
60
+ t("permission_forge.ui.conditions.flags.#{flag}",
61
+ default: pf_ui('conditions.flag_generic', flag: pf_flag_label(flag)))
62
+ end
63
+
64
+ # --- record rules ---------------------------------------------------------
65
+
66
+ def pf_operator_label(operator)
67
+ t("permission_forge.ui.operators.#{operator}", default: operator.to_s.humanize.downcase)
68
+ end
69
+
70
+ # Labels along a rule's relationship path and the final field, e.g.
71
+ # ["Organization", "Members", "User"].
72
+ def pf_rule_field_labels(model_name, rule)
73
+ klass = model_name.to_s.safe_constantize
74
+ labels = rule.field_accessor_path.to_s.split('.').map do |assoc|
75
+ label = pf_attribute_label(klass, assoc)
76
+ klass = pf_association_class(klass, assoc)
77
+ label
78
+ end
79
+ labels << pf_attribute_label(klass, rule.field_name)
80
+ end
81
+
82
+ def pf_attribute_label(klass, attribute)
83
+ klass ? klass.human_attribute_name(attribute) : attribute.to_s.humanize
84
+ end
85
+
86
+ # Target class of an association, or nil (unknown or polymorphic).
87
+ def pf_association_class(klass, assoc)
88
+ reflection = klass&.reflect_on_association(assoc.to_sym)
89
+ begin
90
+ reflection&.klass
91
+ rescue StandardError
92
+ nil
93
+ end
94
+ end
95
+
96
+ def pf_rule_value_text(rule)
97
+ value = rule.field_value.to_s
98
+ return '…' if value.strip.empty? # builder preview before a value is entered
99
+ return pf_ui('rules.current_user') if value == CURRENT_USER
100
+
101
+ pf_rule_value_parts(rule.operator, value)
102
+ end
103
+
104
+ # List values as "a, b"; ranges as "a and b"; anything else verbatim.
105
+ def pf_rule_value_parts(operator, value)
106
+ return value.split(',').map(&:strip).join(', ') if LIST_OPERATORS.include?(operator)
107
+ return value.split(',', 2).map(&:strip).join(" #{pf_ui('rules.and')} ") if operator == 'between'
108
+
109
+ value
110
+ end
111
+
112
+ def pf_rule_nullable?(rule)
113
+ rule.include_nulls && NULLABLE_OPERATORS.include?(rule.operator)
114
+ end
115
+
116
+ # Plural model label. model_name.human(count: 2) falls back to the singular
117
+ # when the host app has no model translations, so pluralize English then.
118
+ def pf_resource_plural(name)
119
+ klass = name.to_s.safe_constantize
120
+ return name.to_s.demodulize.pluralize unless klass.respond_to?(:model_name)
121
+
122
+ plural = klass.model_name.human(count: 2)
123
+ plural == klass.model_name.human && I18n.locale.to_s == 'en' ? plural.pluralize : plural
124
+ end
125
+
126
+ def pf_resource_label(resource_or_name)
127
+ name = resource_or_name.respond_to?(:name) ? resource_or_name.name : resource_or_name.to_s
128
+ return resource_or_name.label if resource_or_name.respond_to?(:label) && resource_or_name.label.present?
129
+
130
+ klass = name.safe_constantize
131
+ klass.respond_to?(:model_name) ? klass.model_name.human : name.demodulize.titleize
132
+ end
133
+
134
+ # Token sentence: Only show [Customers] where [Owner] [is] [the current user], or Owner is empty
135
+ def pf_rule_sentence(rule, model_name = rule.resource&.name)
136
+ field = pf_rule_field_labels(model_name, rule).join(' › ')
137
+ sentence = safe_join(pf_rule_tokens(rule, model_name, field), ' ')
138
+ sentence = safe_join([sentence, content_tag(:span, ", #{pf_ui('rules.or_empty', field: field)}")]) if pf_rule_nullable?(rule)
139
+ content_tag(:p, sentence, class: 'pf-sentence')
140
+ end
141
+
142
+ def pf_rule_tokens(rule, model_name, field)
143
+ [
144
+ content_tag(:span, pf_ui('rules.only_show')),
145
+ content_tag(:span, pf_resource_plural(model_name), class: 'pf-token'),
146
+ content_tag(:span, pf_ui('rules.where')),
147
+ content_tag(:span, field, class: 'pf-token'),
148
+ content_tag(:span, pf_operator_label(rule.operator), class: 'pf-token'),
149
+ pf_rule_value_token(rule)
150
+ ].compact
151
+ end
152
+
153
+ def pf_rule_value_token(rule)
154
+ return if NO_VALUE_OPERATORS.include?(rule.operator)
155
+
156
+ content_tag(:span, pf_rule_value_text(rule), class: 'pf-token pf-token--value')
157
+ end
158
+
159
+ def pf_rule_text(rule, model_name = rule.resource&.name)
160
+ field = pf_rule_field_labels(model_name, rule).join(' › ')
161
+ text = "#{pf_ui('rules.only_show')} #{pf_resource_plural(model_name)} #{pf_ui('rules.where')} #{field} " \
162
+ "#{pf_operator_label(rule.operator)}"
163
+ text += " #{pf_rule_value_text(rule)}" unless NO_VALUE_OPERATORS.include?(rule.operator)
164
+ text += ", #{pf_ui('rules.or_empty', field: field)}" if pf_rule_nullable?(rule)
165
+ text
166
+ end
167
+
168
+ # Short form used in matrix cells: "Owner is the current user".
169
+ def pf_rule_summary(rule, model_name = rule.resource&.name)
170
+ field = pf_rule_field_labels(model_name, rule).join(' › ')
171
+ value = NO_VALUE_OPERATORS.include?(rule.operator) ? nil : pf_rule_value_text(rule)
172
+ [field, pf_operator_label(rule.operator), value].compact.join(' ')
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,152 @@
1
+ module PermissionForge
2
+ # Renders configuration values on the read-only Settings page by type.
3
+ # Secrets show only "Set"/"Not set"; code hooks only default/custom/not set.
4
+ #
5
+ # @api private
6
+ module SettingsHelper
7
+ SECONDS_UNITS = [[86_400, :days], [3600, :hours], [60, :minutes]].freeze
8
+
9
+ # Current-value renderer per setting type (private methods taking the row);
10
+ # any other type renders as a scalar.
11
+ VALUE_RENDERERS = {
12
+ bool: :pf_setting_bool, enum: :pf_setting_enum, num: :pf_setting_number,
13
+ list: :pf_setting_list, names: :pf_setting_list, map: :pf_setting_map,
14
+ secret: :pf_setting_secret, hook: :pf_setting_hook
15
+ }.freeze
16
+
17
+ # Default-value text per setting type; any other type renders as a scalar.
18
+ DEFAULT_RENDERERS = {
19
+ bool: :pf_setting_default_bool, secret: :pf_setting_default_secret, hook: :pf_setting_default_hook,
20
+ list: :pf_setting_default_list, names: :pf_setting_default_list, map: :pf_setting_default_list,
21
+ num: :pf_setting_default_number
22
+ }.freeze
23
+
24
+ def pf_setting_value(row)
25
+ send(VALUE_RENDERERS.fetch(row.type, :pf_setting_scalar), row)
26
+ end
27
+
28
+ def pf_setting_default(row)
29
+ send(DEFAULT_RENDERERS.fetch(row.type, :pf_setting_default_scalar), row)
30
+ end
31
+
32
+ # Compact "key = value" form for the health summary.
33
+ def pf_setting_inline_value(row)
34
+ case row.type
35
+ when :secret then row.value ? t('permission_forge.settings.values.set') : t('permission_forge.settings.values.not_set')
36
+ when :hook then t("permission_forge.settings.values.hook_#{row.hook_state}")
37
+ when :enum then ":#{row.value}"
38
+ else row.value.inspect
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def pf_setting_bool(row)
45
+ if row.value
46
+ content_tag(:span, "● #{t('permission_forge.settings.values.on')}", class: 'pf-pill pf-pill--on')
47
+ else
48
+ content_tag(:span, "○ #{t('permission_forge.settings.values.off')}", class: 'pf-pill')
49
+ end
50
+ end
51
+
52
+ def pf_setting_enum(row)
53
+ options = Array(row.definition.options).map(&:to_s)
54
+ options << row.value unless options.include?(row.value.to_s)
55
+ pills = options.map do |opt|
56
+ current = opt == row.value.to_s
57
+ content_tag(:span, opt, class: "pf-pill pf-pill--opt#{' pf-pill--current' if current}", 'aria-current': (current ? 'true' : nil))
58
+ end
59
+ content_tag(:span, safe_join(pills, ' '), class: 'pf-row pf-row--tight')
60
+ end
61
+
62
+ def pf_setting_number(row)
63
+ value = row.value
64
+ return content_tag(:span, t('permission_forge.settings.values.nil'), class: 'pf-text-3') if value.nil?
65
+
66
+ content_tag(:span, pf_setting_number_text(value, row.definition.unit), class: 'pf-tabular pf-fw-600')
67
+ end
68
+
69
+ def pf_setting_number_text(value, unit)
70
+ return t('permission_forge.settings.values.nil') if value.nil?
71
+ return "#{value} #{t("permission_forge.settings.units.#{unit}", count: value)}" unless unit == :seconds
72
+
73
+ human = SECONDS_UNITS.find { |secs, _| (value % secs).zero? && value >= secs }
74
+ text = "#{value} s"
75
+ human ? "#{text} (#{t("permission_forge.settings.units.#{human.last}", count: value / human.first)})" : text
76
+ end
77
+
78
+ def pf_setting_list(row)
79
+ value = row.value
80
+ return content_tag(:span, pf_setting_nil_text(row), class: 'pf-text-3') if value.nil?
81
+ return content_tag(:span, t('permission_forge.settings.values.empty'), class: 'pf-text-3') if value.empty?
82
+
83
+ content_tag(:span, safe_join(value.map { |v| content_tag(:code, v, class: 'pf-pill pf-pill--opt') }, ' '),
84
+ class: 'pf-row pf-row--tight')
85
+ end
86
+
87
+ def pf_setting_map(row)
88
+ value = row.value
89
+ return content_tag(:span, t('permission_forge.settings.values.empty'), class: 'pf-text-3') if value.blank?
90
+
91
+ content_tag(:span, safe_join(value.map { |k, v| content_tag(:span, "#{k} → #{v}") }), class: 'pf-map')
92
+ end
93
+
94
+ def pf_setting_secret(row)
95
+ if row.value
96
+ content_tag(:span, "● #{t('permission_forge.settings.values.set')}", class: 'pf-pill pf-pill--on')
97
+ else
98
+ content_tag(:span, "○ #{t('permission_forge.settings.values.not_set')} · #{t('permission_forge.settings.values.never_displayed')}",
99
+ class: 'pf-pill')
100
+ end
101
+ end
102
+
103
+ def pf_setting_hook(row)
104
+ state = row.hook_state
105
+ css = state == :custom ? 'pf-pill pf-pill--on' : 'pf-pill'
106
+ content_tag(:span, t("permission_forge.settings.values.hook_#{state}"), class: css)
107
+ end
108
+
109
+ def pf_setting_scalar(row)
110
+ value = row.value
111
+ return content_tag(:span, pf_setting_nil_text(row), class: 'pf-text-3') if value.nil?
112
+
113
+ content_tag(:code, value.to_s, class: 'pf-mono')
114
+ end
115
+
116
+ def pf_setting_default_bool(row)
117
+ row.default ? t('permission_forge.settings.values.on') : t('permission_forge.settings.values.off')
118
+ end
119
+
120
+ def pf_setting_default_secret(_row)
121
+ t('permission_forge.settings.values.not_set')
122
+ end
123
+
124
+ def pf_setting_default_hook(row)
125
+ t("permission_forge.settings.values.hook_#{row.default}")
126
+ end
127
+
128
+ def pf_setting_default_list(row)
129
+ values = Array(row.default)
130
+ return t('permission_forge.settings.values.empty') if values.empty?
131
+
132
+ values.map { |v| Array(v).join(' → ') }.join(', ')
133
+ end
134
+
135
+ def pf_setting_default_number(row)
136
+ pf_setting_number_text(row.default, row.definition.unit)
137
+ end
138
+
139
+ def pf_setting_default_scalar(row)
140
+ value = row.default
141
+ value.nil? ? pf_setting_nil_text(row) : value.to_s
142
+ end
143
+
144
+ def pf_setting_nil_text(row)
145
+ case row.definition.nil_label
146
+ when :disabled then t('permission_forge.settings.values.disabled')
147
+ when :default then t('permission_forge.settings.values.hook_default')
148
+ else t('permission_forge.settings.values.nil')
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,184 @@
1
+ module PermissionForge
2
+ # Building blocks shared by every admin screen: assets, text, dates, state
3
+ # cells, banners, footers, tabs, frames. Badges/chips live in BadgesHelper,
4
+ # people/assignment cells in PeopleHelper.
5
+ # Every class is pf- prefixed; no inline styles (strict-CSP friendly).
6
+ #
7
+ # @api private
8
+ module UiHelper
9
+ STATE_GLYPHS = { granted: '✓', blocked: '⊘', none: '–' }.freeze
10
+
11
+ # --- assets ---------------------------------------------------------------
12
+
13
+ def pf_asset_url(file)
14
+ permission_forge.pf_asset_path(file: file, v: PermissionForge::AssetsController.digest(file))
15
+ end
16
+
17
+ # ` nonce="…"` when the host app runs a CSP with nonces, else nothing.
18
+ def pf_nonce_attribute(_kind = :script)
19
+ nonce = respond_to?(:content_security_policy_nonce) ? content_security_policy_nonce : nil
20
+ nonce.present? ? tag.attributes(nonce: nonce) : ''
21
+ rescue StandardError
22
+ ''
23
+ end
24
+
25
+ def pf_lock_icon
26
+ tag.svg(class: 'pf-lock', viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.6',
27
+ 'aria-hidden': 'true', focusable: 'false') do
28
+ safe_join([tag.rect(x: 3, y: 7, width: 10, height: 7, rx: 1.5), tag.path(d: 'M5.5 7V5a2.5 2.5 0 0 1 5 0v2')])
29
+ end
30
+ end
31
+
32
+ # The attributes, only when the condition holds: `<input <%= pf_attrs_when(error, aria: { invalid: true }) %>>`.
33
+ # Built by `tag.attributes`, so every value is escaped; nothing is marked safe by hand.
34
+ def pf_attrs_when(condition, **attributes)
35
+ condition ? tag.attributes(**attributes) : ''
36
+ end
37
+
38
+ # --- text helpers ---------------------------------------------------------
39
+
40
+ def pf_ui(key, **)
41
+ t("permission_forge.ui.#{key}", **)
42
+ end
43
+
44
+ def pf_people_text(count)
45
+ pf_ui('people_count', count: count)
46
+ end
47
+
48
+ MONTH_KEYS = %w[jan feb mar apr may jun jul aug sep oct nov dec].freeze
49
+
50
+ # "12 Mar 2026" / "12 Mar" using the engine's own month names (no
51
+ # dependency on rails-i18n being installed in the host app).
52
+ def pf_date(value, year: true)
53
+ return pf_ui('dates.never') if value.nil?
54
+
55
+ date = value.to_date
56
+ month = t("permission_forge.ui.months.#{MONTH_KEYS[date.month - 1]}")
57
+ year ? "#{date.day} #{month} #{date.year}" : "#{date.day} #{month}"
58
+ end
59
+
60
+ def pf_datetime(value)
61
+ return pf_ui('dates.never') if value.nil?
62
+
63
+ "#{pf_date(value)} · #{value.in_time_zone.strftime('%H:%M')}"
64
+ end
65
+
66
+ # "today", "yesterday", "3 days ago", or a date for anything older than 30 days.
67
+ def pf_relative(value)
68
+ return pf_ui('dates.never') if value.nil?
69
+
70
+ days = (Time.zone.today - value.to_date).to_i
71
+ return pf_ui('dates.today') if days <= 0
72
+ return pf_ui('dates.yesterday') if days == 1
73
+ return pf_ui('dates.days_ago', count: days) if days < 30
74
+
75
+ pf_date(value)
76
+ end
77
+
78
+ def pf_hour(hour)
79
+ format('%02d:00', hour.to_i)
80
+ end
81
+
82
+ # Read-only state cell (effective access grid, across-sets lists).
83
+ def pf_state_cell(state, aria:, href: nil, why: nil, current: false)
84
+ state = state.to_sym
85
+ label = pf_ui("states.#{state}")
86
+ content = safe_join([content_tag(:span, STATE_GLYPHS.fetch(state), class: 'pf-cell__glyph', 'aria-hidden': true),
87
+ content_tag(:span, label)], ' ')
88
+ opts = { class: "pf-state pf-state--#{state}", 'aria-label': "#{aria}: #{label}" }
89
+ opts[:'aria-current'] = 'true' if current
90
+ opts[:data] = { pf_why: why } if why
91
+ href ? link_to(content, href, opts) : content_tag(:span, content, opts)
92
+ end
93
+
94
+ # --- banners & footers ----------------------------------------------------
95
+
96
+ def pf_help_banner(key, term:, text:)
97
+ return unless pf_help_visible?(key)
98
+
99
+ render 'permission_forge/shared/help_banner', key: key, term: term, text: text
100
+ end
101
+
102
+ def pf_audit_footer(record, prefix: nil)
103
+ render 'permission_forge/shared/audit_footer', record: record, prefix: prefix
104
+ end
105
+
106
+ def pf_tech_details(record, extras: {}, &block)
107
+ render 'permission_forge/shared/tech_details', record: record, extras: extras,
108
+ extra_html: (capture(&block) if block)
109
+ end
110
+
111
+ def pf_setting_path(key)
112
+ permission_forge.settings_path(focus: key, anchor: "set-#{key}")
113
+ end
114
+
115
+ def pf_setting_link(key, text = nil)
116
+ link_to(text || pf_ui('open_setting'), pf_setting_path(key), class: 'pf-fs-12')
117
+ end
118
+
119
+ # Tab link with ARIA state, plus count.
120
+ def pf_tab(label, href, selected:, count: nil)
121
+ text = count.nil? ? label : "#{label} (#{count})"
122
+ link_to(text, href, class: 'pf-tab', role: 'tab', 'aria-selected': selected.to_s)
123
+ end
124
+
125
+ def pf_filter_chip(label, href, selected:, count: nil)
126
+ body = [content_tag(:span, label)]
127
+ body << content_tag(:span, count, class: 'pf-filter__count') unless count.nil?
128
+ link_to(safe_join(body), href, class: 'pf-filter', 'aria-current': (selected ? 'true' : nil))
129
+ end
130
+
131
+ # A section heading inside a drawer or confirmation page. The page's own title is the h1 when
132
+ # the page stands alone and an h2 inside the dialog (the page behind has the h1), so a section
133
+ # is one level below that.
134
+ def pf_section_heading(text, id: nil)
135
+ content_tag(pf_fragment? ? :h3 : :h2, text, class: 'pf-h3', id: id)
136
+ end
137
+
138
+ # Breadcrumb from [[label, path], ..., label]
139
+ def pf_breadcrumb(*items)
140
+ parts = items.map do |item|
141
+ item.is_a?(Array) ? link_to(item.first, item.last) : content_tag(:span, item, 'aria-current': 'page')
142
+ end
143
+ content_tag(:nav, safe_join(parts, content_tag(:span, '/', 'aria-hidden': true)),
144
+ class: 'pf-breadcrumb', 'aria-label': pf_ui('breadcrumb'))
145
+ end
146
+
147
+ # Re-emits nested params as hidden inputs (the review step re-posts the
148
+ # exact edits that were reviewed).
149
+ def pf_hidden_params(prefix, value)
150
+ value = value.to_unsafe_h if value.respond_to?(:to_unsafe_h)
151
+ case value
152
+ when Hash
153
+ safe_join(value.map { |k, v| pf_hidden_params("#{prefix}[#{k}]", v) })
154
+ when Array
155
+ safe_join(value.map { |v| pf_hidden_params("#{prefix}[]", v) })
156
+ when nil then safe_join([])
157
+ else
158
+ tag.input(type: 'hidden', name: prefix, value: value.to_s)
159
+ end
160
+ end
161
+
162
+ # Drawer / dialog frames. The body is captured here (not via `render
163
+ # layout:`) so lazy t('.key') lookups inside it resolve against the
164
+ # calling view, not the frame partial.
165
+ def pf_sheet_frame(locals = {}, &)
166
+ render 'permission_forge/shared/sheet_frame', **locals, body: capture(&)
167
+ end
168
+
169
+ def pf_dialog_frame(locals = {}, &)
170
+ render 'permission_forge/shared/dialog_frame', **locals, body: capture(&)
171
+ end
172
+
173
+ # Modal/drawer link: real page without JS, loaded into <dialog> with JS.
174
+ def pf_modal_link(text = nil, href = nil, kind: 'dialog', **opts, &block)
175
+ if block
176
+ opts = (href.is_a?(Hash) ? href : {}).merge(opts)
177
+ href = text
178
+ text = capture(&block)
179
+ end
180
+ opts[:data] = (opts[:data] || {}).merge(pf_modal: kind)
181
+ link_to(text, href, opts)
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,159 @@
1
+ module PermissionForge
2
+ # One audit event: who did what, to what, when, and what it was before.
3
+ # Written by PermissionForge::Audit only; insert-only once persisted
4
+ # (update, destroy and delete raise). The purge service deletes through a
5
+ # relation (`delete_all`), past the retention period, and records that.
6
+ #
7
+ # `data` is JSON text in the database and a Hash with string keys here:
8
+ # { "changes" => { attr => [from, to] } } on an update, { "snapshot" => {...} }
9
+ # on a create or destroy, plus event-specific keys.
10
+ #
11
+ # @api public
12
+ class AuditEvent < ActiveRecord::Base
13
+ # The table name, with the configured prefix (read on every call, so a prefix set at runtime applies).
14
+ def self.table_name = "#{PermissionForge.table_name_prefix}audit_events"
15
+
16
+ include PermissionForge::Concerns::HasTenantScoping
17
+
18
+ # A system event of a cross-tenant task (a rake clean-up, a report) has
19
+ # no tenant; it is visible while none is set, like any untenanted row.
20
+ def self.tenant_scoping_options
21
+ { optional: true }
22
+ end
23
+
24
+ CATEGORIES = PermissionForge::Audit::EventCatalog::CATEGORIES
25
+ ACTOR_TYPES = PermissionForge::Audit::Actor::TYPES
26
+ SOURCES = PermissionForge::Audit::Source::SOURCES
27
+
28
+ # JSON text that works on SQLite, PostgreSQL and MySQL alike; times are
29
+ # written in ISO 8601 (ActiveSupport::JSON), nothing reads as nil.
30
+ #
31
+ # @api private
32
+ class DataCoder
33
+ def self.dump(value)
34
+ ActiveSupport::JSON.encode(value.nil? ? {} : value)
35
+ end
36
+
37
+ def self.load(value)
38
+ value.blank? ? {} : ActiveSupport::JSON.decode(value)
39
+ end
40
+ end
41
+
42
+ serialize :data, coder: DataCoder
43
+
44
+ # The tenant of the record the event is about. acts_as_tenant writes the
45
+ # ambient tenant to every new row (before_validation); an event follows
46
+ # its subject instead (before_create runs after validation).
47
+ attr_accessor :subject_tenant_id
48
+
49
+ before_validation :assign_uuid, on: :create
50
+ before_create :follow_subject_tenant
51
+
52
+ validates :uuid, :event, :category, :actor_type, :source, :occurred_at, presence: true
53
+ validates :category, inclusion: { in: CATEGORIES }
54
+ validates :actor_type, inclusion: { in: ACTOR_TYPES }
55
+ validates :source, inclusion: { in: SOURCES }
56
+
57
+ scope :newest_first, -> { order(occurred_at: :desc, id: :desc) }
58
+ scope :for_record, ->(record) { where(record_type: record.class.base_class.name, record_id: record.id.to_s) }
59
+ scope :for_subject, ->(user_id) { where(subject_user_id: user_id.to_s) }
60
+ scope :between, ->(from, to) { where(occurred_at: from..to) }
61
+
62
+ apply_tenant_scoping! # from the configuration, as the class loads (HasTenantScoping)
63
+
64
+ # The tenant column of the events: nil when tenancy is off or the table
65
+ # has no such column (installed before tenancy), where events cannot be
66
+ # told apart by tenant.
67
+ #
68
+ # @return [Symbol, nil]
69
+ def self.tenant_column
70
+ column = PermissionForge.configuration.resolved_tenant_column
71
+ column if column && column_names.include?(column.to_s)
72
+ end
73
+
74
+ # The events of one tenant, on the relation it is called on; every event
75
+ # when there is no tenant id or no tenant column. Used by the services
76
+ # that work past the default scope (`unscoped`): erasure, the purge.
77
+ #
78
+ # @param tenant_id [Object, nil]
79
+ # @return [ActiveRecord::Relation]
80
+ def self.in_tenant(tenant_id)
81
+ column = tenant_column
82
+ tenant_id && column ? where(column => tenant_id) : all
83
+ end
84
+
85
+ # The one write (Audit::Recorder). Built under `unscoped`: instantiating
86
+ # a model that has a default scope evaluates it (Rails fills in scope
87
+ # attributes), and acts_as_tenant's raises under require_tenant when no
88
+ # tenant is ambient — a job, a rake task. The tenant comes from the
89
+ # subject (subject_tenant_id), else from the ambient tenant.
90
+ #
91
+ # @param attributes [Hash] columns plus subject_tenant_id:
92
+ # @return [AuditEvent] persisted
93
+ # @raise [ActiveRecord::RecordInvalid, ActiveRecord::StatementInvalid]
94
+ def self.store!(attributes)
95
+ event = unscoped { new(attributes) }
96
+ event.save!
97
+ event
98
+ end
99
+
100
+ # Insert-only. On a persisted event these raise ActiveRecord::ReadOnlyRecord:
101
+ # save, save!, update, update!, update_attribute, update_column(s), touch,
102
+ # toggle!, increment!, decrement! (through update_counters), destroy,
103
+ # destroy!, delete, becomes + save, Class.update(id), Class.destroy(id),
104
+ # Class.update_counters and relation.update_counters, Class.upsert and Class.upsert_all,
105
+ # relation.destroy_all. Still reachable, on purpose: relation.update_all
106
+ # (the erasure service clears label snapshots of an erased person — the
107
+ # one sanctioned mutation, recorded itself), relation.delete_all and
108
+ # Class.delete(id) (the purge service, past the retention period). Protect
109
+ # the table at the database level too (REVOKE UPDATE, DELETE).
110
+ def readonly?
111
+ persisted?
112
+ end
113
+
114
+ # `delete` skips the read-only check Rails makes elsewhere.
115
+ def delete
116
+ raise ActiveRecord::ReadOnlyRecord, "#{self.class} is marked as readonly" if persisted?
117
+
118
+ super
119
+ end
120
+
121
+ # upsert / upsert_all would rewrite an existing row through its uuid, and
122
+ # update_counters is how increment! / decrement! write, past the
123
+ # instance (through the class up to Rails 8.1, through a relation —
124
+ # `unscoped.where(id:).update_counters` — on Rails main). Class methods
125
+ # on Rails 7.1; Relation methods the class delegates to from 7.2 on —
126
+ # refused in both places.
127
+ #
128
+ # @api private
129
+ module InsertOnly
130
+ def update_counters(*, **)
131
+ raise ActiveRecord::ReadOnlyRecord, 'PermissionForge::AuditEvent is insert-only: counters cannot be updated'
132
+ end
133
+
134
+ def upsert_all(*, **)
135
+ raise ActiveRecord::ReadOnlyRecord, 'PermissionForge::AuditEvent is insert-only: rows cannot be upserted'
136
+ end
137
+
138
+ def upsert(*, **)
139
+ raise ActiveRecord::ReadOnlyRecord, 'PermissionForge::AuditEvent is insert-only: rows cannot be upserted'
140
+ end
141
+ end
142
+ singleton_class.prepend(InsertOnly)
143
+ relation_delegate_class(ActiveRecord::Relation).prepend(InsertOnly)
144
+
145
+ private
146
+
147
+ # No uniqueness validation (a query per insert): the unique index holds.
148
+ def assign_uuid
149
+ self.uuid = PermissionForge.configuration.uuid_generator.call if uuid.blank?
150
+ end
151
+
152
+ def follow_subject_tenant
153
+ column = PermissionForge.configuration.resolved_tenant_column
154
+ return if subject_tenant_id.nil? || column.nil? || !has_attribute?(column)
155
+
156
+ write_attribute(column, subject_tenant_id)
157
+ end
158
+ end
159
+ end