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
data/CHANGELOG.md ADDED
@@ -0,0 +1,1138 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). PermissionForge is pre-1.0: a minor version may change
5
+ defaults, and every such change is listed under **Changed** with its opt-out.
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.14.1] - 2026-10-14
10
+
11
+ ### Security
12
+
13
+ - **The admin screens and the API keep checking the CSRF token on Rails 8.2.** Rails 8.2 can verify a request by the
14
+ browser's `Sec-Fetch-Site` header, and `config.load_defaults 8.2` selects `:header_only`, under which a plain-HTTP
15
+ request without that header is not checked at all. The engine's controllers now always use
16
+ `:header_or_legacy_token`: the header decides when the browser sends it (a cross-site request is refused), and
17
+ without it the token is required, as before. One change on Rails 8.2 the engine cannot avoid (Rails accepts it under
18
+ both strategies): a request the browser marks `Sec-Fetch-Site: same-site`, which includes a page on another subdomain
19
+ of your site, passes without a token. Rails 7.1 to 8.1 are unaffected.
20
+
21
+ ### Fixed
22
+
23
+ - **On Rails 8.2 (main), a Redis that cannot be reached no longer counts as a done invalidation.** Rails 8.2's
24
+ `RedisCacheStore#write_multi` answers the same value whether Redis took the entries or not, so an edit to a Permission
25
+ Set whose holders are invalidated in bulk (two `write_multi` calls per 500 holders) was taken as confirmed while Redis was
26
+ down: nothing was retried, owed or reported through `on_cache_failure`, and the holders kept their cached permissions
27
+ until `cache_ttl`. A batch now counts as written only when the store also handed no error to its `error_handler`
28
+ meanwhile; Rails 7.1 to 8.1 already answered `nil` for it. PermissionForge puts a wrapper in front of the
29
+ `error_handler` of a store it writes a batch to: your handler is still called for every error, with the same
30
+ arguments. The number of Redis round trips does not change.
31
+
32
+ ## [0.14.0] - 2026-10-14
33
+
34
+ This release closes the findings of a pre-release security review. Several defaults change: each is listed under
35
+ **Changed** with its opt-out, and [Upgrading from 0.13](#upgrading-from-013) lists what to check. No migration is needed.
36
+
37
+ ### Security
38
+
39
+ - **Conditional permission sets are evaluated for the request that reads the permissions.** A set gated on a
40
+ session flag (MFA), an IP allowlist or login hours was evaluated once, when the user's hash was built, and that
41
+ hash was cached per user: for up to `conditional_set_cache_ttl` every other session, IP or job of the same user
42
+ got the grants (or the lockdown) of whichever request built it. The cache now keeps one base entry per user (the
43
+ unconditional sets, plus the user's conditional sets and their conditions) and one variant per combination of
44
+ conditional sets a request context selects, in a slot of the base key (`<key>_c_<1..63>`); every read evaluates the
45
+ current context. A variant is verified against the user's invalidation stamp when it is stored and, on every read,
46
+ against the base entry it was built for (`variant_of`: a digest of the base entry's token and the set ids), so an
47
+ invalidation removes only the base entry: the variants it leaves can no longer be read and expire with
48
+ `conditional_set_cache_ttl`. `DataSubjectErasure` removes every slot of the person's keys (the variants hold the
49
+ profile), and `rake permission_forge:cache:clear` removes them too, found through a small index entry
50
+ (`<key>_c_index`) written before a variant is stored.
51
+ `permissions_hash` keeps its format, and a user without conditional sets costs what it did (one store read, no
52
+ resolver call).
53
+ - **Strict mode keeps conditional Muting Permission Sets.** With `conditional_sets_strict_mode` and no request
54
+ context (the resolver returns nothing, or neither an IP nor session flags: a job, the console), conditional
55
+ *muting* sets now still apply and only conditional *granting* sets are dropped. Before, both were dropped, so a
56
+ missing context removed a lockdown. With a request context, a conditional muting set whose condition cannot be
57
+ judged also applies in strict mode: an IP allowlist when the request has no IP (a proxy that strips it), a session
58
+ flag when the context has no `:session_flags` at all. Granting sets are unchanged.
59
+ - **Masked in one set and Hidden in another no longer unmasks.** A field Masked in one Permission Set and Hidden
60
+ (read off) in another was merged as readable and unmasked, in either order: getters, `as_json` and
61
+ `FiltersResponseAttributes` returned the plaintext. Only rows that grant read now decide the mask and its strategy;
62
+ a Hidden row never votes. The admin's Effective Access uses the same merge (`FieldPermissionMerger.merge_row`).
63
+ - **STI subclasses obey their base class's Resource.** A subclass of a protected model (`VipCustomer < Customer`)
64
+ looked up a Resource by its own name only, so `find_by`, `permission_scoped`, auto-scoping and `has_many`
65
+ traversal returned rows the `Customer` rules deny, and its fields were unconfigured. `PermissionForge::ResourceChain`
66
+ now resolves a class to its own Resource entry and every ancestor's up to `base_class`: the rules of all of them
67
+ apply; a `view_all`/`modify_all` bypass skips only the rules of the entry that grants it; a field is as closed as the
68
+ strictest entry that configures it, and an entry that does not grant read hides every field the chain configures.
69
+ Only single-table inheritance forms a chain. Rules on a Resource named after a subclass apply to queries through
70
+ that subclass, not through the base class, and are not part of the RLS policy (reported in `skipped_rules`).
71
+ - **Muting a Resource's read hides its fields.** A Muting Permission Set that revoked read on a resource left every
72
+ field readable through getters, `as_json` and the response filter. Fields of a resource whose read is muted are now
73
+ forced to `read_access: false, edit_access: false`, including variants and implicit virtual fields; this also
74
+ blocks them on create through `PermitsAttributes`. Muting a resource's edit still mutes only the flag. A muting
75
+ field row that mutes read now mutes edit too (the API could write a row with `read_access` alone, which hid the
76
+ field but left it writable). A muting resource row naming an STI base reaches the entries of its subclasses the
77
+ person holds (CRUD flags, View All / Modify All, actions and field rows), so `can_*?` and the controller guard on
78
+ `VipCustomer` follow a muted `Customer`; a row naming a subclass reaches that subclass alone.
79
+ - **`PermitsAttributes` fails closed on shaped values and nested payloads.** A non-editable key whose value was a
80
+ Hash or an Array of Hashes (a json column, or `email: { name: 'x' }`) was kept when an inner key matched an editable
81
+ parent field, and a nested payload for a child model the user was never granted was filtered against the parent's
82
+ fields. Now a key the user cannot edit is dropped whatever its value; only `<assoc>_attributes` for an
83
+ `accepts_nested_attributes_for` association recurses, record by record, against the child model's own Resource; a
84
+ child model absent from the user's permissions drops the payload (see `nested_attributes_parent_fallback` under
85
+ Changed); non-record elements are dropped. A nested record keeps its `id` only when the id names a row the child
86
+ model's row-level rules let the user reach (one query per payload); otherwise the whole record is dropped. A child
87
+ model without `PermissionScopedFindable` is left to Rails.
88
+ - **The JSON API's CSRF check runs before `api_auth`.** `protect_from_forgery with: :null_session` ran after the
89
+ authentication callbacks, so a cross-site form post carrying an admin's cookies was authenticated against the real
90
+ session and its change went through. The check is now prepended: a forged request reaches `api_auth` with an empty
91
+ session and cookie jar. Token or header authentication is unaffected. If your `api_base_controller_class` declares
92
+ `protect_from_forgery with: :exception`, the API controllers use `:null_session` instead, and same-origin
93
+ JavaScript that relies on the session must send `X-CSRF-Token`. `:null_session` does not empty the raw `Cookie`
94
+ header, identities computed by middleware, or browser-cached HTTP Basic credentials (see `docs/admin-api.md`).
95
+ - **The admin and the API reach people by id only within `user_list_resolver`.** A person the resolver leaves out
96
+ (for example another tenant's user) is now not found (404) on the person page, erasure, assignment, restore, group
97
+ membership, bulk assignment and the API's assignment endpoints, and on every path that takes an assignment or
98
+ membership id (end date, Keep, Remove, Undo, removing a group member, the API's `DELETE …/users/:id`). Lists of a
99
+ set's holders still show every holder.
100
+ - **The `is_editable` lock covers what a record holds.** The API checked only the record's own flag, so children of a
101
+ locked Permission Set or Resource could be created, changed or deleted, and holders added. A locked Permission Set
102
+ now gains no resources, fields, rules, actions or holders; a locked Resource keeps its fields, rules and actions; a
103
+ locked Permission Set Group keeps its name, members and list of sets. Refusals are 403 / an alert, recorded as
104
+ `admin.lock_refused`. Narrowing access is always allowed and widening it is refused, in the direction the set
105
+ works (`Admin::EditableLock.widens_access?`): on a granting set holders can be removed and end dates set or moved
106
+ earlier; on a Muting Permission Set holders can be added and end dates cleared or moved later, and "Remove all
107
+ access" keeps a person's locked muting sets and says so. The group page cannot move a set out of a locked group,
108
+ and saving a set leaves the fields of a locked Resource as they are.
109
+ - **Auto-applied masking reaches every process.** `MaskedAttributes` was mixed into a model only in the process that
110
+ committed its first `Resource` row (and at boot); other web and job processes served its columns unmasked until
111
+ restarted, and a development code reload dropped it. The committing process now writes a token to the cache store,
112
+ and every process checks it at the start of each request or job (one store read; the Resource names are re-read only
113
+ when it changed). Renaming a Resource to a new model is covered. This needs a shared cache store: under `:memory`
114
+ or a null store other processes pick a new model up at their next boot. The token is written with no expiry (also
115
+ under a store-wide `expires_in`), and a process that finds none writes a new one, so an evicted token costs one
116
+ query per process, never a model left unmasked. Two rows for a new model committed together announce it. When the
117
+ store does not answer, a process stops reading it for 30 seconds (then one request per process probes it) and logs
118
+ once per outage; a token the store did
119
+ not take is written once it answers again, and a store that raises never makes the `Resource` save raise. A model
120
+ whose table cannot be read when masking is applied is no longer left unmasked silently: it is logged with a
121
+ SECURITY warning and retried at each request or job.
122
+ - **RLS `view_all` bypass respects `Resource.is_active` and the row's tenant** (PostgreSQL, opt-in). Deactivating a
123
+ view_all row stopped the application's bypass but not the policy's, and a view_all grant in one tenant bypassed the
124
+ rules on every tenant's rows. The bypass now requires an active resource row and, with tenancy on, one in the row's
125
+ tenant (a row with no tenant gets no bypass), and a rule set applies only while its resource row is active, read live
126
+ by the policy. `RlsPolicyGenerator::GENERATOR_VERSION` is 5: existing installs see a drift warning until they run
127
+ `rake permission_forge:rls:sync`.
128
+ - **`inspect` and `pretty_print` show what the getters return.** A masked record in a log line, an error report or a
129
+ console printed its masked and hidden values in full. They now go through the field guard (Rails 7.1 to 8.1), are not
130
+ audited reads, and never raise: a read, or Rails' formatting of the value, that fails shows the hidden value.
131
+ `*_before_type_cast`, `*_in_database`, `attributes_for_database`, the dirty-tracking helpers, `Marshal`, `pluck` and
132
+ SQL aliases still read raw values (documented).
133
+ - **A declared virtual field is editable only when the resource grants edit.** Under
134
+ `extra_fields_default_access = :grant`, a read-only user could write a declared extra field. The implicit entry
135
+ now follows the resource's `read_access` and `edit_access`.
136
+ - **The data-subject erasure's audit-label scrub stays in the current tenant.** With a tenant set (the admin UI), a
137
+ tenant admin could clear a person's name labels in every tenant's audit log. With no tenant set
138
+ (`rake permission_forge:erasure`) it still covers every tenant.
139
+ - **Destroying a user removes their assignments and memberships in every tenant**, each through its callbacks
140
+ (audited, cache invalidated). Rows in other tenants stayed behind with a `user_id` that a reused id could inherit.
141
+ - **`ExpiredAssignmentCleaner` re-reads each assignment under a row lock** before destroying it; an assignment
142
+ renewed while the clean-up ran was deleted anyway.
143
+ - **A host `User` method can no longer replace one of the gem's private helpers.** A user model with its own
144
+ `read_permissions` or `context_hash` (two of 33 unprefixed private names the `PermissionedUser` concerns added)
145
+ replaced the gem's: cache hits could raise, or an MFA-gated Permission Set applied without the flag. The same held
146
+ for `guard` and `hidden_attribute?`, which `MaskedAttributes` added to every protected model. Every private method
147
+ the gem adds to a host model now starts with `pf_`, and guard tests keep it so (see Changed).
148
+ - **Admin preference cookies (`pf_theme`, `pf_help`) are `Secure` when the admin is served over HTTPS.**
149
+ - **Crafted input answers 4xx instead of 500.** A String where a form section is expected, a Hash where an id or a
150
+ tab is, out-of-range pages, ids and dates, NUL bytes in text or attribute hashes, blank names, and a backslash or
151
+ control character in `return_to` are refused (400/422) or ignored. An unreadable `expires_at` is refused instead of
152
+ creating a permanent assignment.
153
+ - **Nothing a request left set in the gem's per-execution state can reach the next request on the same thread.** At
154
+ the end of every executor run (request, job), whatever the run added to `PermissionForge::ExecutionState` (the
155
+ `without_permission_scope` flag, re-entrancy guards, masking escape hatches, the audit context) is removed, and
156
+ nothing is ever written back. The scoping and masking flags stay per fiber under every `isolation_level`: an
157
+ Enumerator paused inside `without_permission_scope` never leaves the rest of the request unscoped.
158
+ - **The getting-started guide no longer suggests a thread-local user.** Its `current_user_resolver` example read
159
+ `Thread.current[:current_user]`, which nothing clears between requests on a reused thread: a request that did not
160
+ set it (unauthenticated, an error path, a job) resolved the previous request's user and got that user's
161
+ permissions. The guide now uses `ActiveSupport::CurrentAttributes` and says why. If you copied the old example,
162
+ change it.
163
+
164
+ ### Changed
165
+
166
+ - **Cache keys move to the `v2:` namespace** (`pf:v2:user_<id>`, `pf:v2:tenant_<t>_user_<id>`, variants
167
+ `…_c_<1..63>`; `Cache::UserKeys`), and `user.permission_cache_key` returns the new key. An invalidation
168
+ also removes the 0.13 keys (until 0.15), but a 0.13 process cannot reach a 0.14 entry: see step 1 of
169
+ [Upgrading from 0.13](#upgrading-from-013).
170
+ - **`request_context_resolver` runs on every permission read of a user who holds a conditional set.** It must be
171
+ cheap, return `{}` (or anything with `#to_h`) where there is no request (a resolver that raises there raises on
172
+ every such read), and must not read the permissions of the user it resolves for (a masked attribute of the
173
+ current user, a `can_*?`): such a read raises `PermissionForge::ConfigurationError`.
174
+ - `regenerate_permissions` builds the base entry and, when the request context selects conditional sets, the
175
+ variant, and returns the hash for the current context. The raw entry under `user.permission_cache_key` is the base
176
+ (unconditional sets) plus `:conditional_sets` for a user who has any.
177
+ - A user who holds more than 6 conditional sets (`ConditionalSets::MAX_CACHED_SETS`) has the per-context variants
178
+ built per request and kept on the instance, never cached: one rebuild per request for that user.
179
+ - **The strategy options are validated when set**: `nil_user_strategy`, `response_filter_nil_user_strategy`,
180
+ `masked_attributes_nil_user_strategy`, `masked_write_protection`, `action_guard_nil_user_strategy`,
181
+ `admin_tenantless_access`, `unconfigured_action_strategy` and `extra_fields_default_access`. A String is taken as its Symbol (`'block'` is
182
+ `:block`) and any other value raises `ConfigurationError` in the initializer. Before, a String was read as an
183
+ unknown value: `response_filter_nil_user_strategy = 'block'` meant `:bypass`, and `nil_user_strategy = 'raise'`
184
+ meant `:bypass` in `find_by`. The other options' unknown values failed closed; they now raise instead.
185
+ - **`api_require_authorization` defaults to `true`.** Until both `api_action_authorization` and
186
+ `api_assignment_authorization` are set, API changes and assignments are refused (403, `api.auth_failed`). Opt out
187
+ with `config.api_require_authorization = false` (the boot warning and `rake permission_forge:audit` still report it).
188
+ - **With `tenant_model` set, admin and admin API requests that have no current tenant are refused (403)**, recorded as
189
+ `admin.auth_failed` / `api.auth_failed` with reason `tenant_missing`. Set the tenant in `base_controller_class` /
190
+ `api_base_controller_class` (or middleware). Opt out with `config.admin_tenantless_access = :allow`: tenant-less
191
+ requests then read, change and delete existing records of every tenant; creating a record still needs a tenant.
192
+ A boot warning and the audit finding `tenantless_admin` flag tenancy with a default `ActionController::Base` base.
193
+ - **`nested_attributes_parent_fallback` defaults to `false`.** A `<assoc>_attributes` payload for a child model the
194
+ user has no Resource entry for is dropped. `true` filters it against the parent's fields instead (not recommended;
195
+ reported by `rake permission_forge:audit` as medium). The other `PermitsAttributes` changes above apply either way.
196
+ - **The private helpers the gem adds to host models are `pf_`-prefixed** (`PermissionedUser`'s 33, for example
197
+ `pf_read_permissions`, `pf_context_hash`, `pf_invalidation_stamp_key`; `MaskedAttributes`' `pf_guard`,
198
+ `pf_guarded_user`, `pf_hidden_attribute?`, `pf_guard_attributes_hash`). A host override of a former name
199
+ (`invalidation_stamp_key`, `resource_permissions`, `context_hash`) no longer takes effect: override the public
200
+ methods (`permissions_hash`, `regenerate_permissions`, `invalidate_permissions`, `permission_cache_key`) instead.
201
+ `active_non_expired_permission_sets` stays as a private alias until 1.0.
202
+ - **`PermitsAttributes` keeps a nested record's `_destroy` when the user may delete the child model**: the
203
+ `delete_access` of the entry that model answers to (its own, or its nearest STI base's when it has none; the
204
+ parent's under `nested_attributes_parent_fallback`). It used to drop `_destroy` for every user. Without that access
205
+ it is dropped and reported in `params.filtered` as `<assoc>_attributes.*._destroy`; a record whose `id` the child's
206
+ rules deny is still dropped whole.
207
+ - **`NotAuthorizedError#message` (and `to_s`, `full_message`) is always a String**: the rule messages joined by
208
+ `', '`, the `:message` of a Hash, or the class name. Read the structured value from `#details`. An error built with
209
+ keywords only (`new(user:, record:)`) reads as the class name, so the user's `inspect` never reaches the message.
210
+ - `config.user_list_resolver` is the boundary of the people the admin and the API can reach by id, not only the
211
+ picker list. The default resolver lists every user, as before.
212
+ - Permission Set, Permission Set Group, field and action names must not be blank when a row is created or renamed
213
+ (422 in the API). Rows already blank stay editable in their other attributes. Blank names returned by
214
+ `extra_fields_for_permissions` are skipped (and logged) when fields are generated.
215
+ - A Permission Set's group id must name an existing group of the current tenant.
216
+ - `api_assignment_authorization` receives `user_ids` as an Array cast to the user key's type (Integers for integer
217
+ keys) on every endpoint.
218
+ - The API's `POST /permission_sets/:id/clone` makes an editable copy (editable rows), outside a locked group, like
219
+ the admin's Duplicate. Cloning or duplicating a Permission Set Group makes an editable group whose copied sets and
220
+ rows are editable too. Clone names that are not text fall back to "<name> (Copy)".
221
+ - A permission-hash rebuild reads its rows in a fixed number of queries (7; 8 when an assignment names its grantor)
222
+ instead of about four per resource (10 sets with 20 resources each: 828 queries before).
223
+ - The holders of a changed Permission Set are invalidated by id, in slices of 500, with one multi-key stamp write
224
+ and one multi-key removal per slice and no read (`Cache::Base#write_many` / `#expunge_many` / `#delete_many`;
225
+ `RailsCache` uses the store's `write_multi` where it has it: Redis pipelines it, Solid Cache writes one statement).
226
+ For 10,000 holders on local Redis this is 40 round trips and about 0.45–0.55 s, whatever the size of their cached
227
+ hashes and the number of conditional sets they hold, instead of 40,000 to 50,000 round trips. The per-user path
228
+ (`User#invalidate_permissions`) is still used when the user model overrides `invalidate_permissions`,
229
+ `permission_cache_key` or the private `pf_invalidation_stamp_key`, when something subscribes to
230
+ `permissions.invalidated`, or inside a snapshot transaction. A delegating wrapper around the cache backend must
231
+ forward these methods' blocks (they carry errors into the failure report).
232
+ - A permission hash merges resources, fields, actions and rules in id order (sets keep `created_at, id`). Before,
233
+ rows within a set came in query-plan order. Only ties can change: when two fields map to one variant (`customer_id`
234
+ and `customer_uuid`) the older row's `mask_strategy` wins on every database (on SQLite it was the alphabetically
235
+ first), and the order of keys, rules and actions in the hash may differ. Snapshot tests of the JSON may need
236
+ updating.
237
+ - `BulkPermissionSetAssignment` invalidates each assigned or revoked user once, after the commit. `invalidated`
238
+ counts only users whose assignment changed, and is `nil` when the call runs inside a caller's transaction.
239
+ - A cache store that raises (no failsafe) during a rebuild no longer makes `permissions_hash` raise: the request uses
240
+ the hash it built and nothing is cached. A store that raises on a plain read still raises.
241
+ - `rake permission_forge:erasure` says what it removes and what it keeps.
242
+ - Internal APIs: `ExtraFieldDefaults.call` takes `entry:` instead of `fields:` (`IMPLICIT_ENTRY` is replaced by
243
+ `ExtraFieldDefaults.implicit_entry(permissions)`); `AttributeAccessGuard.call(resource_name:)` accepts the chain of
244
+ names a class answers to; `RlsPolicyGenerator#call[:skipped_rules]` gains reason `:sti_subclass_resource`;
245
+ `Engine.apply_masked_attributes_to_managed_models` returns `true`/`false`.
246
+ - Documentation: the README has a production hardening checklist of every default that fails open and how to close
247
+ it, and the guides no longer claim more than the code does (the API's CSRF protection, `user_active_check`, which
248
+ does not remove access, `on_erasure` timing, RLS outside the application, which `find_by` calls apply rules,
249
+ resource CRUD flags, which are enforced where you call `can_*?` or use `ControllerActionGuard`, rules across sets,
250
+ which intersect, log parameter filtering, which is on by default, the benchmarks' Redis/Oj claims, and the
251
+ compliance guides).
252
+
253
+ - **The models live in `app/models/permission_forge/` and are autoloaded by your application** (previously
254
+ required by the gem at `require` time). They read the configuration when they load, right after
255
+ `config/initializers`, so `config.user_class`, `config.audit_user_class` and `config.tenant_model` apply without
256
+ any re-declaration; in development they reload with your code. Configure PermissionForge in `config/initializers`:
257
+ a setting changed later (in `after_initialize`, a console) does not reach a class that has already loaded (in
258
+ production every class, eager-loaded at boot) and PermissionForge logs a warning naming the setting and the
259
+ classes.
260
+ - A `PermissionForge::...` model cannot be referenced before the application has booted: not from the top level
261
+ of a file in `config/initializers`, an engine or railtie initializer, or a file required from
262
+ `config/application.rb` (the boot fails with a `NameError` that says so). There, `defined?(PermissionForge::Resource)`
263
+ and `safe_constantize` answer `nil`. Reference a model from a method (a model, controller, job or rake task).
264
+ To change a class (include a module, add a callback), use `Rails.application.config.to_prepare` or
265
+ `Rails.autoloaders.main.on_load('PermissionForge::PermissionSet') { |klass, _| ... }`, which run again after every
266
+ code reload; `config.after_initialize` runs once, so a change made there is lost at the first reload in
267
+ development: use it only to read.
268
+ - Outside a Rails application, `require 'permission_forge'` no longer defines the model classes.
269
+ - **`ActiveRecord::Base` now loads after your `config/initializers`, as Rails intends.** Before, the gem loaded it at
270
+ `Bundler.require`, so `config.active_record.*` settings made in an initializer (including every
271
+ `config.active_record` value in `config/initializers/new_framework_defaults_*.rb`, such as
272
+ `belongs_to_required_by_default` and `partial_inserts`) were silently ignored, and `config.filter_parameters`
273
+ never reached `ActiveRecord::Base.filter_attributes`. They now take effect: check them before upgrading.
274
+ Consequence you will see: attributes matching `config.filter_parameters` print as `[FILTERED]` in `#inspect`,
275
+ `pretty_print` and error reports. With the default `gdpr_defaults` and `filter_parameters_integration` that
276
+ includes the GDPR patterns, so `name`, `email`, `first_name`, `phone`, `address`, `city`, … of every model,
277
+ PermissionForge's own `name` columns included (`#<PermissionForge::PermissionSet ... name: [FILTERED]>`). To keep
278
+ them visible in the console, set `config.filter_parameters_integration = false` or narrow `config.filter_parameters`.
279
+ - PermissionForge's associations state `optional:` explicitly and no longer follow `belongs_to_required_by_default`:
280
+ an assignment needs its user and every permission row needs its tenant (when tenancy is on) whatever that setting
281
+ says; the parent associations stay optional, as before (the foreign keys hold). Only a host that set
282
+ `belongs_to_required_by_default = false` sees a difference: a user-less assignment or a tenant-less row is now
283
+ refused by validation.
284
+ - The default GDPR field patterns match the short stems `tel`, `city`, `dob`, `fax`, `mobile` and `zip` as a name
285
+ segment (`home_tel`, `tel2`, `telNumber`, `city_name`, `cityName`, `zipcode`), not inside another word: `capacity`,
286
+ `hotel_id`, `telemetry_enabled`, `automobile_id` and `gzip_enabled` are no longer masked by `gdpr_defaults`,
287
+ reported by the GDPR audit or filtered from logs. A name that glues a stem to another word (`homecity`, `telno`)
288
+ is no longer matched either: add what your schema needs to `config.gdpr_field_patterns`. `ethnicity` and
289
+ `telegram_*`, which only matched by accident, now have a pattern of their own. The same patterns serve
290
+ masking detection and `filter_parameters`, so both change together.
291
+ - `PermissionForge::RESOURCE_NAME_FORMAT` is the one definition of a Resource name (`Resource::NAME_FORMAT` and
292
+ `ControllerActions::RESOURCE_FORMAT` point at it); `PermissionForge::MODEL_NAMES` lists the nine models.
293
+ - **Validators of a masked model judge the stored value of the attribute they validate.** Before, they read the
294
+ masked getter: under `masked_attributes_nil_user_strategy = :hide`, `create!`/`update!` with nobody signed in failed
295
+ "can't be blank", and a signed-in user who reads a masked or hidden column failed format or presence checks on any
296
+ save, while uniqueness was checked against the mask. Only that attribute is read raw, only while the validators
297
+ run; other getters and a custom `validate` block read masked. Errors still show what the user reads:
298
+ `errors.details` values and `%{value}` are the masked value, or absent for a hidden column.
299
+ - **Inside `PermissionForge.without_permission_scope`, with nobody signed in, `find_by`, `find_by!` (so
300
+ `authenticate_by`) and `permission_scoped` apply no rules whatever `nil_user_strategy` says** (before: `:deny`
301
+ returned nothing, `:raise` raised). Reported as `rule.bypassed` (`scope_suspended`). The block is the host's
302
+ explicit opt-out, so wrap trusted code only, never code an anonymous request reaches; a signed-in user's lookups
303
+ still apply their rules. The gem's own scope suspensions (holders of an assignment, a cache clear, the record an
304
+ audit event names) no longer count as the host's, and nobody signed in gets no bypass inside them.
305
+ - **`masked_attributes_nil_user_strategy = :hide` no longer hides the user model** (`user_class` and its subclasses)
306
+ when nobody is signed in: authentication reads it before anyone is signed in, so with a Resource row for the user
307
+ model nobody could sign in. Every other masked model still hides. The consequence: with nobody signed in the user
308
+ model's columns, password digests included, read as stored, so do not render it with no current user
309
+ (`permission_forge:audit` reports `user_model_unmasked_without_user`). `explain_all` reports
310
+ `:no_user_user_model_exempt`.
311
+ - The install generator writes a short initializer (148 lines, was 430): `action_guard_nil_user_strategy = :deny`,
312
+ `conditional_sets_strict_mode = true` and `cache_backend = :rails_cache` set, `config.preset = :strict` and
313
+ `masked_attributes_nil_user_strategy = :hide` offered commented with what each costs, and the integration points.
314
+ Every option stays documented in docs/configuration.md, and the library defaults do not change. Existing installs
315
+ are not affected. A new install no longer assigns its own `ignored_fields` list, so it keeps the library's frozen one (`+=` works, `<<` raises).
316
+ - Auto-applied masking no longer reads the cache store at every request or job when `auto_apply_masked_attributes`
317
+ and `mask_virtual_attributes` are each a list of models or off (before: one read per request or job), and
318
+ committing a `Resource` row then writes no masking token; with both off the per-request check reads nothing.
319
+ `:auto` is unchanged. The guides now recommend `include PermissionForge::Concerns::MaskedAttributes` in each model
320
+ with `auto_apply_masked_attributes = false` in production.
321
+ - A name in an `auto_apply_masked_attributes` or `mask_virtual_attributes` list that is no model in the process is
322
+ now logged as a SECURITY warning (once per name) and retried at the next request or job, instead of counting as
323
+ masked.
324
+ - Internal APIs: `PermissionForge::Engine` only wires initializers and hooks; it no longer extends `BootChecks`,
325
+ `MaskedModels` or `QueryScopingWarnings`. `Engine.try_apply_masked_attributes` / `try_mask_virtual_readers` are
326
+ `MaskedModels.apply_concern` / `mask_virtual_readers`, `Engine.run_boot_checks` is `PermissionForge::BootChecks.run`,
327
+ and `MaskingSync.named!` takes the "another row has this name" answer as a block.
328
+ `Engine.apply_masked_attributes_to_managed_models` stays as a deprecated delegator (see Deprecated).
329
+ - The audit context set by `PermissionForge.with_audit_actor` (or `Audit::Context.with`) around code that runs an
330
+ executor run (an inline ActiveJob in a console or rake task) is kept after that run. Before, it was emptied at the
331
+ end of the run, and the changes after the job were recorded without the actor. A request or job still ends with an
332
+ empty context.
333
+ - A cache failure reported in another fiber of the same thread (an Enumerator iterated with `next`) now counts for
334
+ the `Cache::Failure` check around it, so such an invalidation is reported as unconfirmed instead of silently passing.
335
+ - `PermissionForge::RLS` no longer calls `ActiveRecord::Base.connection` (soft-deprecated from Rails 7.2; raises
336
+ under `ActiveRecord.permanent_connection_checkout = :disallowed`). `with_user`, `sync!`, `remove!`, `drift_report`
337
+ and `postgres?` use `connection_pool.with_connection` and give back a connection the thread did not hold;
338
+ `set_user!` / `clear_user!` keep using the connection the thread keeps (`lease_connection`).
339
+ - `ControllerActions.guarded?` raises `ConfigurationError` when a controller's before_action chain cannot be read
340
+ (`_process_action_callbacks`), instead of reporting the controller as guarded. The admin action catalog leaves such
341
+ entries out (logged once); `SecurityAudit` then reports `action_guard_missing`.
342
+ - The `find_by` / `find_by!` override on models with `PermissionScopedFindable` finds its record with
343
+ `where(...).take` instead of a relation's `find_by` (same query; no reliance on how Rails routes `Relation#find_by`).
344
+ - Admin UI: the field drawer, audit event and rule builder pages read from `Admin::FieldDetail`,
345
+ `Admin::AuditEventDetail` and `Admin::RuleBuilder`; no admin markup is marked safe by hand (a test bans
346
+ `html_safe`, `safe_concat`, `<%==` and `raw` in `app/` and `lib/`); `pf_admin.js` uses `const`/`let` and builds no
347
+ markup from strings.
348
+
349
+ #### Upgrading from 0.13
350
+
351
+ No migration is needed. Check each item that applies to your app:
352
+
353
+ 1. **Every app: clear the permission cache once.** When every process runs 0.14 and the last 0.13 process is gone,
354
+ run `bin/rails permission_forge:cache:clear ACTOR="..." REASON="deploy 0.14"`. During a rolling deploy a 0.13
355
+ process cannot invalidate a 0.14 entry, so a change made there could stay cached until `cache_ttl`; running the
356
+ task earlier does not help, since a 0.13 invalidation after it would miss the new entries again. Every user's
357
+ entries are removed (both versions', and the variants), by key pattern where the store can (Redis, memory, file)
358
+ or user by user; the cache is cold afterwards, one rebuild per user on their next request. User by user (Solid
359
+ Cache, Memcached), a user who had a per-context variant stored costs 64 more keys per cache key (10,000 such users
360
+ took about 7 s on local Redis), and each removed key is written as an empty entry, which Solid
361
+ Cache keeps as a row until it trims its table.
362
+ 2. **JSON API enabled?** Set `api_action_authorization` and `api_assignment_authorization`, or API changes are
363
+ refused with 403. To keep 0.13's behaviour, set `api_require_authorization = false`.
364
+ 3. **Multi-tenant (`tenant_model` set)?** Make `base_controller_class` (and `api_base_controller_class`) a controller
365
+ that sets the current tenant, or the admin answers 403. `admin_tenantless_access = :allow` restores 0.13's
366
+ behaviour.
367
+ 4. **Nested attributes through `PermitsAttributes`?** Give each child model its own Resource entry, or set
368
+ `nested_attributes_parent_fallback = true`.
369
+ 5. **Code reading `NotAuthorizedError#message` as an Array or Hash?** Use `#details`.
370
+ 6. **PostgreSQL RLS?** Run `rake permission_forge:rls:sync` (with eager loading, so subclass rules are reported).
371
+ 7. **Controllers using `ControllerActionGuard` with `nil_user_strategy = :bypass`?** Signed-out requests still pass
372
+ mapped actions. New installs set `action_guard_nil_user_strategy = :deny`; consider setting it.
373
+ 8. **Several app processes?** Use a shared cache store (`:rails_cache` over Redis, Memcached or Solid Cache) so
374
+ auto-applied masking reaches every process.
375
+ 9. **`PermissionForge::Cache::RedisCache` referenced?** It is removed; use `cache_backend = :rails_cache` with
376
+ `redis_cache_store`.
377
+ 10. **STI subclasses of protected models?** They now obey the base class's rules and fields; check that users who
378
+ should read them still can.
379
+ 11. **Conditional Permission Sets?** Check that `request_context_resolver` is cheap, returns `{}` outside a request
380
+ and reads no permissions; consider `conditional_sets_strict_mode = true`.
381
+ 12. **Anything in `config/initializers` (or an engine/railtie initializer, or a file `config/application.rb`
382
+ requires) that names a `PermissionForge::` model at the top level?** The models are autoloaded after the
383
+ initializers now, so the boot fails with a `NameError`. Move the code into `Rails.application.config.to_prepare`
384
+ or `Rails.autoloaders.main.on_load('PermissionForge::PermissionSet')` (they run again after a reload, so keep
385
+ them idempotent), or into a method. Configure PermissionForge only in `config/initializers`; a later change does
386
+ not reach a loaded class and is logged.
387
+ 13. **`config.active_record.*` in an initializer or in `new_framework_defaults_*.rb`?** It now applies (before it was
388
+ ignored in an app with PermissionForge installed): check `belongs_to_required_by_default`, `partial_inserts` and
389
+ the rest before deploying.
390
+ 14. **`#inspect` shows `[FILTERED]`?** `config.filter_parameters` now reaches `ActiveRecord::Base.filter_attributes`,
391
+ and the GDPR patterns are part of it. Narrow `config.filter_parameters`, or set
392
+ `filter_parameters_integration = false`, if you need the values in a console. The patterns for `tel`, `city`,
393
+ `dob`, `fax`, `mobile` and `zip` now match a name segment only: check your schema with
394
+ `rake permission_forge:audit` and add what you lost to `config.gdpr_field_patterns`.
395
+ 15. **Calling `PermissionForge::Engine.apply_masked_attributes_to_managed_models`?** It warns through
396
+ `PermissionForge.deprecator` and is removed in 0.15: call `PermissionForge::MaskedModels.apply(only:)`. Other
397
+ `Engine` helpers (`try_apply_masked_attributes`, `try_mask_virtual_readers`, `run_boot_checks`,
398
+ `apply_tenant_scoping_to_models`, `rebind_configurable_associations`) moved or are gone (see Removed).
399
+ 16. **Calling `PermissionForge::RLS.connection`?** It was undocumented and is removed.
400
+ 17. **Models with `masked_attributes_nil_user_strategy = :hide`, or code under `without_permission_scope`?** Validators
401
+ now read stored values; the user model is not hidden with nobody signed in; `find_by` / `permission_scoped`
402
+ inside `without_permission_scope` apply no rules when nobody is signed in. Do not include
403
+ `PermissionScopedFindable` in your user model.
404
+ 18. **Large app?** Consider `config.preset = :strict` (the production hardening checklist in one line, see
405
+ docs/configuration.md#presets) and, in production, `include PermissionForge::Concerns::MaskedAttributes` in each
406
+ model with `auto_apply_masked_attributes = false` (no cache-store read per request).
407
+ 19. **`current_user_resolver` reading `Thread.current[:current_user]` or a class variable?** Use
408
+ `ActiveSupport::CurrentAttributes` (see Security above).
409
+
410
+ ### Added
411
+
412
+ - `rake permission_forge:cache:clear` (`Services::PermissionCacheClearer`): removes every user's cached permissions
413
+ in every tenant, by key pattern where the store can (verified by writing and reading back canary keys) or
414
+ through the bulk invalidation of every user. Records one `cache.cleared` event per tenant plus one without a
415
+ tenant. The cache is cold afterwards. On an in-memory store (`:memory`, `:memory_store`) it says that only its own
416
+ process's store was cleared.
417
+ - `PermissionForge::ConditionalSets` and `Concerns::PermissionsPerContext` (per-context selection);
418
+ `Cache::Base#read_many` (`RailsCache`: one `read_multi`), `#clear_entries` / `#purge_entries`.
419
+ - `PermissionSetConditionEvaluator` accepts an `IPAddr` as the request IP.
420
+ - `config.masked_attributes_nil_user_strategy` (`:bypass` default, `:hide`). With no current user, `:hide` hides
421
+ every guarded attribute of a masked model (getters, `read_attribute`, `[]`, `attributes`, `as_json`, wrapped
422
+ virtual readers and store accessors); the primary key and the raw paths are untouched. `rake
423
+ permission_forge:audit` reports `:bypass` as `masked_nil_user_bypass` (medium), the Settings page lists it, and the
424
+ generated initializer offers it commented, with the trade-off for jobs and mailers that read masked data.
425
+ - `config.action_guard_nil_user_strategy`: what `ControllerActionGuard` does for a mapped action when nobody is signed
426
+ in (`nil` or blank follows `nil_user_strategy`; `:bypass`; `:deny`). The install generator sets `:deny`.
427
+ - `config.admin_tenantless_access` (`:deny` by default, `:allow`).
428
+ - `config.nested_attributes_parent_fallback` (`false` by default).
429
+ - `PermissionForge::ResourceChain`, the one resolver of the Resource entries a model class answers to;
430
+ `MaskedAttributes.explain` reports it as `:resource_chain`.
431
+ - Audit-log data: `assignment.changed` carries `previous_subject_user_id` when an assignment moves to another user.
432
+ - `rake permission_forge:audit` findings `nested_parent_fallback` (medium) and `tenantless_admin`.
433
+ - `config.preset = :strict` closes the fail-open defaults of the Production hardening checklist in one line
434
+ (`nil_user_strategy :deny`, `response_filter_nil_user_strategy :block`, `auto_query_scoping :filter_only`,
435
+ `unconfigured_action_strategy :block`, `action_guard_nil_user_strategy :deny`, `conditional_sets_strict_mode true`,
436
+ `mask_virtual_attributes :auto`, `extra_fields_default_access :unconfigured`, `cache_backend :rails_cache`) and keeps
437
+ the closed ones closed. `:compat` (the default) is the library defaults. A setting you make yourself always wins,
438
+ in any order. `masked_attributes_nil_user_strategy` is in no preset. `rake permission_forge:audit` prints the preset
439
+ (also `preset` in `FORMAT=json`) and reports `strict_overrides`; the Settings page shows it.
440
+ - `PermissionForge.deprecator`, an `ActiveSupport::Deprecation` registered as
441
+ `Rails.application.deprecators[:permission_forge]`.
442
+ - `rake permission_forge:audit` reports `user_model_scoped` (the user model includes PermissionScopedFindable under
443
+ `nil_user_strategy :deny`/`:raise`: sign-in lookups find nothing) and `user_model_unmasked_without_user` (under
444
+ `:hide` the user model reads as stored with nobody signed in).
445
+ - `@api public` / `@api private` markers on every class and module, a `.yardopts`, and `rake yard:check` in CI; the
446
+ supported surface is now machine-readable (README, Versioning and stability).
447
+
448
+ ### Deprecated
449
+
450
+ - `PermissionForge::Engine.apply_masked_attributes_to_managed_models`: use `PermissionForge::MaskedModels.apply`;
451
+ removed in 0.15. From this release on, renamed or removed public API is listed here and warns through
452
+ `PermissionForge.deprecator` for at least one minor version.
453
+
454
+ ### Fixed
455
+
456
+ - **A user instance kept across requests stops granting at an assignment's `expires_at`.** It kept granting from an
457
+ expired assignment until an invalidation in its process or `reload`; the memo is now retired at the soonest
458
+ `expires_at` that went into it.
459
+ - **The maintenance services run tenant-less under `require_tenant = true`.** `ExpiredAssignmentCleaner`,
460
+ `DataSubjectErasure`, `StaleAssignmentAuditor` and `ConsentWithdrawalHandler` raised
461
+ `ActsAsTenant::Errors::NoTenantSet` when run without a tenant (the setting the tenancy guide recommends); they now
462
+ lift the tenant scope for that run (`Services::Concerns::AcrossTenants`), like the purge and the cache clearer. The
463
+ `cleanup.expired_assignments` and `consent.withdrawn` summaries of such a run are written in each tenant concerned
464
+ (its counts and sets) and once without a tenant (the totals), with `across_tenants: true`.
465
+ - **An unusual `Accept` header on a failing admin form was a 500** (a missing template, for example
466
+ `Accept: application/json` on `POST /assignments`). The admin answers HTML unless the action has that export.
467
+ - **The missing audit table warning names the right step.** A fresh install booted before `db:migrate` was told to
468
+ run `permission_forge:upgrade` (which would duplicate migrations); it now says `bin/rails db:migrate`, and names the
469
+ upgrade generator only when no migration of the gem creates the table.
470
+ - **`attributes` keeps the primary key.** Under `unconfigured_field_strategy = :block` the guarded `attributes`
471
+ hash left out `id`, though `read_attribute` and `as_json` never guard it.
472
+ - **`PermissionForge.cache.clear` removes only the gem's keys.** On the memory and file stores the `"pf:*"`
473
+ String became an unanchored pattern that removed any key containing "pf" (a host's `apfx_session`), and on a
474
+ store with its own `namespace:` it raised `NoMethodError`. It now uses `/^pf:/` there (Redis keeps the glob).
475
+ - A user cached by 0.13 with Active Record's 6.1 marshalling format (its memo is an Array) is read from the store
476
+ again instead of raising.
477
+ - **The app boots when the database cannot be reached** (a Docker build running `assets:precompile`, a deploy during
478
+ a failover). On Rails 7.2+ the boot checks raised `ActiveRecord::DatabaseConnectionError`. They now ping the
479
+ database once and skip the steps that read it, with one log line; masking is applied at the first request or job
480
+ that reaches the database.
481
+ - **The generated initializer works without a `Current` class.** `current_user_resolver` reads `Current.user` only
482
+ when `Current` exists and answers `user`; apps without one raised `NameError` on every permission check. The
483
+ generated `profile_builder` keeps `uuid`, as the hash contract says.
484
+ - **A user model without a `uuid` column** no longer makes `permissions_hash` raise once an assignment was made by a
485
+ signed-in admin (`audit_trail_enabled`): `permission_set_granted_by` / `_updated_by` hold the user's uuid when it
486
+ has one, else its id.
487
+ - **A string longer than its column is a validation error (422), not a 500 on MySQL.** Only a Permission Set's name
488
+ had a length rule; MySQL in strict mode raised `ActiveRecord::ValueTooLong` for the other strings the admin and the
489
+ API write. Each permission model (`Concerns::ValidatesColumnLengths`) checks the attributes being written against
490
+ the limit their column declares (characters for a string column, bytes for a text column). A column that declares
491
+ no limit (a `string` on PostgreSQL or SQLite) is not checked, and a longer value stored before does not block edits
492
+ to the row's other attributes.
493
+ - `DataSubjectErasure(on_erasure:)` called inside a transaction runs the callback after that transaction commits, and
494
+ never after a rollback. `callback_invoked` is then `nil`, and an error the callback raises is reported through
495
+ `Rails.error` and the log.
496
+ - Moving an assignment to another user invalidates the previous holder's cached permissions too. A resource, field,
497
+ action or rule moved to another set or resource invalidates the holders of every set it left in the transaction,
498
+ also after a savepoint rollback or when another instance of the row commits.
499
+ - A purge run with no tenant set writes one `audit.purged` per tenant whose events it deleted (that tenant's count,
500
+ `across_tenants: true`) plus one for untenanted rows; tenant auditors could not see the single tenant-less event.
501
+ - `cleanup.expired_assignments` stores `affected_user_count` and at most 100 ids (`affected_user_ids_truncated`)
502
+ instead of an unbounded list, which could overflow a MySQL TEXT column.
503
+ - `POST /api/…/rules` with `is_null` / `is_not_null` and no `field_value` answered 500; the value is stored as empty.
504
+ - The admin's Effective Access sorts rows like the hash builder, so it shows the same `mask_strategy` on a variant
505
+ tie (`PermissionHashBuilder.sort_rows`).
506
+ - The erasure and stale-assignment dry runs no longer raise on an assignment whose Permission Set is gone.
507
+ - `BulkPermissionSetAssignment` no longer counts a revocation that a callback refused.
508
+ - `ManagedIndex` keeps one per-request copy per tenant key instead of one per cache-version bump.
509
+ - The GDPR boot scan plucks field names instead of loading every unmasked `ResourceField`.
510
+ - The test suite passes on MySQL (test-only fixes for backtick quoting, DDL inside a transaction and foreign keys
511
+ on drop); CI gains a MySQL job.
512
+ - `PermissionForge::AuditEvent` refuses `update_counters` on a relation too (`increment!`/`decrement!` take that
513
+ path on Rails 8.2), so an audit row cannot be changed that way.
514
+ - Admin UI accessibility: every page has one h1 (drawer and confirmation titles, Permission Set and Group names),
515
+ heading levels no longer skip, filter chips use `aria-current`, links in footers and hints are underlined, an
516
+ existing action's key is no longer a label for a missing control, the erasure confirmation points
517
+ `aria-describedby` at its error only when it is shown.
518
+
519
+ ### Removed
520
+
521
+ - `PermissionForge::Cache::RedisCache`. It was never loaded, could not be configured and did not match the cache
522
+ interface. Use `cache_backend = :rails_cache` with `ActiveSupport::Cache::RedisCacheStore` (or pass the store).
523
+ - `Cache::Base#json_dump`, `#json_load` and `.oj_available?`, which only `RedisCache` used.
524
+ - Internal planning documents (`docs/plan-*.md`, `docs/prd-*.md`) and the duplicate `LICENSE` file (the license
525
+ is `LICENSE.txt`).
526
+ - `PermissionForge::RLS.connection` (undocumented).
527
+ - Internal: `HasAuditTrail.rebind_audit_associations!`, `Engine#rebind_configurable_associations`, the
528
+ `permission_forge.rebind_associations` and `permission_forge.load_models` initializers (nothing is rebound after
529
+ boot; the 288 "method redefined" warnings at boot under `ruby -w` are gone), `Engine.apply_tenant_scoping_to_models`
530
+ (each model scopes itself as it loads) and `MaskingSync.boot_skipped!` (use `reset!`).
531
+
532
+ ### Internal
533
+
534
+ - Rails-internals canary (`test/permission_forge/rails_internals_canary_test.rb`): one test per private
535
+ ActiveRecord/ActiveSupport API the gem relies on, each naming the code that relies on it; CI runs it on Rails
536
+ `main` in an allow-failure job.
537
+ - Test suite: the configuration is restored from a snapshot after every test instead of a hand-written reset; the
538
+ SQLite suite runs in forked workers (about 30 s instead of about 2 minutes; `PARALLEL_WORKERS=1` for one process);
539
+ the dummy app logs warnings only; no wall-clock sleeps; the suite's own Ruby warnings are gone.
540
+
541
+ ## [0.13.0] - 2026-10-06
542
+
543
+ Includes the fixes for the four blockers found by the pre-release review (the first four entries under Fixed).
544
+
545
+ ### Security
546
+
547
+ - **API lookups under `api_record_identifier = :id` no longer match an identifier by its leading digits.** The identifier went to the database as given, which casts `1abc` — or a uuid that starts with `1` — to `1`, so `GET /api/permission_sets/1abc` returned Permission Set 1 and a uuid could name an unrelated record. Every API lookup now goes through one method that accepts only digits under `:id` (and refuses a NUL byte under either setting, which PostgreSQL answered with a 500); anything else is a 404.
548
+ - **The compliance documents no longer claim more than the code does.** The HIPAA, GDPR, masking and security-audit guides and the three compliance skills now map each control as "what the gem evidences (with the event or feature) / what the host must add". Removed: `PermissionsRegenerator` (cache invalidation) as an audit-control row, the sentence that audit reports are "certification evidence", the withdrawn ISO 27001:2013 numbering (Annex A 2022 is used), and mappings that did not hold — expiry and allowed hours as "automatic logoff", masking as "encryption/decryption", API auth, UUID ids and pagination as "transmission security", assignments as "identity management". Nothing claims compliance or certification.
549
+ - **Symbol-named extra fields could unmask a configured field.** When a model's `extra_fields_for_permissions` returned Symbols (`%i[email]`), a name that collided with a configured `ResourceField` slipped past the collision check added in 0.10.0 and replaced the configured entry once the cache symbolized the hash — the field became readable, editable and unmasked. A muting set also could not hide a Symbol-named extra field. Names are now normalized to Strings before merging. Apps declaring Strings (`%w[...]`) were not affected.
550
+ - **A permission-hash rebuild that raced a change could cache stale permissions for the whole TTL.** A request whose cache entry was missing read the permission rows, an admin then committed a change (or revoked an assignment) and invalidated, and the request stored what it had read — after the invalidation's delete. Every invalidation now leaves a per-user stamp (written before the delete) and a rebuild checks it before reading the rows and after storing: if it moved, the stored hash is dropped and built again. Rebuilds read their rows outside ActiveRecord's query cache, and the stamp past the cache store's per-request local cache, so the check also holds inside a web request. Host apps that derive their own cache entry in a `regenerate_permissions` override should compare `permission_invalidation_stamp` the same way (see `docs/architecture.md`).
551
+ - **A configured virtual field could lose to the implicit grant, depending on which Permission Set was older.** The implicit read + edit entry for extra fields was added after each granting set. For a person with two sets containing the resource, a row in the *newer* set (say Masked, read-only) merged into the older set's implicit entry and the field stayed editable and unmasked; with the sets the other way round the row won. The implicit entry is now added once per resource, after every granting set is merged (`Services::ExtraFieldDefaults`), so a row in any of the person's sets wins. **This can reduce access** for people in that situation; the admin's Effective access page showed the stricter result all along.
552
+ - **`alias_attribute` names bypassed field-level masking on a model with `MaskedAttributes`.** For `alias_attribute :mail, :email` with `email` hidden or masked, `record.mail` returned the stored value on Rails 7.2 and later (on 7.1 when the model was in use before the concern arrived), `record[:mail]` and `read_attribute(:mail)` returned it on every Rails version unless `unconfigured_field_strategy` was `:block`, `as_json(methods: [:mail])` exposed it, and `record.mail = …` skipped the write-back protection, so a hidden `nil` read through the alias and assigned back cleared the column. An alias is now judged as the attribute it points to on every path — getter, `[]`, `read_attribute`, `slice`, serialization, setter — whenever and wherever it was declared (before or after the concern, in a subclass, on a parent with or without the concern), and otherwise does what Rails does for an alias on that Rails version. Code that reached a hidden column through its alias on purpose must use `*_before_type_cast`. See "Aliases" in `docs/masked-attributes.md`.
553
+ - **A subclass that was in use before its parent got `MaskedAttributes` kept unguarded column getters.** Rails gives each subclass its own column methods on first use; a subclass used before the concern arrived — host code touching a record during boot, or the parent's first `Resource` row created at runtime in a process that had already used the subclass — went on calling them. A normal boot was not affected. **Upgrade note:** such a subclass is now guarded like any other, under its own class name: with the default `unconfigured_field_strategy = :block` and no `Resource` rows of its own, every getter returns the hidden value, including columns readable on the parent. Give each subclass its own Resource and field rows.
554
+ - **On databases whose transactions read from a snapshot, a permission hash built inside a transaction is no longer cached.** Under REPEATABLE READ or SERIALIZABLE — MySQL's default, SQLite, and PostgreSQL inside `transaction(isolation: :repeatable_read)` or `:serializable` — a transaction keeps reading the rows as they were when it began, so a rebuild inside it could store a hash older than a change that had already been invalidated, and keep it until `cache_ttl`. Inside such a transaction the request now builds and uses its own hash and the cache store is left alone; the user object does not read the store again until the transaction has ended. `regenerate_permissions` and `invalidate_permissions` called there take effect in the cache once the outermost transaction has committed (once per user per transaction, nothing after a rollback, also when a commit callback of another record raises). Which transactions count is the new `config.snapshot_transactions` (`:auto`). **PostgreSQL at READ COMMITTED is not affected: inside a transaction everything is cached and invalidated as before** — including the known limit that a grant regenerated inside a transaction that then rolls back stays cached until the next invalidation (invalidate after a rollback). On MySQL and SQLite, permissions first read inside a transaction are built once per transaction and not cached: read them once before a loop of saves (50 saves on a cold entry are 50 builds and 350 queries instead of 1 and 7), or set the option to `false` on a MySQL that runs at READ COMMITTED. Until the commit, another instance of the same user goes on reading the entry of the committed rows. A single `transaction(joinable: false)` with nothing joinable around it is not recognised — it is what wraps a test.
555
+ - **A cache store that fails without raising no longer loses an invalidation silently.** Redis, Memcached and Solid Cache stores answer false or nil when unreachable — a delete also for "nothing there". The answers are checked: an unconfirmed invalidation is tried three times, then logged at error level, published as `cache_failure.permission_forge` and handed to `config.on_cache_failure`. Nothing is raised out of `after_commit` (a raising hook or notification subscriber is contained too). The process that failed keeps the invalidation as owed (up to 500 users), does not serve those users from the store meanwhile, and makes the invalidation when the store answers again, 25 per pass — extra effort, not a guarantee: other processes, a restart or an overflow leave `cache_ttl` as the backstop. A store that is down never widens access: requests work with the hash they build, never an empty one (an empty hash switched row-level rules off).
556
+ - **Invalidation follows the changed record's tenant, not the ambient one.** A record of tenant A saved while tenant B — or none — was ambient looked in the wrong tenant and invalidated nobody. The assigned users are loaded past the default scopes of the host's user model. A permission table without the tenant column is looked up without one instead of failing.
557
+ - **The managed-actions index is not written to the cache from inside a transaction.** A guard check inside a transaction that switched a managed action off and was then rolled back left "not managed" in the shared cache for up to 60 seconds, allowing the action for everybody under the default `unconfigured_action_strategy`. An index built inside an open transaction now stays with that request (a guard check inside a transaction costs the index's two queries each time), and a drop of the index made inside a transaction is repeated after the commit.
558
+ - **The managed-actions index is invalidated and rebuilt like a permission hash.** A request that read the rows before a new action was committed could store its older index after the commit had dropped it; and a drop the cache store did not confirm was reported but neither tried again nor avoided. Either way later requests saw the action as unmanaged and, under the default `unconfigured_action_strategy = :allow`, could run it without a grant until the index expired (60 seconds), also after the store had recovered. The index now goes through `Cache::Invalidation` and `Cache::Rebuild`: every drop writes the stamp `managed_actions_invalidation` first, a build stores the index only when that stamp did not move meanwhile (otherwise it is removed and built once more, reading past the query cache), and a drop the store does not confirm is tried again, reported and owed — until the store confirms it, the process builds the index from the database instead of reading it. Other processes may serve the old index for 60 seconds at most. `Cache::Backlog` now lists entries by stamp key (`Invalidation.owed?` takes the stamp key).
559
+ - **Nothing is cached from `rails console --sandbox`.** A grant tried out in a sandbox console was cached and outlived the sandbox's rollback.
560
+ - A request that had just rebuilt its hash read the entry back and could pick up another rebuild's not-yet-verified hash for that one request. It now keeps the hash it built and verified.
561
+ - **Row-level rule values are compared in the type of their column, on every database.** A rule's `field_value` used to reach the database as text. PostgreSQL raised on text it could not cast, SQLite compared as text, and MySQL converted it: `supplier_id equal '5_id'` matched 5, `'abc'` matched 0, and `deactivated equal 'true'` matched the rows where it was false. On SQLite `not_equal` or `less_than` with such a value matched every row. `Operators::ValueTyping` now types the value (number, boolean, ISO 8601 date/time, text, enum label → stored value). **A value the column cannot hold makes the rule match nothing**, for the negative operators too and without the `include_nulls` rows. The column is found the way the database resolves the name: any case on MySQL and SQLite, a schema or database prefix, a column in `ignored_columns`, dotted names and join rules. Not typed, as before: array, uuid and json columns, the table alias of a self-join, and the row id pseudo-column (`_rowid`, `rowid`). See "How Values Are Compared" in `docs/row-level-rules.md`, and **Upgrading** below for what changes per database.
562
+ - **A rule's `join_type` is looked up in an allow-list.** It was passed to `Relation#send`, so a rule row written past the model's validation — the rule tables can be shared with other applications — could call any relation method: `send` with a `field_accessor_path` of `delete_all` emptied the table, `unscope` dropped the rules applied before it. Anything but `joins`, `left_joins`, `left_outer_joins` (or nothing, blank or `filter`, which mean a filter on the model's own table) now raises `PermissionForge::ConfigurationError` in `find_by`, `find_by!` and `permission_scoped`.
563
+ - **`contains`, `not_contains`, `starts_with`, `ends_with` match `%`, `_` and `\` literally on SQLite, and on MySQL under `NO_BACKSLASH_ESCAPES` with the trilogy adapter.** The SQL had no `ESCAPE` clause, so `not_contains '50%'` let rows containing `50%` through. (With the mysql2 adapter a text value raises in that mode, as before.)
564
+ - **RLS policies select the rows the application layer selects, and never more.** String operators compile to `ILIKE` on the column itself (they were case-sensitive, so `not_contains 'acme'` left `ACME` rows visible, and cast `char(n)` to text, which dropped the padding). `include_nulls` adds NULL rows only for `equal`, `not_equal`, `in`, `not_in` (it made `is_not_null` always true). A string operator on a non-text column compiles to `FALSE` (the application layer raises there). Values are typed by the same code. A rule whose `join_type` is `filter` is no longer skipped. **Run `rake permission_forge:rls:sync` after upgrading** (`GENERATOR_VERSION` 3; `rls:status` reports every protected table as stale until then, and the old policies stay in force meanwhile — wider than the application layer in the cases above).
565
+ - **A user object read on several threads at once could be left with an empty permission hash.** A read-through rebuild handed its hash back to `permissions_hash` through instance variables of the user, which every rebuild cleared when it finished. When two threads missed the cache on the same user object at once (a user handed to threads of a request, or kept across requests), one of them could get `{}` and memoize it until the next invalidation in the process: every `can_*?` check denied, every field hidden under the default `unconfigured_field_strategy = :block` (passed in the clear under `:warn` or `:pass_through`), and row-level rules switched off for that object, because an empty hash has no `permission_set_rules`. Each rebuild now hands its hash back on its own thread, and a host override of `regenerate_permissions` whose `super` runs on another thread or fiber gets the hash `super` memoized. The `{}` a build is answered when it asks for its own user's permissions (a masked attribute read by the profile builder) used to be memoized as well, so another thread reading that object during the build was served it; it is now returned to that call alone and never memoized. A host override of `regenerate_permissions` that returns without building the hash for the cache key — it skips `super`, or runs it where the tenant is another — used to leave `{}` on every call (row-level rules off); it now raises `PermissionForge::ConfigurationError` naming the key. A user object loaded per request on one thread was not affected.
566
+ - **A user object read on several threads at once could keep another tenant's hash, or a hash read from the store before an invalidation.** The memoized hash was stored as four instance variables (key, version, transaction, hash) written one after the other, so two threads memoizing on the same object could leave one thread's key with the other thread's hash — another tenant's permissions under this tenant's key — or a hash read before an invalidation under the version after it. The memo is now one frozen object written in one assignment, and a hash read from the cache store is memoized under the version from before the read, so an invalidation during the read retires it. A hash built inside a transaction whose hashes are withheld (`config.snapshot_transactions`) is served only on that transaction's connection: another thread's connection does not see the rows it was built from (`OpenTransaction#visible?`), and a thread on another connection neither replaces it nor stays away from the store because of it. The transaction the user object is tied to now lives in the memo too. Objects kept on a user still survive `Marshal.dump`.
567
+ - **Reading a record while `MaskedAttributes` was being applied to its class could raise.** When the concern was included at runtime (the engine applies it when a model's first `Resource` row appears) while other threads read records of that model, `read_attribute`, `[]`, `attributes`, `as_json` and `to_json` could raise `NoMethodError` (`pf_resource_symbol`) for an instant, and `as_json(methods: [...])` `NameError` (`guarded_user`). Every read during the include now answers either as without the concern or as with it; no step returns nil, or a stored value once the guard is in place.
568
+
569
+ ### Added
570
+
571
+ - **Audit event log.** Every create, update and destroy of a Permission Set, Resource, field, action, rule, group, assignment or group membership is recorded in the new `audit_events` table — who did it (`actor_type/id/label`), from where, when, the changed attributes with their previous values or a full snapshot of a deleted row, the Permission Set and person concerned, and one `correlation_id` per transaction — inside the change's own transaction: a change made through the models whose event cannot be written (writes that skip callbacks are never recorded) raises `PermissionForge::Audit::EventNotRecordedError` and rolls back, also when the error is rescued inside an enclosing transaction (`config.audit_change_failure_strategy`). Events are insert-only in Ruby only (`update_all`, `delete_all`, `insert_all` and database access still reach the table; protect it at database level), tenant-scoped, published as `"<event>.permission_forge"` notifications after the commit, and switched by `config.audit_events` (`:auto`: on when the table exists; a boot warning says when it is missing). New: `PermissionForge::Audit.record/instrument/instrument?/correlate`, `PermissionForge.with_audit_actor`, `record.audit_as`, `Audit::Context`, `Audit::EventCatalog` (adding an event is one entry), `Concerns::RecordsAuditEvents`, `OpenTransaction#before_commit` and `#after_end`, and the options `audit_events`, `audit_actor_resolver`, `audit_request_context`, `audit_change_failure_strategy`, `audit_hook_failure_strategy`, `audit_runtime_events`, `audit_log_subscriber`, `audit_retention_period`, `audit_log_authorization` and `admin_assignment_authorization`. Run `bin/rails generate permission_forge:upgrade` and migrate; the migration (`--tenant-column`, irreversible) also adds the assignment-review and acknowledgement columns. See `docs/audit-log.md`, which also lists what is not recorded.
572
+ - **Who did it, in every request.** The admin UI and the API record the acting person on every audit event — `config.audit_actor_resolver`, else the signed-in user, else the HTTP Basic username (`actor_type: basic_auth`) or an anonymous API client — with the request id, IP (`config.audit_request_context`; client-influenced, correct only with trusted proxies configured), an optional `reason` parameter and one correlation id per change request; jobs are recorded with source `job`. Failed Basic and API sign-ins, refused API authorizations and locked-record refusals are recorded (`admin.auth_failed`, `api.auth_failed`, `admin.lock_refused`) even when the surrounding transaction rolls back (not inside a non-joinable one), and viewing someone's effective access, the audit tabs and CSV exports are recorded (`admin.effective_access_viewed`, `audit.viewed`, `audit.exported`). Event columns are cleaned once (valid UTF-8, one line, no control characters, at most 255 characters). Refusals of `admin_assignment_authorization` and `audit_log_authorization` are recorded, and so are reads of the audit log through the API. `created_by` / `updated_by` are only stamped for a record of `audit_user_class`.
573
+ - **Audit events cover copies and cascades.** Duplicating a Permission Set or group (admin UI, "start from", API clone) credits the person making the copy on every copied row (fresh uuid, `created_by`/`updated_by` reset) and records `permission_set.cloned` / `permission_set_group.cloned` with the source; copies are created unlocked instead of unlocked afterwards with `update_all`; deleting a group detaches its Permission Sets through audited saves (one `permission_set.updated` each) instead of `dependent: :nullify`; Review-and-save and field sync run all-or-nothing under one correlation id, and a Review-and-save that fails halfway leaves neither changes nor events, even inside a caller's transaction; field generation (`Resource.grant_full_access!`, API `generate_fields`) is all or nothing under one correlation id; a copied field starts without the original's "This is intended" acknowledgement.
574
+ - **Access review and assignment evidence.** "Keep" records `reviewed_at` and `reviewed_by_id` with an `assignment.reviewed` event (decision `keep`). "This is intended" records `sensitive_acknowledged_by_id` with a `field.sensitive_acknowledged` event. Every grant, expiry change, revocation and group membership change from the admin UI and the API writes its event with actor, person, expiry and reason, and the reason is also kept on the row. New `config.admin_assignment_authorization = ->(controller, target, user_ids, action)` is asked before each of them (`:grant :revoke :change_expiry :review :add_member :remove_member`), for example to forbid self-assignment; consent withdrawal, erasure, the expired-assignment clean-up, assignments removed with a deleted Permission Set or group, and Ruby/console calls are not gated by it. `record.audit_as` only renames a model's own update events to the names listed in `RecordsAuditEvents::AUDIT_AS_RENAMES`.
575
+ - **Audit trail for housekeeping, and retention.** `DataSubjectErasure`, `ConsentWithdrawalHandler` and `ExpiredAssignmentCleaner` take `actor:` and `reason:` and write one summary event (`erasure.performed`, `consent.withdrawn`, `cleanup.expired_assignments`) next to the per-row revocations; erasure also clears the person's name from existing events in every tenant (ids stay). New `Services::AuditEventPurger` and `rake permission_forge:audit_events:purge` (`DRY_RUN=1`, `ACTOR=`) delete events older than `audit_retention_period` in batches, each batch with its own `audit.purged` event in one transaction (a batch whose event cannot be written is not deleted; `audit.purged` events are never purged; no route or API reaches the purge). Rake tasks take `ACTOR=`/`REASON=`; report tasks record `report.generated`.
576
+ - **Runtime decision events.** Row denials and nil-user denials (`access.denied`), refused controller actions (`action.denied`), every way past the row-level rules (`rule.bypassed`: view_all, modify_all, nil_user, scope_suspended, auto_scope_opt_out, no_rules, guard_bypass — once per request under auto-scoping), Level 2 findings (`query.unscoped`), field reads (`field.access`, one per record read, listing hidden and masked field names, never values), dropped params (`params.filtered`), refused masked write-backs (`write.masked_skipped`) and permission hash rebuilds/invalidations are published as `"<event>.permission_forge"` notifications; denials are stored by default, the other groups per `config.audit_runtime_events` (`denied bypass field_read params_filtered cache`). Denials inside a transaction that rolls back are still recorded. `NotAuthorizedError` gains `#user`, `#record`, `#record_id`; `ActionNotAuthorizedError` gains `#user`, `#record`. `config.audit_log_subscriber = true` writes one JSON line per event (name labels left out, event `data` included). With nobody listening, attribute reads are not slower than before (measured against the previous commit in `docs/benchmarks.md`, "Audit events: attribute reads").
577
+ - **Audit log in the admin.** A new Audit log page lists every recorded event with filters (dates in the application's time zone, event, category, actor, person, Permission Set, action), page links and no cap, opens each event with who/where/why and its before and after, and exports the current filter as CSV or JSON with every column and a metadata block (exported at, time zone, by, filters, row count), CSV-injection safe and streamed in batches. The Permission Set and person audit tabs show real timelines; a "Who holds what" export lists every assignment with who granted it, its expiry and last review; review labels read "Reviewed by … on …" or "Never reviewed"; "Last edited by" comes from the latest change event; change forms take an optional reason; views of the log are hidden from lists unless asked for and always exported; Home warns when the audit log is not recording. The log, its exports, "Who holds what" and the audit tabs and CSVs are gated by `config.audit_log_authorization` (refusals recorded as `admin.auth_failed`, views and exports as `audit.viewed` / `audit.exported`); the "Last edited by" footer and the "Assigned by" labels are not. Settings: "Record who created and last edited".
578
+ - **Read-only audit API.** `GET /api/audit_events` and `/api/audit_events/:identifier` with filters (time range, event, category, actor, subject, Permission Set, record, correlation id), pagination, `config.audit_log_authorization` enforced through `Audit::LogAccess`. A `page` beyond 2,147,483,647 is clamped on every paginated API endpoint.
579
+ - **Security audit: audit-log check.** `rake permission_forge:audit` gains the `audit_trail` check (table missing, audit off, shared login, anonymous API client, unknown actors, self-granted access, runtime groups, failure strategies, retention). Boot warns for audit off and for a shared login; `audit.settings_observed` records audit-setting changes at boot (a toggle made at run time after boot is not observed).
580
+ - **Documentation:** `docs/audit-log.md` (what is recorded and what is not, configuration, failure strategies, exports, retention, protecting the table at database level, forwarding, clock synchronisation and the other host duties).
581
+ - **Controller actions as permissions.** Include `PermissionForge::Concerns::ControllerActionGuard` in a controller and its actions are checked against the current user's Permission Sets before they run: REST actions against the Resource's CRUD flags, other actions against the Resource's custom actions (`user.can?(:export, :Customer)`). A denied request raises `PermissionForge::ActionNotAuthorizedError` (with `resource`, `action`, `controller_path`, `reason`) — the guard never renders. Actions map to a Resource by convention (`customers` → `Customer`), by `permission_forge_actions resource:, actions:, rest_actions:` on the controller, or by `config.controller_action_resolver`. `ControllerActions::Authorizer.allowed?` answers the same question for hiding buttons. See `docs/controller-actions.md`.
582
+ - **Managed actions.** An action is *managed* once an active row for it exists in any active Permission Set; from then on only people whose sets permit it can run it. An action or Resource nobody manages follows `config.unconfigured_action_strategy` (`:allow` by default, or `:block`). `config.action_guard_bypass` skips the guard for chosen people; with no current user the guard follows `nil_user_strategy`.
583
+ - **Admin: controller actions on the Actions tab** (`config.controller_action_discovery = true`). Lists the app's controller actions that map to the Resource, with their endpoints, whether the controller includes the guard, and what applies right now. Discovered actions are added per row or in bulk through *Review and save*, which states when adding makes an action managed for everyone. With discovery off the tab is unchanged.
584
+ - **Virtual fields are first-class in the admin.** *Add a resource* presets and *Sync with model* now create rows for a model's declared virtual fields, and an unconfigured virtual row says what applies to it right now.
585
+ - `config.extra_fields_default_access` — `:grant` (default, as before) or `:unconfigured`: a declared virtual field with no row gets no implicit access and follows `unconfigured_field_strategy`.
586
+ - `config.virtual_field_discovery` — the admin also lists attribute-API attributes and store accessors as virtual fields. Discovered names never get implicit access.
587
+ - `config.mask_virtual_attributes` (`false` / `:auto` / list of models) — model-level masking of virtual reader methods on top of column masking: a Hidden or Masked virtual field is hidden or masked when code calls the reader, including `as_json(methods:)`. `record.pf_without_virtual_masking { ... }` reads the real value inside the model. See `docs/masked-attributes.md`.
588
+ - Audits cover virtual fields: `GdprFieldAuditor` (new `:implicit_virtual_grant` finding), `MaskedAttributes.explain_all`, `rake permission_forge:audit` (`implicit_virtual_personal_data`).
589
+ - `rake permission_forge:audit` has a new `action` check: managed actions nobody is granted, managed actions with an endpoint on a controller that does not run the guard, action rows no controller action maps to, rows whose name is not a valid action key, and discovery without any guarded controller.
590
+ - `config.snapshot_transactions` (`:auto`, `true`, `false`) — whether a transaction is treated as reading from a snapshot. `:auto` decides from the adapter and the isolation level Rails opened the transaction with, without asking the server; set `true` for a PostgreSQL whose default isolation level was raised for the database or role, `false` for a MySQL at READ COMMITTED. Any other value raises `ConfigurationError`.
591
+ - `config.on_cache_failure` and the `cache_failure.permission_forge` notification (`operation:`, `user_id:`, `keys:`, `error:`).
592
+ - `User#invalidate_permissions` returns whether the cache store confirmed it. `PermissionForge::Cache::Failure.none? { … }` says the same for a block of code, whatever a host's override of that method returns; the services and admin screens use it: `DataSubjectErasure` (`cache_cleared`; `nil` when called inside a transaction, where it is only known at the commit), `ConsentWithdrawalHandler` (`invalidated`), the `invalidated` count of `BulkPermissionSetAssignment` and `ExpiredAssignmentCleaner`, and the People screens' "refresh cached permissions", "erase", "withdraw consent" and "remove all", which show an error when the store did not confirm. It sees failures reported during the block on the calling fiber: one reported later, by an `after_commit` of a transaction the host opened around the call, is not seen (it still reaches `on_cache_failure`).
593
+ - `PermissionForge::OpenTransaction.withholding` — for a host that writes a cache entry of its own derived from the permissions (a `regenerate_permissions` override): non-nil when the hash just built must not be shared.
594
+ - `PermissionForge::Cache::Base#expunge(key)` — remove a key and say whether it is certainly gone (custom backends inherit a default built on `delete`).
595
+ - `rake permission_forge:audit` lists rules in effect that match nothing because their value is not one their column can hold, or their field name is not a name (`unusable_rule_values`; medium when people hold the set, otherwise low). Run it after upgrading.
596
+ - The suite runs on MySQL: `DB=mysql BUNDLE_GEMFILE=gemfiles/mysql.gemfile bundle exec rake test` (trilogy; `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`).
597
+ - Install generator options `--user-id-type` and `--tenant-id-type` (`bigint`, `integer`, `uuid`, `string`), detected from the configured user model and the `--tenant` model when not given.
598
+ - `PermissionForge::Services::HostKeyGuard` — reading permissions or writing an assignment raises `ConfigurationError` when an integer column would have to hold a non-integer host key. It checks `user_id` and every tenant column, including the audit log's once `audit_events` exists (a truncated tenant id would show one tenant another's events). Checked at the point of use, not at boot, so `db:migrate` can still run.
599
+ - A fresh-install test: a child process boots a brand-new Rails app, runs the real install generator with default flags, the generated initializer and migration, and exercises the core contract on the stock `rails new` test settings (default prefix, `:null_store`), for integer and string user keys.
600
+
601
+ ### Changed
602
+
603
+ - **The "who holds what" export is streamed.** Assignments were all read into memory before the first byte was sent; they are now read in batches of 500 while the file is written, as the audit log export does. Output is the same. `row_count` is the count when the export starts: an assignment removed while the file is written is missing from it. The streamed body of both exports now runs with the action's tenant and with record rules suspended, as the action did (a host that set the tenant in its own `around_action` made the body raise or read the wrong tenant, and a record rule on the user model blanked the person columns).
604
+ - **Audit closing fixes.**
605
+ - The association writers Rails generates for the gem's collections (`user.permission_sets.delete`, `set.user_ids = []`, `group.permission_sets = []`, `clear`, …) remove assignments, memberships and set links through callbacks: an event and a cache invalidation each. `clear` and `delete_all` without an argument now run record by record; an explicit `delete_all(:delete_all)` or a narrowed relation is still plain SQL and not recorded.
606
+ - Consent withdrawal is one transaction with its revocations and its `consent.withdrawn` summary.
607
+ - `field.access` names the fields read in the clear (`passed_fields`); a hidden virtual reader dropped from `as_json(methods:)` is listed; `record.attributes` with no user publishes the bypassed decisions and calls `on_field_filtered`.
608
+ - Audit sentences name sets, groups and people as they were at the event, and say when a revocation removed an expired assignment (the clean-up's reason defaults to `expired`).
609
+ - Every form that changes permission data takes an optional reason.
610
+ - New security finding `audit_anonymous_api`; `audit_unknown_actors` counts change events only; the other audit-trail findings say more precisely what is at risk.
611
+ - `audit_events = true` without the table is shown as a risk in Settings, Home and the log.
612
+ - The set and person CSVs are `no-store`, and `/permission_sets/:id.csv` no longer fails without `?tab=audit`.
613
+ - The upgrade's de-dup migration prints what it deleted.
614
+ - **Upgrading: the audit log.** Run `bin/rails generate permission_forge:upgrade --skip` (adds the migrations you do not have yet and leaves earlier ones alone; `--tenant-column=<column>` for tenant-scoped installs; a fresh `install` takes `--tenant=Model`) and `bin/rails db:migrate`; the upgrade also runs on installs that already have the resource-field unique index (every install from 0.11 on); the migration is irreversible and leaves existing assignments with no `reviewed_at`. Set `config.audit_actor_resolver` so events name a person. `rake permission_forge:audit` with `FAIL_ON=high` now also fails on an app that has not run the migration (`audit_table_missing`), on `audit_events = false` and on self-granted access; `FAIL_ON=medium` or `low` also on a default HTTP Basic install, and on an install with the API enabled (`audit_anonymous_api`), until `audit_actor_resolver` is set, on `unknown`-actor change events in the last 30 days and on `audit_change_failure_strategy` other than `:raise`; `FAIL_ON=low` also on `audit_hook_failure_strategy = :log`. See `docs/migration-guide.md`.
615
+ - **Staleness is measured from the last review.** An assignment is stale when it is permanent and its `reviewed_at` (else `created_at`) is older than `admin_stale_assignment_days`; editing an assignment is no longer a review, and existing rows are not backfilled. `StaleAssignmentAuditor` rows gain `last_reviewed_at` and `reviewed_by_id` and count `days_stale` from the review; `rake permission_forge:stale_assignments` prints days since the last review or grant.
616
+ - **A change fails when its audit event cannot be written** (`audit_change_failure_strategy = :raise`, the default); so does a view or export of the audit log. Choose `:report` or `:log` to let changes through; the security audit then reports `audit_changes_unrecorded`.
617
+ - **`config.audit_runtime_events` is stored frozen**: `config.audit_runtime_events << :bypass` raises; assign an array.
618
+ - **`on_rule_bypass` and `on_field_filtered` are subscribers of the new events.** Same signatures; their errors are no longer swallowed but follow `config.audit_hook_failure_strategy` (default `:report`: logged and handed to `Rails.error`; `:raise` lets the error replace the decision's outcome); `on_field_filtered` is called once per field decision, in the same order with the same arguments: as the decision is made when it is the only listener and `field_read` is not stored (as before), otherwise when the read returns.
619
+ - **The per-person and per-Permission-Set CSV exports (the records a person created or last edited) are no longer capped at 500 rows per type.**
620
+ - With defaults unchanged, apps whose models declare sensitive-looking virtual fields without rows will see them in the Home privacy audit, in *Sync with model* ("N new"), and in `rake permission_forge:audit`. **`rake permission_forge:audit_masks` now exits 1 for them** — give the fields a row or set `extra_fields_default_access = :unconfigured`.
621
+ - `ResourceFieldGenerator` also creates rows for declared virtual fields when `extra_fields_default_access = :unconfigured`.
622
+ - **The JSON API rejects action names that are not valid keys** (lowercase letters, digits and `_`, starting with a letter, up to 63 characters) with a 422 on create and on rename — the rule the admin screens already applied. Existing rows are untouched and can still be updated, cloned, or renamed to a valid key.
623
+ - **Cache invalidation is coalesced per transaction.** Each permission record used to invalidate every affected person's cached hash in its own `after_commit` — once per record, per person. The records written in one transaction now share an invalidation batch: each person (per tenant) is invalidated once per commit, and the affected-user lookup is not repeated for records of the same Permission Set. Saving 20 records in a set assigned to 50 people goes from 1,000 `invalidate_permissions` calls and about 1,120 SQL statements to 50 calls and about 65 statements. `User#invalidate_permissions` is still the method called, once per person. The three callbacks `:invalidate_permissions_for_users`, `:invalidate_managed_actions` and `:invalidate_user_permissions` are now one `after_commit :invalidate_after_commit`; a host `skip_callback` on the old names raises. Writes that skip callbacks (`update_all`, `insert_all`) still need a manual `invalidate_permissions`, as before.
624
+ - Moving a `Resource` (or a field, action or rule) to another Permission Set also invalidates the people of the set it left.
625
+ - `FieldPermissionMerger` merges field rows only; the implicit entries for extra fields come from `Services::ExtraFieldDefaults` (called by `PermissionHashBuilder`). Code calling the merger directly no longer gets them.
626
+ - Locale keys `resource_actions.show.{permitted,not_permitted,blocked,unchanged}` moved to `resource_actions.states.*`. Host overrides of the old keys need renaming.
627
+ - `ResourceActionsController::KEY_FORMAT` moved to `PermissionForge::ControllerActions::KEY_FORMAT`.
628
+ - **Reading a column that was not loaded raises on a model with `MaskedAttributes`, as it does on any model.** On a record loaded with a partial `select` (`Customer.select(:id)`), a masked getter, `email?`, `slice`, `as_json(methods:)` and `read_attribute_for_serialization` returned `nil` or `false` where Rails raises `ActiveModel::MissingAttributeError`; they now raise. Code that leaned on that `nil` must select the column or rescue.
629
+ - `record[:id_value]` and `read_attribute(:id_value)` return the id on a model with `MaskedAttributes` (Rails' own alias of `id`); for a user under `:block` they returned the hidden value.
630
+ - Every class that declares an `alias_attribute` in or above a model with `MaskedAttributes` gets a `MaskedAliasAccessors` module in front of its generated attribute methods, including a parent or abstract base that does not have the concern. Records of classes without the concern behave as before; `instance_method(:alias).owner` is that module. On Rails 7.1 the deprecation "model aliases `x` and has a method called `x` defined" is no longer printed for such classes.
631
+ - After a rebuild the user object keeps the hash it built; it is not read back from the store (a rebuild is 3 store reads and 1 write, was 4 and 1). One invalidation is 2 writes and 2 deletes for a cached user and 3 writes and 2 deletes for an uncached one (was 1 write and 2 deletes): a delete that answers "nothing there" is followed by a one-second placeholder write, which is what tells an absent entry from a store that is down.
632
+ - `ControllerActions::ManagedIndex.invalidate!` checks the store's answers, tries every key, reports a removal it could not confirm, returns whether all were confirmed, and no longer raises.
633
+ - `PermissionForge.cache.clear` removes nothing on Solid Cache and Memcached (no `delete_matched`); the log message and docs now say so and name the alternatives (`docs/configuration.md`, "Cache Consistency").
634
+ - With Solid Cache on the application's own database connection (not a separate cache database), an `invalidate_permissions` inside a transaction holds a row until the commit, so another request of that user — or an admin's invalidation of them — waits for it.
635
+ - **Upgrading: rule values.** Rules are rows somebody typed, so check them with `rake permission_forge:audit` (`unusable_rule_values`).
636
+ - *Newly matches.* PostgreSQL (these raised): a decimal, a number past the column's range or scientific notation on an integer column; an integer-backed enum label; `current_user` on a string column. SQLite: boolean words other than `1`/`0`; enum labels; `T`-notation date-times; time columns; `%`, `_`, `\` in string operators.
637
+ - *Newly matches nothing, everywhere.* Text that is not a number or one of the boolean words `true t yes y on 1` / `false f no n off 0` (PostgreSQL also read prefixes such as `tr`); `0x1F`, `1_000`, `NaN`, `Infinity` and numbers past double range; an unknown enum label; `current_user` on a boolean or date column, or inside a list or range on a number column; `not_in` with an empty list; `between` without exactly two values; a field name with an empty part (`owner_id.`), which is also rejected on new rules and renames. On MySQL and SQLite only: a date or time that is not ISO 8601 — PostgreSQL still reads its own notations (`today`, `10.01.2026`) and raises on the rest, as before.
638
+ - *Returns other rows.* MySQL: `true`/`yes`/`on` matched the FALSE rows; an enum label matched the stored-0 rows; `current_user` on a string column compared numerically; decimal `in`/`not_in` compared as floats; a `tinyint(1)` column holding other numbers only matches boolean words. All databases: a string-backed enum label now means its stored value (when a label equals another label's stored value, the label wins); a date-time offset is applied (PostgreSQL ignored it on `timestamp without time zone`; MySQL used the session zone); `between '5,50'` on a string column is a text range. MySQL and SQLite: a date column compared with a date-time value compares the day.
639
+ - *Host operator strategies.* One registered under a built-in operator name receives Integer / BigDecimal / true / false / Date / Time / String, and an Array for `in`, `not_in`, `between`; it is not called when the value is unusable. A strategy under a name of its own gets the value as written (`current_user` replaced by the id).
640
+ - *RLS.* A rule with an unusable value now syncs (as `FALSE`) instead of failing the sync. A date PostgreSQL cannot read still fails the sync at that table and leaves the tables after it as they were. A word like `today` in a policy is read when the policy is created, not per query. `RlsOperatorSql` strategies and helpers take the typed value.
641
+
642
+ ### Fixed
643
+
644
+ - **The default install works.** The install migration prefixed foreign-key *columns* as well as tables (`permission_forge_permission_set_id`), while the models read `permission_set_id`, so an install with any non-empty table prefix — including the default — could not create or read permission data. Columns are never prefixed now; only table names and `to_table` are. Unprefixed installs generate the same migration as before. The `permission_forge:upgrade` de-duplication migration had the same mistake and is fixed too. `docs/table-prefix.md` has the column renames for a prefixed install made with 0.12.2 or earlier.
645
+ - **A cache that drops writes no longer switches row-level rules off.** After a rebuild the read-through re-read the store and fell back to `{}`. With `cache_backend = :null`, a `NullStore` behind `Rails.cache` (the stock Rails test environment) or a store outage, every `can_*?` was false but `permission_set_rules` was empty too, so `find_by`, `permission_scoped` and auto query scoping applied no rules. The freshly built hash is returned instead; `{}` remains only for the re-entrancy guard.
646
+ - **UUID and string keys no longer merge users.** `user_id`, `created_by_id` / `updated_by_id` and the `--tenant` column were always `bigint`; a UUID was stored as its leading digits with no error, so a user who was never assigned inherited the sets of any user sharing those digits (and UUID-keyed tenants would have shared permission data the same way). The install generator now types these columns from the user and tenant models' primary keys — the audit log's `reviewed_by_id`, `sensitive_acknowledged_by_id` and tenant column included (the upgrade copies the types of your existing columns) — and `BulkPermissionSetAssignment` no longer calls `to_i` on ids.
647
+ - **API `generate_fields` is authorized and honours locks.** `POST .../resources/:id/generate_fields` skipped `api_action_authorization`, `api_require_authorization` and the `is_editable` lock, so a caller the hooks would deny could grant read and edit on every unconfigured column. It now passes the hook (action `:generate_fields`) and returns 403 when the resource or its permission set is locked.
648
+ - **An edit by nobody, a Basic-auth login or an API client no longer blanks `updated_by_id`**; the previous editor is kept; `created_by_id` is stamped on create and `updated_by_id` on create and on each update, only from an actor that is a record of `audit_user_class`. The admin's "Last edited by" comes from the latest change event, because the row can pair a kept editor with a newer `updated_at`.
649
+ - **A switched-off field row is described as what it is.** The engine skips a row with `is_active: false`, so the field behaves as if that set had no row for it. The Home privacy card now says its setting is *turned off* (it said "Not configured"), the "in other sets" list of the field drawer and Field lookup marks a row that is not in effect — the row, its resource or its Permission Set is off — with an Off badge instead of showing its level as if in force, and the card notes that each line looks at one Permission Set on its own.
650
+ - **`GdprFieldAuditor` reads only rows in effect** (active row, active resource, active granting Permission Set — `ResourceField.in_effect`). A switched-off unmasked row was reported as `:unmasked_field` although masking it would do nothing, and a column whose only row was switched off, or sat in a switched-off resource, a switched-off set or a muting set, counted as mapped. Such a column is now reported once, as `:unmapped_column`. **`rake permission_forge:audit_masks` can newly exit 1, and `rake permission_forge:audit` can newly report a high finding** (under `unconfigured_field_strategy` `pass_through` / `warn`) for these columns; rows that are not in effect no longer produce `:unmasked_field` or `:cross_set_unmasking` findings.
651
+ - **Admin: the Fields tab's bulk "Set to" buttons did nothing with JavaScript on.** The script that opens *Review and save* in a dialog posted the form without the pressed button, so the chosen level was lost and the page answered "No changes to save". It now sends the button. Related fixes in the same script:
652
+ - "Select all shown fields" no longer ticks rows hidden by the search box, and a row that is filtered out leaves the selection.
653
+ - *Discard* also clears the bulk selection bar.
654
+ - Going *Back* from the review dialog keeps the leave-page warning armed.
655
+ - If the script's request fails, the retry is made by pressing the same button again, so it keeps the button's value and target (a failed "Check names" preview could fall back to posting the real assignment).
656
+ - **Admin: Enter in a text box no longer triggers the wrong button.** On the Resource page it submitted through the first bulk "Set to" button, proposing that level for the ticked rows; in the field drawer it submitted through "Apply recommendation" (changing the level at once and dropping the typed label) or "Explain". Both forms now have a plain default button, so Enter means review, or save.
657
+ - **Store accessors on a hidden or masked store column no longer crash or wipe the stored data.** Rails reads and writes a store (`store :settings, accessors: [...]`, `store_accessor`) through the column's getter, which `MaskedAttributes` masks. With the column hidden or masked for the current user, Rails saw `nil` where it expects the hash: on Rails 7.1–8.0 reading a key raised `NoMethodError`, and on every version Rails replaced the column with an empty hash in memory, so a later `save` wiped the stored data. Reading a key now returns the hidden value without touching the column, and writing a key works on the stored hash, so the other keys survive.
658
+ - Admin copy: the Albanian and Macedonian strings added in this release now use the same words as the rest of each file ("kontrollues", "e ndryshueshme", "картичка", "ја следи поставката …"), and the "add discovered actions" label carries its count in the singular form too — Macedonian uses the singular for 21, 31, 101.
659
+ - Admin: the default buttons added above stay rendered (clipped out of sight, out of the tab order) instead of `hidden` — Safari before 16.4 ignores a `display:none` button when Enter looks for a form's default button. The rule builder gets the same default button (Enter in the Name box re-rendered the builder and saved nothing), and a drawer opening moves focus to its first real control again. Verified in Firefox 155, WebKit 26.6 and Chromium 153, with and without JavaScript.
660
+ - **A resource can no longer get a second action row with the same name.** Two rows for one action are OR-ed when permissions are built, so a second row could permit what the first one blocks. Through the JSON API a duplicate raised `ActiveRecord::RecordNotUnique` (a 500) on tables that have the unique index and was accepted on tables that do not; it is now a 422 with the engine's translated message, on create, on rename and when a row is moved to another resource. Rows that already share a name stay editable: `rake permission_forge:audit` lists them (`duplicate_actions`, medium), and cloning a Permission Set copies them as the single row they amount to.
661
+ - **`rake permission_forge:audit_masks` prints every finding it counts.** The header counted five kinds of finding and only three had a line. View All on a resource with sensitive fields now prints as `[VIEW ALL]`, a field unmasked in one Permission Set and masked in another as `[OVERRIDES MASK]`; the three existing lines, the all-clear line and the exit status are unchanged (`GdprFieldAuditor::TextReport`). Both checks are also more exact, in `GdprFieldAuditor` and so in the `personal_data` check of `rake permission_forge:audit`: View All is reported only for a resource in effect in a granting set (it was also reported for a muting set, where `view_all` takes the bypass away, and for switched-off rows), and cross-set unmasking only when the same field of the same resource is masked in another granting set (it matched on the field name alone, across resources and inside a single set). **`audit_masks` can newly exit 0** where those were the only findings.
662
+ - `ResourceField` name uniqueness is checked the way the column's collation compares. The forced case-sensitive check let `Email` through beside `email` on MySQL's default (case-insensitive) collation, and the unique index then raised `ActiveRecord::RecordNotUnique`; it is now a validation error there. Nothing changes on PostgreSQL and SQLite, where the two stay separate fields.
663
+ - **A store accessor on a partially loaded record could wipe the stored hash.** On a record loaded without its store column, reading a store key and saving nulled the column, and writing a key replaced the whole hash, on a model with `MaskedAttributes`. The accessor is now left to Rails, which raises `ActiveModel::MissingAttributeError`; the stored hash is untouched.
664
+ - `RlsPolicyGenerator#digest` raised `ArgumentError` for two rules of one set that differ only in `include_nulls`.
665
+ - `DB=postgres bundle exec rake test` failed five `RlsEnforcementTest` cases that pass under `rake test:rls`: other test files define more protected models on the `customers` table, and the last one synced replaced `Customer`'s policies. The test now narrows the protected models to `Customer`. (The cause is a limit of its own: two protected models on one table share one set of policies.)
666
+
667
+ ### Upgrade notes
668
+
669
+ - An `api_action_authorization` lambda that switches on the action name must handle `:generate_fields`; anything it does not allow is now denied.
670
+ - Hosts with UUID or string user keys on a schema created by 0.12.2 or earlier get `ConfigurationError` on the first permission check. See "UUID or string keys" in `docs/migration-guide.md`.
671
+
672
+
673
+ ## [0.12.2] - 2026-10-04
674
+
675
+ ### Fixed
676
+
677
+ - **`config.tenant_column` is now honoured.** It was never passed to `acts_as_tenant`, so a custom column scoped queries on the inferred `<model>_id` column while cache invalidation read the configured one. `HasTenantScoping` now declares the association with `foreign_key: config.resolved_tenant_column` and `class_name: config.tenant_model`.
678
+ - **Namespaced tenant models** (`config.tenant_model = 'Billing::Account'`, `--tenant=Billing::Account`) produced an invalid association name and a `billing/account_id` column. Both now use the demodulized name (`:account`, `account_id`).
679
+ - **Boot now fails when `tenant_model` is set but the `acts_as_tenant` gem is not loaded** (`PermissionForge::ConfigurationError`). Previously the tenant scope was silently skipped and every tenant's permission data was visible to every other tenant.
680
+ - `apply_tenant_scoping!` is idempotent — a second application no longer adds a second tenant default scope.
681
+ - Configuration docs claimed rows with a NULL tenant column were visible to all tenants; acts_as_tenant hides them under every tenant. The comment and `docs/multi-tenancy.md` now say so, and document the tenant-less (job/console) behaviour.
682
+
683
+ ### Added
684
+
685
+ - The install migration indexes the tenant column on all 8 tables, and `rails generate permission_forge:install --tenant=Account` writes `config.tenant_model = 'Account'` into the generated initializer.
686
+ - Tenancy test suite (`test/permission_forge/tenancy/`): row isolation across all 8 models, per-tenant cache invalidation including changes made with no tenant set, `PermissionScopedFindable` on a tenant-scoped host model in both declaration orders, API and admin UI isolation, and the configuration/generator surface.
687
+
688
+ ## [0.12.1] - 2026-10-03
689
+
690
+ ### Added
691
+
692
+ - **`rake permission_forge:audit`** — a read-only security review, safe to run in production (`kamal app exec -i "bin/rails permission_forge:audit"`). Reports findings from Critical to Info across configuration (admin UI with no authentication, process-local cache in production, fail-open flags, unguarded API mutations), privileged access (View All / Modify All, temporary-looking sets, broad delete), assignments (stale, deleted or deactivated users, expired rows, people with 3+ sets), record rules, data integrity (duplicate field rows, dependency violations, unknown models) and personal data. `FORMAT=json` for machine output, `FAIL_ON=high` to fail CI. In code: `PermissionForge::Services::SecurityAudit.call`. See `docs/security-audit.md`.
693
+ - **Admin Home: "Record rules not applied everywhere"** — lists active rules the app's query scoping won't apply to every query (all of them while `auto_query_scoping` is off; join rules when it's on). The Rules tab shows the same warning as a banner.
694
+ - `PermissionSetRule#join_rule?` / `.join_type_rules` and `Resource#rules_bypassed?`.
695
+
696
+ ### Fixed
697
+
698
+ - **`permission_forge:upgrade` now uses the app's table prefix.** It defaulted to `permission_forge_`, so apps on another prefix (e.g. `""`) got migrations for tables that don't exist unless they passed `--table-prefix`. The default is now the configured `config.table_name_prefix`; the flag still overrides it. If you generated with the wrong prefix, delete the unapplied migration files and run the generator again.
699
+ - The Rules tab's empty state said nobody with the set sees any records. No rules means records aren't limited; it now says so.
700
+ - The admin classified rules with a join type but no relationship path as filter rules, while auto-scoping skips them. Both now decide by `join_type`.
701
+
702
+ ## [0.12.0] - 2026-10-03
703
+
704
+ ### Admin UI — redesigned
705
+
706
+ The built-in admin is rebuilt around the owner's questions ("who can see and do what, and why?"). **The HTML routes changed** (the JSON API is untouched); see `docs/admin-ui.md`.
707
+
708
+ - **New information architecture:** Home · People · Permission Sets · Permission Set Groups · Settings. Resources are edited *inside* their Permission Set (the old top-level Resources list, which showed the same model once per set, is gone).
709
+ - **Home — Needs attention:** privacy audit (sensitive fields shown unmasked, sensitive columns not configured while `unconfigured_field_strategy` lets them through) with *Fix* and *This is intended*; access ending this week (*Extend 30 days*); expired-assignment cleanup with a dry run; stale assignments (*Keep* / *Remove*; staleness measured like `StaleAssignmentAuditor`); sets to tidy up. All-good and first-run states.
710
+ - **People and Person:** search and filters; per-person *Access* (end dates, keep, remove, undo), **Effective access** with provenance (which set granted or muted every flag, field, action and record rule) via `PermissionForge::Admin::EffectiveAccess`, parity-tested against `PermissionHashBuilder`; *Audit* (access received, changes made, CSV export). Person actions: remove all access, withdraw consent (dry run), GDPR Art. 17 erasure (dry run + typed confirmation), refresh cached permissions.
711
+ - **Permission Set editor:** granting vs **muting** chosen at creation (and switchable behind an inversion warning); start from a copy; Resources matrix with dependency-aware CRUD cells; batched edits confirmed in **Review and save** (lists every change, cascades and affected people; one transaction); Assignments with bulk assign (paste emails/names) and bulk revoke; Conditions tab (IPv4/IPv6/CIDR validation per line, overnight hours, session flag); Audit tab with filters and CSV; deep **Duplicate** that unlocks the copy.
712
+ - **Resource page:** CRUD + View All / Modify All, **Sync fields with model** (new columns, missing columns, ignored columns), Fields with a 4-level control (Hidden / Masked / Read-only / Editable; Unchanged / Force mask / Hide in muting sets), filters, search and bulk edit; **Actions** (previously no HTML UI); Rules.
713
+ - **Field drawer:** mask strategy with live preview (real `MaskValue`, custom strategies included), privacy recommendation, linked variants, virtual fields, rules using the field, the field **across all Permission Sets**, *Explain for a person*. **Field lookup** ("Who can see iban?").
714
+ - **Rule builder:** sentence-style, with field and relationship pickers from model reflection (no free text), 15 operators in plain words, "the current user", include-empty (only for operators it affects), blocked message, join type under *Advanced*, live preview.
715
+ - **Settings (read-only):** every option with plain-language copy, current vs default, risk/secure markers, health summary, the 3-level query-scoping ladder, and a copy-paste "How to change" snippet. Secrets are only shown as set/not set; lambdas only as default/custom/not set.
716
+ - **Audit:** created-by / last-edited-by footers on every record; person and set audit tabs; a clearly marked "coming soon" change-timeline preview (needs a future audit-log table).
717
+ - **Brand:** new logo, favicon and touch icon in the admin; README header uses the new light/dark logo.
718
+
719
+ ### Admin UI — zero host dependencies
720
+
721
+ - CSS/JS/brand are served by the engine (`PermissionForge::AssetsController`, fingerprinted, cached forever). The admin no longer registers `permission_forge/application.css` with Sprockets/Propshaft; `app/assets` was removed.
722
+ - Everything is namespaced: `pf-*` classes, `data-pf-*` attributes, `pf_*` cookies scoped to the mount path, `pf_*` helpers. No inline scripts, styles or handlers (strict-CSP safe; script tag carries the CSP nonce). Every flow works without JavaScript.
723
+ - Light / dark / auto themes; English, Albanian, Macedonian. System font stack only (no third-party fonts).
724
+ - Host theming hook: override `permission_forge/shared/_head_extra` to add a stylesheet that re-defines `--pf-*` tokens.
725
+
726
+ ### Added
727
+
728
+ - `config.admin_model_resolver`, `config.admin_session_flags`, `config.admin_stale_assignment_days`, `config.admin_app_name`, `config.admin_back_url` (the "← Back to <app>" link under the logo), `config.admin_docs_url` (admin UI only).
729
+ - People search and bulk name matching also use `firstname`/`lastname`/`username`/`login` columns, and multi-word queries match across split name columns ("ana kras" finds Ana Krasniqi whether the app stores `name` or first/last names). Labels compose `firstname`/`lastname` when there is no `name`.
730
+ - `resource_fields.sensitive_acknowledged_at` (nullable) for the privacy audit's *This is intended*. New installs get it; existing installs: `bin/rails generate permission_forge:upgrade && bin/rails db:migrate`. The UI hides the button until the column exists.
731
+ - `PermissionForge::Admin::*` read models/services behind the screens: `EffectiveAccess`, `ChangeSet`, `AttentionItems`, `SettingsCatalog`, `ModelCatalog`, `FieldRows`, `RuleForm`, `AuditTrail`, `PeopleDirectory`, `PersonLabel`, `SensitiveColumns`, `ExpiryPresets`, `BulkPeopleMatcher`, `FieldLevel`.
732
+ - `docs/configuration.md` gains an *Option reference* with one anchor per option (the Settings page links to them).
733
+
734
+ ### Removed
735
+
736
+ - Old HTML admin controllers/views (`DashboardController`, top-level `ResourcesController` routes, `PermissionSetUsersController`, the `HtmlCrudActions` concern) and the asset-pipeline stylesheet. Hosts that overrode old engine views should re-check overrides against the new view paths.
737
+
738
+ ### Noted (engine behaviour, unchanged)
739
+
740
+ - Field `mask_access` is AND-merged across every granting set that configures the field, so a set that lists a field as **Hidden** (read off, mask off) still counts towards *unmasking* it for people who also have a set that reads it masked. The Effective access screen shows the real outcome.
741
+ - `user_active_check` is not applied when building permissions (only `PermissionSetUser#deactivated?` uses it).
742
+
743
+ ## [0.11.0] - 2026-07-28
744
+
745
+ ### Performance — fixed
746
+
747
+ - **`permissions_hash` is now memoized per user instance** (retired by a process-wide version bump on any `invalidate_permissions` / `regenerate_permissions` / cache-store swap, by `#reload`, and by a tenant cache-key change). Previously EVERY call — and MaskedAttributes triggers one per attribute read — paid a full cache-store round-trip plus deserialization of the entire hash; on a host with a multi-MB hash and a store-backed cache this turned an admin show page rendering a user dropdown into a 40-second, GC-thrashing request. The memoized hash is a shared object: **treat it as read-only** and deep-dup before mutating.
748
+ - **`pf_raw_user_attribute` now reads real columns via `read_attribute_before_type_cast` FIRST**, falling back to the public reader only for virtual attributes or blank columns. Dropdown labels no longer route through the AttributeAccessGuard per attribute per user, and can no longer show masked/hidden sentinels (the dropdown is documented masking-exempt admin infrastructure). Note: a host method overriding a *populated* column is no longer consulted for labels — use `config.user_label_formatter` for custom label logic.
749
+ - **`DataSubjectErasure` invalidates through `invalidate_permissions`** (all cache keys + memo version bump) instead of a raw single-key cache delete, so no live instance can keep serving an erased user's permissions from its memo.
750
+
751
+ ### Added
752
+
753
+ - **`config.user_list_limit` (default 500)** — hard cap on the admin UI assignment dropdowns, applied after excluding already-assigned users. Without a `user_list_resolver` the dropdown falls back to the host's ENTIRE users table; the cap stops that from rendering six-figure `<option>` lists. A resolver relation carrying its own smaller LIMIT is preserved; `nil` disables. When the cap trims the list the dropdown renders a localized hint (new i18n key `permission_forge.shared.user_select_truncated` in en/sq/mk). **Behavior change for hosts with >500 users and no `user_list_resolver`** — configure a resolver to control which users appear.
754
+
755
+ - **Three-level configurable query scoping** (all default OFF — zero behavior change unless enabled; see `docs/query-scoping.md`):
756
+ - **Level 1 — ENFORCE (`config.auto_query_scoping = :filter_only`).** A permanent `default_scope` on protected models applies the current user's filter-type rules to every relation (`where`/`all`/`find`/`first`/`count`/`pluck`/association traversal). Join-type rules still require `permission_scoped` (boot warning fires when both are in play). view_all/modify_all bypasses; nil user follows `nil_user_strategy` (`:deny`/`:raise` fail closed to an empty relation). Tenant scoping and host `default_scope`s are preserved in both declaration orders. Per-model opt-out via `self.permission_forge_auto_scope = false`.
757
+ - **Level 2 — DETECT (`config.unscoped_query_detection = :log | :raise`).** A `sql.active_record` subscriber flags SELECT/UPDATE/DELETE statements on protected tables that bypassed a permission-scoped path (scoped paths carry a `/* pf_scoped */` annotation). Environment-gated via `unscoped_query_detection_environments` (default dev/test); `:raise` surfaces `PermissionForge::UnscopedQueryError` at the offending call site. With Level 1 on, detection flags exactly the true escapes: `unscoped`, `find_by_sql`, raw SQL.
758
+ - **Level 3 — Postgres Row-Level Security (`config.database_row_level_security`).** `bin/rails generate permission_forge:rls` installs policies compiled from filter-type rules with *dynamic membership* (assign/revoke needs no resync; only rule-content edits do — `rake permission_forge:rls:sync`, drift detected via policy-comment digest + boot warning + `rls:status`). Runtime plumbing: `PermissionForge::RLS.with_user(user) { }` (transaction-local) and `set_user!`/`clear_user!` for around_action. Enforced below ActiveRecord: `unscoped` and raw SQL are filtered by the database. Muting sets cancel view_all bypasses dynamically; missing session variable follows `nil_user_strategy`; **only unconditional sets grant the DB-level view_all bypass** (a consent/IP/hours-gated set granting view_all never widens RLS access while its condition can't be evaluated in SQL — found by GDPR audit, enforcement-tested).
759
+ - `PermissionForge.without_permission_scope { }` — nesting-safe escape hatch suspending Levels 1–2 for intentional unscoped access.
760
+ - New Postgres CI job + `rake test:rls` enforcement suite (runs as a non-superuser role; skips cleanly without Postgres).
761
+ - Boot warnings: join-type rules under Level 1, `:raise` detection configured for production, RLS enabled on a non-Postgres adapter, RLS policy drift.
762
+
763
+ ### Fixed
764
+
765
+ - **`equal`/`is_null` rule predicates no longer crash `Model.new`/`create` under a `default_scope`.** Operator predicates built on SQL-literal columns are now wrapped in `Arel::Nodes::Grouping`, so Rails' `scope_for_create` attribute inference skips them instead of raising `NoMethodError` (and rule values can never leak into new-record attributes).
766
+ - **The install migration template now adds the tenant column to all 8 tables** when `--tenant` is given — previously only `permission_set_groups` got it, so a multi-tenant install would fail `acts_as_tenant` scoping on the other 7 models. Single-tenant installs are unaffected (no tenant column is rendered without `--tenant`).
767
+ - **The HTML admin UI now enforces the `is_editable` lock.** Update/destroy of a record flagged `is_editable: false` redirects with a flash alert instead of mutating, matching the API's 403 behavior. New i18n key `permission_forge.flash.not_editable` (en/sq/mk).
768
+ - The boot warning for missing HTTP Basic Auth credentials now names the real config option (`http_basic_auth_user`, previously the nonexistent `http_basic_auth_username`).
769
+
770
+ ### Changed (behavior)
771
+ - **Service result keys renamed `regenerated:` → `invalidated:`** in `ExpiredAssignmentCleaner`, `BulkPermissionSetAssignment`, and `ConsentWithdrawalHandler` — the counters have counted cache invalidations (lazy rebuild) since 0.10.0, and the old name was misleading. The API bulk-assignment response (`POST .../users/bulk`) returns `invalidated` accordingly, and the `permission_forge:cleanup_expired_assignments` rake output wording matches.
772
+
773
+ ### Internal
774
+ - Test suite grown from 899 to 1090 tests (HTML admin controllers, application helper, response renderer, MaskedAttributes diagnostics/write-guard edges, engine boot warnings, install migration template, permissions_hash memoization, dropdown cap). All gates green: RuboCop, Reek, i18n-tasks health.
775
+
776
+ ## [0.10.0] - 2026-07-14
777
+
778
+ Security & correctness hardening release. **Contains behavior changes** (all pre-1.0, documented below). No changes to the cached permission-hash public structure.
779
+
780
+ ### Security — fixed
781
+
782
+ - **Revocation now takes effect immediately (critical).** Removing a user's permission-set assignment — via the admin UI, the JSON API single-user endpoint, or cascading `PermissionSet#destroy` — previously left the user's cached permissions live until an unrelated change or the expiry cron ran (with the old `cache_ttl: nil`, that was indefinitely). `PermissionSetUser` now invalidates the affected user's cache on create/update/destroy, so `PermissionSet#destroy` invalidates every previously-assigned user through the `dependent: :destroy` cascade.
783
+ - **Read path now fails closed.** `FiltersResponseAttributes` no longer returns raw attributes when an authenticated user has no configured access to a resource — every field is routed through `unconfigured_field_strategy` (default `:block`). `masked_field?` reports unconfigured fields as masked under `:block`/`:mask_all`. Unknown `unconfigured_field_strategy` values fall back to `:block`, never pass-through. New `response_filter_nil_user_strategy` (`:bypass` default / `:block`) controls the no-current-user case.
784
+ - **Cache miss is no longer fail-open.** `permissions_hash` is now read-through: a miss (eviction, TTL expiry, flush) rebuilds in the *current* request rather than silently returning `{}`.
785
+ - **Muting sets now revoke field variants.** A muting set revoking `user_id` (or `price_cents`) now also revokes its `user_uuid` (or `price`) variant — previously the variant stayed readable.
786
+ - **Row-level rule columns use real identifier quoting** (`quote_table_name` / `quote_column_name`) instead of the previous no-op `sanitize_sql`, hardening against a malformed `field_name` reaching SQL even if a rule bypassed model validation.
787
+ - **Masked values can no longer be persisted back.** New `masked_write_protection` (`:skip` default) prevents a callback that reads a masked/hidden value and writes it back from corrupting the stored column.
788
+ - **Activation conditions evaluate against the right principal.** Cross-user permission changes now *invalidate* affected users (rebuilt lazily in each user's own request) instead of rebuilding in the editing admin's request context. Conditional sets get a short cache TTL (`conditional_set_cache_ttl`, default 300s) so IP/hours changes go stale quickly.
789
+ - **API CSRF posture.** The API base controller now uses `protect_from_forgery with: :null_session` instead of unconditionally skipping forgery protection, neutralizing cross-site form POSTs against session-authenticated deployments while leaving token clients unaffected.
790
+ - **is_editable is enforced** on API create/update/destroy (records flagged `is_editable: false` return 403).
791
+ - **Authorization hook coverage.** `api_action_authorization` now gates create/update/destroy (not just clone); `api_assignment_authorization` now gates single-user revoke and group-user create/destroy.
792
+ - **No internal error leakage.** `generate_fields` (API + HTML) now logs the exception and returns a generic message instead of echoing `e.message`.
793
+ - **Resource#name is validated** as a Ruby constant path (it is resolved via `safe_constantize`).
794
+ - **HTML locale switcher** no longer reflects arbitrary query parameters through `url_for` (removes an off-site link-injection primitive).
795
+ - **Dependency CVEs patched**: puma, sqlite3, oj, nokogiri, net-imap, json, concurrent-ruby, crass, websocket-driver bumped in the dev lockfile; `bundle-audit` is now clean.
796
+ - **Accept-Language parsing** fixed to match bare tags (`en`) and always reset locale per request (was leaking across requests on reused threads).
797
+ - **`extra_fields` can no longer unmask a configured field.** A host-declared extra field whose name collides with a DB-configured `ResourceField` is now strictly additive — it no longer overwrites the configured entry's `mask_access`/`edit_access` (previously a name collision stripped masking and granted edit).
798
+ - **Config options are no longer frozen at gem-require time.** `config.user_class` (and `audit_user_class`) associations and the `audit_trail_enabled` toggle are now honored — associations are re-declared after host initializers load, and the audit callbacks check the toggle at runtime. Previously `user_class`/`audit_trail_enabled`/`audit_user_class` set in the initializer were silent no-ops.
799
+ - **Duplicate `ResourceField` rows are blocked** — a unique index on `(resource_id, name)` plus a model validation (a duplicate row with `mask_access: false` could otherwise silently unmask the field via mask AND-logic). Companion uniqueness validations added on `PermissionSetUser`/`PermissionSetGroupUser` so a repeat assignment returns 422 instead of a `RecordNotUnique` 500. Run `bin/rails generate permission_forge:upgrade` to add the index (and de-dup) on existing installs.
800
+ - **Masked write-back protection now survives a request boundary.** The guard additionally compares an incoming write to the mask of the *currently-persisted* value, so the classic form roundtrip (render `********`, submit it back on a freshly-loaded record) no longer overwrites the stored value — the previous ledger only covered same-instance read-modify-write.
801
+ - **`find_by` honors its nil contract.** A genuinely missing record returns `nil` (matching stock Rails) instead of raising `NotAuthorizedError`; the error is now raised only when the record exists but a rule denies it.
802
+ - **`find_by` no longer resurrects default-scoped rows.** The internal fallback uses `where(nil)` instead of `unscoped`, preserving a host model's `default_scope` (e.g. soft-delete) and the tenant scope.
803
+
804
+ ### Added
805
+
806
+ - `Model.permission_scoped` — a rule-constrained, chainable relation (`.where`, `.first`, `.find`, `.count`), plus a `find_by!` override, extending row-level enforcement beyond `find_by`.
807
+ - `User#invalidate_permissions(tenant_id:)` — deletes a user's cached hash so the next read rebuilds it.
808
+ - Config: `conditional_set_cache_ttl`, `response_filter_nil_user_strategy`, `masked_write_protection`.
809
+ - **`config.conditional_sets_strict_mode`** (default `false`) — when `true`, a permission set with activation conditions (session flag / `allowed_ips` / login hours) is treated as **inactive** when `request_context_resolver` returns a blank context, so an unwired resolver can no longer silently leave IP/hours/consent restrictions unenforced. A boot warning fires when conditional sets exist without a context resolver. **Default flips to `true` in the next major.**
810
+ - **`config.api_require_authorization`** (default `false`) — when `true`, API mutation and assignment endpoints deny (403) if `api_action_authorization` / `api_assignment_authorization` is unset, closing the "`api_auth` proves identity but not admin-ness" escalation. A boot warning fires when the API is enabled without both authorization lambdas. **Default flips to `true` in the next major.**
811
+ - `bin/rails generate permission_forge:upgrade` — migration generator that de-dups and adds the `resource_fields` unique index for pre-existing installs.
812
+ - `Configuration#field_variants_for` — shared variant computation used by the granting and muting phases.
813
+ - CI: dedicated `security` job running Brakeman and bundler-audit.
814
+ - CI: the Rails matrix now actually exercises each version via per-Rails gemfiles (`gemfiles/rails_*.gemfile` selected through `BUNDLE_GEMFILE`) — previously every cell installed the same latest Rails. The dummy test app now eager-loads so the admin-UI controllers/views are covered.
815
+ - The install initializer template now documents admin-UI Basic Auth, activation-condition context, and the two new secure-default flags.
816
+
817
+ ### Changed (behavior)
818
+
819
+ - **`cache_ttl` now defaults to `3600`** (was `nil`/never-expire) as a staleness backstop; the effective TTL is clamped to `conditional_set_cache_ttl` for conditional sets and to the soonest assignment `expires_at`.
820
+ - Cross-user permission changes now **invalidate** caches (lazy rebuild) rather than eagerly rebuilding; deactivated users are invalidated too (fixes stale cache on reactivation).
821
+ - Permission sets are merged in a deterministic order (`created_at, id`) so "first `mask_strategy` wins" is stable.
822
+ - `PermitsAttributes` is now non-mutating (returns a new hash) and filters nested `*_attributes` against the association's own resource when configured (falling back to the parent otherwise).
823
+ - Row-level `field_value` is used verbatim — the undocumented `_id` → integer coercion was removed (`'current_user'` substitution is unchanged).
824
+
825
+ ### Internal
826
+
827
+ - `PermissionRuleApplier` happy path is now a single existence check (per-rule queries only run when access is denied).
828
+ - `MemoryCache` is mutex-guarded and dups on write; `RailsCache#clear` logs loudly when the store can't honor a bulk clear.
829
+ - All gates green: 899 tests / 0 failures, RuboCop 0 offenses, Reek 0 warnings, Brakeman 0 warnings, bundler-audit clean, i18n-tasks health perfect across en/sq/mk.
830
+
831
+ ## [0.9.0] - 2026-05-03
832
+
833
+ ### Added
834
+ - **MaskedAttributes concern** — model-level field masking that auto-applies to managed models. Direct AR readers (e.g. `customer.email`) now route through PermissionForge's masking logic instead of bypassing it via AR's generated attribute methods.
835
+ - New `MaskedAttributesSettings` configuration module
836
+ - `auto_apply_masked_attributes` config — auto-applies the concern to every model with a matching `Resource` row
837
+ - `omit_hidden_in_attributes_hash` and `hidden_attribute_value` config options
838
+ - Diagnostic helpers: `MaskedAttributes.explain(record, attr)` and `.explain_all(record)` for debugging field decisions
839
+ - **AttributeAccessGuard service** — centralizes per-attribute access decisions (passed/masked/hidden/unconfigured/bypassed) shared by `MaskedAttributes` reads and `FiltersResponseAttributes`
840
+ - **User list resolver + label formatter** — `config.user_list_resolver` and `config.user_label_formatter` lambdas for customizing the admin UI's user-assignment dropdown
841
+ - **Search in resources index** — search input with clear-query support
842
+ - **Assignment expiration UI** — datetime picker on the user assignment form
843
+ - **`PermissionForge::BUNDLED_LOCALES`** — canonical fallback for the locale dropdown when `config.available_locales` is unset
844
+
845
+ ### Fixed
846
+ - Locale fallback now uses `BUNDLED_LOCALES` (`[:en, :sq, :mk]`) instead of `I18n.available_locales`, so the admin UI dropdown only shows locales the gem actually supports (no more host-app/Faker pollution)
847
+ - `module_eval` for per-column attribute readers replaced with `define_method` — safer code generation, brakeman-clean
848
+ - Two pre-existing test bugs in `assert_raises(Class, /regex/)` calls (invalid Minitest API)
849
+
850
+ ### Internal
851
+ - All static analysis clean: rubocop 0 offenses, reek 0 warnings, brakeman 0 warnings
852
+ - Extracted `pf_compose_name` and `pf_format_name_and_email` helpers from `pf_default_user_label`
853
+ - `MaskedAttributes#guard_attributes_hash` refactored to `filter_map { ... }.to_h`
854
+ - `MaskedAttributes.decide` converted to keyword args
855
+
856
+ ## [0.8.0] - 2026-04-28
857
+
858
+ ### Added
859
+ - **Rails 8.1 support** — gemspec dependency upper bound raised to `< 8.2` for `activerecord`, `activesupport`, and `railties`. Tested against Rails 8.1.3 and Ruby 3.4.5 / 3.4.7.
860
+ - CI matrix now includes Rails 8.1 alongside 7.1, 7.2, and 8.0.
861
+
862
+ ## [0.7.0] - 2026-03-24
863
+
864
+ ### Added
865
+ - **Multi-Tenancy (acts_as_tenant)** — optional integration with acts_as_tenant for full tenant isolation
866
+ - New `config.tenant_model` / `config.tenant_column` configuration (nil = disabled, fully backward compatible)
867
+ - New `HasTenantScoping` concern — conditionally applies `acts_as_tenant` to all 8 models
868
+ - Engine initializer applies tenant scoping after configuration is loaded
869
+ - Tenant-aware cache key: `tenant_#{id}_user_#{id}` prevents cross-tenant cache collisions
870
+ - `PermissionScopedFindable` uses `tenant_safe_find_by` to preserve tenant scope in fallback queries
871
+ - Migration generator accepts `--tenant=Account` to add tenant column to all tables
872
+ - Cross-tenant user sharing: same user, different permissions per tenant
873
+ - All services (DashboardStats, ExpiredAssignmentCleaner, GdprFieldAuditor, etc.) auto-scoped via default scope
874
+ - New doc: `docs/multi-tenancy.md`
875
+ - **Activation Conditions** — unified conditional activation system for permission sets (Salesforce-inspired)
876
+ - **Session-Based Permissions**: `requires_session_flag` — set only activates when session flag is present (e.g., after MFA step-up)
877
+ - **IP Range Restrictions**: `allowed_ips` — comma-separated CIDR ranges, set only activates from matching IPs
878
+ - **Login Hour Restrictions**: `allowed_hours_start` / `allowed_hours_end` — set only activates during allowed hours (supports midnight wrap)
879
+ - New `PermissionSetConditionEvaluator` service — evaluates all conditions against a request context
880
+ - New `config.request_context_resolver` lambda — provides `{ ip:, session_flags:, time: }` context at regeneration time
881
+ - `PermissionCacheAccessor` filters conditional sets through evaluator when context is available
882
+ - `conditional?` / `unconditional?` instance methods and `.conditional` / `.unconditional` scopes
883
+ - Hour validations (0-23 range)
884
+ - Admin UI: activation conditions fieldset on permission set form
885
+ - Admin API: all condition columns exposed in serializer and accepted in params
886
+ - i18n: condition labels/hints/placeholders added to all 3 locale files
887
+ - New doc: `docs/activation-conditions.md`
888
+ - Fully backward compatible — default resolver returns `{}`, no filtering occurs
889
+ - **Assignment Expiration** — time-bound permission set assignments that auto-expire (Salesforce-inspired)
890
+ - New `expires_at` datetime column on `permission_set_users` table (nullable — nil = never expires)
891
+ - `PermissionCacheAccessor` filters expired assignments before building permission hash
892
+ - `ExpiredAssignmentCleaner` service — removes expired records + regenerates permissions (supports `dry_run:`)
893
+ - `.active_assignments` / `.expired` scopes on PermissionSetUser
894
+ - `expired?` / `active_assignment?` instance methods on PermissionSetUser
895
+ - `BulkPermissionSetAssignment` accepts optional `expires_at:` parameter
896
+ - Admin API: `expires_at` exposed in serializer, accepted in create/bulk params
897
+ - Admin UI: datetime picker on user assignment form, expiration status in users table
898
+ - i18n: expiration labels added to all 3 locale files (en, sq, mk)
899
+ - New doc: `docs/assignment-expiration.md`
900
+ - **Muting Permission Sets** — permission sets that subtract permissions instead of granting them (Salesforce-inspired)
901
+ - New `is_muting` boolean column on `permission_sets` table (default: `false`)
902
+ - `PermissionHashBuilder` partitions sets into granting/muting, applies granting merge first, then subtracts muting
903
+ - Muting revokes: resource CRUD, view_all/modify_all, field read/edit access, action is_permitted
904
+ - Muting forces: field `mask_access` to `true` when muting field has `mask_access: true`
905
+ - Muting does NOT add row-level rules (subtraction only)
906
+ - `muting?` / `granting?` instance methods on PermissionSet
907
+ - `.muting` / `.granting` scopes on PermissionSet
908
+ - `source_permission_sets` in cache hash now includes `is_muting` flag
909
+ - `ValidatesDependency` skips validation for muting sets (inverted semantics — `delete_access: true` means "revoke delete")
910
+ - Admin UI: muting checkbox on permission set form, muting badge on index/show
911
+ - Admin API: `is_muting` exposed in serializer, accepted in create/update params
912
+ - Deep clone preserves `is_muting` flag
913
+ - i18n: muting labels/hints added to all 3 locale files (en, sq, mk)
914
+ - New doc: `docs/muting-permission-sets.md`
915
+
916
+ ### Changed
917
+ - `PermissionHashBuilder#call` now partitions permission sets before processing (internal, no API change)
918
+ - `ValidatesDependency` now checks `belongs_to_muting_permission_set?` before enforcing dependency rules
919
+ - Migration template includes `is_muting` column on `permission_sets` table
920
+
921
+ ### Tests
922
+ - 55 new tests across 4 test files (model, builder, integration, API)
923
+ - Edge cases: inactive muting sets, inactive muting resources/fields/actions, multiple stacking muting sets, muting-only users, order independence, disjoint resources
924
+
925
+ ## [0.6.0] - 2026-03-24
926
+
927
+ ### Refactored (non-breaking)
928
+ - **PermissionedUser** split into 3 focused concerns: `PermissionCacheAccessor`, `ResourcePermissionChecker`, `FieldMaskChecker`. The original concern is preserved as a composition for backward compatibility.
929
+ - **PermissionScopedFindable** query logic extracted to `PermissionRuleApplier` service. Concern remains thin orchestrator.
930
+ - **PermissionHashBuilder** field logic extracted to `FieldPermissionMerger` service.
931
+ - **PermissionSet/PermissionSetGroup** `deep_clone` extracted to `PermissionSetCloner`/`PermissionSetGroupCloner` services.
932
+ - **Resource** `grant_full_access!` extracted to `ResourceWithFieldsBuilder` service.
933
+ - **FiltersResponseAttributes** unconfigured field strategies use registry pattern (extensible via `UNCONFIGURED_FIELD_STRATEGIES`).
934
+ - **MaskValue** supports custom strategies via `config.custom_mask_strategies` and `MaskValue.strategies`.
935
+ - **BulkPermissionSetAssignment** actions use strategy pattern via `ACTION_STRATEGIES`.
936
+ - **PermissionSetRule** field value validation delegated to `Operators::Validations`.
937
+ - **Api::BaseController** split into `ResponseRenderer`, `Pagination`, `SerializerResolver` concerns.
938
+ - **DashboardController** stats extracted to `DashboardStats` service.
939
+
940
+ ### Added
941
+ - `config.custom_mask_strategies` — register custom masking strategies without modifying MaskValue
942
+ - `PermissionForge::Operators::Validations` — extensible operator validation registry
943
+ - DIP: New services accept injectable dependencies (`current_user`, `configuration`, `operator_registry`) with sensible defaults
944
+
945
+ ### DRY improvements
946
+ - Extracted shared view partials: `_form_errors`, `_form_actions`, `_empty_state` — eliminates 13 duplicate blocks across forms and index views
947
+ - `HasActiveScope` concern — eliminates `scope :active` repeated in 6 models
948
+ - `Clonable` service concern — shares clone/duplicate logic between `PermissionSetCloner` and `PermissionSetGroupCloner`
949
+ - `NestedResourceLookup` API concern — shares `set_resource` and `find_nested_record` across 3 API controllers
950
+ - `HtmlCrudActions` concern — shares `crud_create`, `crud_update`, `crud_destroy` helpers across 5 HTML controllers
951
+
952
+ ### No breaking changes
953
+ - All public APIs preserved. Models delegate to services for backward compatibility.
954
+ - Permission hash structure unchanged.
955
+ - `NotAuthorizedError` namespace unchanged.
956
+
957
+ ## [0.5.0] - 2026-03-23
958
+
959
+ ### Added — Security Audit Remediation
960
+
961
+ **Phase 1: Critical Model Validations**
962
+ - **PermissionSetRule validations** — `operator` inclusion (15 operators), `field_value` presence (except is_null/is_not_null), `join_type` inclusion (3 types), `field_name` presence + format (`/\A[a-zA-Z_][a-zA-Z0-9_.]*\z/`), operator-specific validation (between requires 2 values, in/not_in requires 1+)
963
+ - **ResourceField mask_strategy validation** — when `mask_access: true`, `mask_strategy` must be present and one of `full`, `email`, `last4`, `first1_last4`
964
+ - **MaskValue strict mode** — raises `ArgumentError` for unknown strategies instead of silent fallback to `full`
965
+
966
+ **Phase 2: GDPR Configuration Defaults**
967
+ - **`gdpr_defaults` now defaults to `true`** — safe-by-default for new installations
968
+ - **`config.gdpr_require_explicit_choice`** — when `true`, raises `ConfigurationError` at boot if `gdpr_defaults` was never explicitly set
969
+ - **`grant_all: true` respects GDPR masks** — `grant_all` grants read/edit access but does NOT unmask GDPR fields
970
+ - **GDPR Masking Disclaimer** — new `docs/gdpr_masking_disclaimer.md` documenting that masking is display-level obfuscation, not GDPR anonymization
971
+
972
+ **Phase 3: Unconfigured Field Strategy & GdprFieldAuditor**
973
+ - **`config.unconfigured_field_strategy`** — choose how unmapped fields are handled in `FiltersResponseAttributes`: `:pass_through`, `:warn` (default), `:mask_all`, `:block`
974
+ - **GdprFieldAuditor extended** — now detects unmapped model columns (no ResourceField exists) matching GDPR patterns, in addition to existing unmasked field detection. Report items include `type: :unmasked_field` or `type: :unmapped_column`
975
+
976
+ **Phase 4: Production Safety & BETWEEN Coercion**
977
+ - **Production MemoryCache warning** — logs warning at boot when using in-process MemoryCache in production (permissions will be inconsistent across Puma workers)
978
+ - **BETWEEN type coercion** — auto-coerces integer, float, and date string pairs for the `between` operator
979
+
980
+ **Phase 5: API Hardening**
981
+ - **`config.api_stats_require_admin`** — when `true`, the `/api/stats` endpoint returns 403 unless `@admin = true` is set by the auth lambda
982
+ - **BulkPermissionSetAssignment nil safety** — gracefully handles `nil` action parameter
983
+
984
+ **Phase 6: GDPR Pattern Validation & Drift Protection**
985
+ - **GDPR pattern regex validation** — invalid regex patterns in `gdpr_field_patterns` raise `ConfigurationError` at config time
986
+ - **`rake permission_forge:audit_masks`** — CI-friendly rake task that exits non-zero when GDPR audit finds issues
987
+ - **Pattern drift detection** — `config.gdpr_patterns_changed_since_generation?` detects when patterns changed since last `ResourceFieldGenerator` run
988
+
989
+ **Phase 7: Low-Priority Hardening**
990
+ - **HTTP Basic Auth boot warning** — logs warning when HTTP Basic Auth is enabled but credentials are blank
991
+ - **PermissionSet name length validation** — `validates :name, length: { maximum: 255 }`
992
+ - **`PermissionSetRule.recently_deactivated` scope** — queryable scope for rules deactivated within the last 7 days
993
+ - **`config.on_field_filtered` audit hook** — optional lambda called for each field processed by `FiltersResponseAttributes` with `action: :hidden | :masked | :passed`. Errors in the hook are rescued and logged — never break filtering.
994
+
995
+ ### Changed
996
+ - **`gdpr_defaults` default: `false` → `true`** — existing apps that explicitly set `gdpr_defaults = false` are unaffected
997
+ - **`unconfigured_field_strategy` default: `:warn`** — previously unconfigured fields silently passed through (`:pass_through` behavior). Now logs a warning. Set to `:pass_through` to restore old behavior.
998
+ - **GdprFieldAuditor report format** — report items now include a `:type` key (`:unmasked_field` or `:unmapped_column`). Existing fields like `:field_name`, `:suggested_strategy` are preserved.
999
+ - **MaskValue load order** — `mask_value.rb` is now required before models (was after) to support `MaskValue::STRATEGIES` constant in ResourceField validation
1000
+
1001
+ ### Performance
1002
+ - **Zero measurable regression** on hot paths (cache lookups, read-path filtering, row-level rules)
1003
+ - Read-path: +2.7% on mixed access scenario (from audit hook nil-check overhead)
1004
+ - Cache lookups: unchanged (~0.026ms/call)
1005
+ - Row-level rules: unchanged (~0.28ms/rule)
1006
+ - Full benchmark comparison in [docs/benchmarks.md](docs/benchmarks.md#historical-v050-security-audit-impact)
1007
+
1008
+ ### New Config Options
1009
+
1010
+ | Option | Default | Description |
1011
+ |--------|---------|-------------|
1012
+ | `gdpr_require_explicit_choice` | `false` | Raise at boot if `gdpr_defaults` not explicitly set |
1013
+ | `unconfigured_field_strategy` | `:warn` | How to handle unmapped fields in read-path |
1014
+ | `on_field_filtered` | `nil` | Audit hook for field-level compliance logging |
1015
+ | `api_stats_require_admin` | `false` | Gate dashboard stats behind admin flag |
1016
+
1017
+ ### New Constants
1018
+
1019
+ | Constant | Location | Description |
1020
+ |----------|----------|-------------|
1021
+ | `PermissionSetRule::OPERATORS` | `Operators::Constants` | Frozen array of 15 valid operators |
1022
+ | `PermissionSetRule::JOIN_TYPES` | `Operators::Constants` | Frozen array of 3 valid join types |
1023
+ | `MaskValue::STRATEGIES` | `Services::MaskValue` | Frozen array of 4 valid mask strategies |
1024
+ | `BulkPermissionSetAssignment::VALID_ACTIONS` | `Services::BulkPermissionSetAssignment` | `%i[assign revoke]` |
1025
+
1026
+ ### Tests
1027
+ - **72 new tests** (446 → 518 total), 96.11% line coverage
1028
+ - New test files for all validation, configuration, and service changes
1029
+
1030
+ ## [0.4.0] - 2026-03-11
1031
+
1032
+ ### Added
1033
+ - **15 Operators** — expanded from 6 to 15 row-level rule operators
1034
+ - Comparison: `equal`, `not_equal`, `less_than`, `greater_than`, `less_than_or_equal`, `greater_than_or_equal`
1035
+ - Set: `in`, `not_in`
1036
+ - String: `contains`, `not_contains`, `starts_with`, `ends_with`
1037
+ - Range: `between`
1038
+ - Null: `is_null`, `is_not_null`
1039
+ - **ViewAll / ModifyAll** — resource-level permissions that bypass all row-level rules (Salesforce-inspired)
1040
+ - `can_view_all?` and `can_modify_all?` helpers on PermissionedUser
1041
+ - `PermissionScopedFindable` automatically skips rules when enabled
1042
+ - **Dependency Validations** — enforced at model level
1043
+ - `edit_access` requires `read_access` (on Resource and ResourceField)
1044
+ - `delete_access` requires `read_access` (on Resource)
1045
+ - `modify_all` requires `view_all` (on Resource)
1046
+ - `mask_access` requires `read_access` (on ResourceField)
1047
+ - **Bulk Assign/Revoke** — `BulkPermissionSetAssignment` service for batch user ↔ permission set operations with single cache regeneration pass
1048
+ - **YARD Documentation** — minimal `@param`, `@return`, `@example` docs on all public APIs (PermissionedUser, PermissionScopedFindable, all services, Operators::Registry)
1049
+
1050
+ ### Changed (Internal — no breaking changes)
1051
+ - **Strategy Pattern: Operators::Registry** — replaced 15-branch case/when in `PermissionScopedFindable` with frozen Hash registry of callable operator objects. O(1) lookup, open/closed for extension.
1052
+ - **HasPermissionSetUsers concern** — extracted duplicated `users_on_permission_set` logic from 5 models into a shared concern with 3 strategies (`:self`, `:direct`, `:via_resource`)
1053
+ - **ValidatesDependency concern** — extracted 5 identical validation methods across Resource and ResourceField into a declarative DSL (`validates_dependency :field, requires: :other`)
1054
+ - **CrudActions concern** — extracted duplicate CRUD methods from 4 API controllers into a Template Method concern (index/show/create/update/destroy)
1055
+
1056
+ ### Performance
1057
+ - Read-path overhead improved: ~0.05ms/record (was ~0.08ms)
1058
+ - Cache lookups improved: ~0.046ms/call (was ~0.07ms)
1059
+ - Write-path overhead: ~0.10ms/request (was ~0.15ms)
1060
+
1061
+ ### Fixed
1062
+ - **Resource `label` column** — added `label` field to resources table in install migration and dummy schema for human-readable resource names in the admin UI
1063
+ - **Resource uniqueness** — added `validates :name, uniqueness: { scope: :permission_set_id }` and a composite unique index (`permission_set_id`, `name`) to prevent duplicate resources within a permission set
1064
+ - **Auto-generate fields idempotent** — `ResourceFieldGenerator` now skips columns that already have a `ResourceField` record, preventing duplicates on repeated use
1065
+ - **Admin UI: Resource form** — added permission set dropdown to the new resource form, preventing `NOT NULL` constraint errors on `permission_set_id`
1066
+ - **Admin UI: User assignment** — `PermissionSetUsersController#create` now uses `find_by` instead of `find`, returning a translated flash error instead of crashing with `RecordNotFound` when the user doesn't exist
1067
+ - **Admin UI: Resource create** — added `NotNullViolation` rescue in `ResourcesController#create` so missing `permission_set_id` renders the form with validation errors instead of a 500
1068
+ - **Admin UI: i18n completeness** — added `permission_set_label`, `permission_set_prompt`, and `user_not_found` i18n keys to all 6 locales
1069
+ - **Security: Brakeman SQL injection** — rewrote all 15 operators in `Operators::Registry` from raw SQL string interpolation to Arel node methods (`.eq()`, `.lt()`, `.matches()`, etc.), eliminating all 19 Brakeman warnings
1070
+
1071
+ ## [0.3.0] - 2026-03-10
1072
+
1073
+ ### Added
1074
+ - **Admin JSON API** — built-in RESTful JSON endpoints for managing permissions programmatically
1075
+ - 9 API controllers under `PermissionForge::Api::` namespace
1076
+ - Full CRUD for permission sets, resources, fields, actions, rules, groups
1077
+ - Clone permission set, auto-generate fields, dashboard stats
1078
+ - Lightweight built-in serializers with AMS override support
1079
+ - Configurable pagination, record identifier, and authentication
1080
+ - **API Authentication** — 3-tier: lambda auth, base controller inheritance, or locked-by-default
1081
+ - **Configuration** — `api_enabled`, `api_auth`, `api_base_controller_class`, `api_pagination_handler`, `api_record_identifier`, `api_serializer_overrides`
1082
+
1083
+ ### Fixed
1084
+ - **HTML Admin UI** — added `mask_access` checkbox and `mask_strategy` dropdown to resource fields form
1085
+
1086
+ ## [0.2.0] - 2026-03-10
1087
+
1088
+ ### Added
1089
+ - **FiltersResponseAttributes** — read-path attribute filtering service (parallel to write-path `PermitsAttributes`)
1090
+ - Removes fields without `read_access`, masks fields with `mask_access`, unconfigured fields pass through
1091
+ - Framework-agnostic — works with any serializer (AMS, Blueprinter, jbuilder, etc.)
1092
+ - Non-mutating (returns new hash), single cache read, O(n) per object
1093
+ - **Masked Attributes** — field-level masking with `mask_access` and `mask_strategy` columns on ResourceField
1094
+ - 4 built-in strategies: `full`, `email`, `last4`, `first1_last4`
1095
+ - `MaskValue` service for applying masks to values
1096
+ - `masked_field?`, `mask_strategy_for`, `field_permissions` helpers on PermissionedUser
1097
+ - **GDPR Defaults** — auto-mask PII fields when generating resource fields
1098
+ - `config.gdpr_defaults = true` enables automatic masking on field creation
1099
+ - `DEFAULT_GDPR_FIELD_PATTERNS` covers email, phone, name, address, SSN, IBAN, DOB, IP
1100
+ - `config.gdpr_field_patterns` for extending or overriding patterns
1101
+ - `GdprFieldAuditor` service to scan existing fields for unmasked GDPR-sensitive data
1102
+ - **Custom Resource Actions** — admin-configurable actions per resource (sync, export, approve, etc.)
1103
+ - New `ResourceAction` model (8th model) with `name`, `is_permitted`, `is_active`
1104
+ - `can?(action, resource)` generic method on PermissionedUser — delegates CRUD, checks custom actions
1105
+ - Actions included in cached permission hash under `actions:` key
1106
+ - `PermissionSet#deep_clone` clones resource actions
1107
+
1108
+ ### Changed
1109
+ - Cache format: field entries now include `mask_access` and `mask_strategy` (additive, backward-compatible)
1110
+ - Cache format: resource entries now include `actions` hash (additive, backward-compatible)
1111
+ - `PermissionHashBuilder` includes mask metadata and resource actions in output
1112
+ - Migration template includes `resource_actions` table and mask columns on `resource_fields`
1113
+
1114
+ ### Fixed
1115
+ - **Multi-set merge: most permissive wins** — when a user belongs to multiple permission sets:
1116
+ - `mask_access`: changed from last-wins to AND logic (FALSE = unmasked wins)
1117
+ - `mask_strategy`: changed from last-wins to first-wins (`||=`)
1118
+ - Resource action `is_permitted`: changed from last-wins to OR logic (TRUE wins)
1119
+ - Resource CRUD and field read/edit already used `||=` (TRUE wins) — no change needed
1120
+
1121
+ ## [0.1.0] - 2026-03-10
1122
+
1123
+ ### Added
1124
+ - Initial release
1125
+ - Configuration DSL with configurable user resolution, cache backend, table prefix
1126
+ - 7 ActiveRecord models: PermissionSet, PermissionSetUser, PermissionSetGroup, PermissionSetGroupUser, Resource, ResourceField, PermissionSetRule
1127
+ - PermissionedUser concern (permission generation, caching, access checks)
1128
+ - PermissionScopedFindable concern (row-level query enforcement)
1129
+ - PermissionsRegenerator concern (cascading permission refresh)
1130
+ - HasUuid and HasAuditTrail utility concerns
1131
+ - Cache backends: Redis, Memory, Null
1132
+ - PermissionHashBuilder, PermitsAttributes, ResourceFieldGenerator services
1133
+ - Install generator (migration + initializer)
1134
+ - Admin API generator (controllers, serializers, routes)
1135
+
1136
+ [Unreleased]: https://github.com/fetijashari/permission_forge/compare/v0.14.1...HEAD
1137
+ [0.14.1]: https://github.com/fetijashari/permission_forge/compare/v0.14.0...v0.14.1
1138
+ [0.14.0]: https://github.com/fetijashari/permission_forge/releases/tag/v0.14.0