permission_forge 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (406) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +1138 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +356 -0
  5. data/app/controllers/concerns/permission_forge/acting_admin.rb +17 -0
  6. data/app/controllers/concerns/permission_forge/api/crud_actions.rb +84 -0
  7. data/app/controllers/concerns/permission_forge/api/nested_resource_lookup.rb +18 -0
  8. data/app/controllers/concerns/permission_forge/api/pagination.rb +50 -0
  9. data/app/controllers/concerns/permission_forge/api/response_renderer.rb +65 -0
  10. data/app/controllers/concerns/permission_forge/api/serializer_resolver.rb +30 -0
  11. data/app/controllers/concerns/permission_forge/api_authentication.rb +40 -0
  12. data/app/controllers/concerns/permission_forge/assignment_authorization.rb +97 -0
  13. data/app/controllers/concerns/permission_forge/audit_log_gate.rb +55 -0
  14. data/app/controllers/concerns/permission_forge/basic_authentication.rb +77 -0
  15. data/app/controllers/concerns/permission_forge/cache_confirmation.rb +32 -0
  16. data/app/controllers/concerns/permission_forge/forgery_protection.rb +29 -0
  17. data/app/controllers/concerns/permission_forge/html_format.rb +40 -0
  18. data/app/controllers/concerns/permission_forge/param_shapes.rb +87 -0
  19. data/app/controllers/concerns/permission_forge/tenant_required.rb +42 -0
  20. data/app/controllers/permission_forge/api/audit_events_controller.rb +33 -0
  21. data/app/controllers/permission_forge/api/base_controller.rb +220 -0
  22. data/app/controllers/permission_forge/api/dashboard_controller.rb +30 -0
  23. data/app/controllers/permission_forge/api/permission_set_group_users_controller.rb +51 -0
  24. data/app/controllers/permission_forge/api/permission_set_groups_controller.rb +71 -0
  25. data/app/controllers/permission_forge/api/permission_set_rules_controller.rb +37 -0
  26. data/app/controllers/permission_forge/api/permission_set_users_controller.rb +105 -0
  27. data/app/controllers/permission_forge/api/permission_sets_controller.rb +87 -0
  28. data/app/controllers/permission_forge/api/resource_actions_controller.rb +58 -0
  29. data/app/controllers/permission_forge/api/resource_fields_controller.rb +30 -0
  30. data/app/controllers/permission_forge/api/resources_controller.rb +56 -0
  31. data/app/controllers/permission_forge/application_controller.rb +198 -0
  32. data/app/controllers/permission_forge/assets_controller.rb +97 -0
  33. data/app/controllers/permission_forge/assignments_controller.rb +266 -0
  34. data/app/controllers/permission_forge/audit_events_controller.rb +94 -0
  35. data/app/controllers/permission_forge/bulk_assignments_controller.rb +108 -0
  36. data/app/controllers/permission_forge/field_lookups_controller.rb +38 -0
  37. data/app/controllers/permission_forge/home_controller.rb +24 -0
  38. data/app/controllers/permission_forge/maintenance_controller.rb +13 -0
  39. data/app/controllers/permission_forge/people_controller.rb +190 -0
  40. data/app/controllers/permission_forge/permission_set_editor.rb +71 -0
  41. data/app/controllers/permission_forge/permission_set_group_users_controller.rb +57 -0
  42. data/app/controllers/permission_forge/permission_set_groups_controller.rb +111 -0
  43. data/app/controllers/permission_forge/permission_set_rules_controller.rb +97 -0
  44. data/app/controllers/permission_forge/permission_sets_controller.rb +227 -0
  45. data/app/controllers/permission_forge/preferences_controller.rb +26 -0
  46. data/app/controllers/permission_forge/resource_actions_controller.rb +106 -0
  47. data/app/controllers/permission_forge/resource_fields_controller.rb +129 -0
  48. data/app/controllers/permission_forge/resources_controller.rb +189 -0
  49. data/app/controllers/permission_forge/settings_controller.rb +33 -0
  50. data/app/frontend/permission_forge/brand/apple-touch-icon.png +0 -0
  51. data/app/frontend/permission_forge/brand/chevron.svg +1 -0
  52. data/app/frontend/permission_forge/brand/favicon.ico +0 -0
  53. data/app/frontend/permission_forge/brand/favicon.svg +1 -0
  54. data/app/frontend/permission_forge/brand/logo-horizontal-dark.svg +1 -0
  55. data/app/frontend/permission_forge/brand/logo-horizontal-light.svg +1 -0
  56. data/app/frontend/permission_forge/brand/mark-small.svg +1 -0
  57. data/app/frontend/permission_forge/brand/mark.svg +1 -0
  58. data/app/frontend/permission_forge/pf_admin.css +766 -0
  59. data/app/frontend/permission_forge/pf_admin.js +482 -0
  60. data/app/helpers/permission_forge/application_helper.rb +167 -0
  61. data/app/helpers/permission_forge/audit_helper.rb +230 -0
  62. data/app/helpers/permission_forge/audit_sentences_helper.rb +84 -0
  63. data/app/helpers/permission_forge/badges_helper.rb +134 -0
  64. data/app/helpers/permission_forge/changes_helper.rb +117 -0
  65. data/app/helpers/permission_forge/people_helper.rb +118 -0
  66. data/app/helpers/permission_forge/sentences_helper.rb +175 -0
  67. data/app/helpers/permission_forge/settings_helper.rb +152 -0
  68. data/app/helpers/permission_forge/ui_helper.rb +184 -0
  69. data/app/models/permission_forge/audit_event.rb +159 -0
  70. data/app/models/permission_forge/permission_set.rb +100 -0
  71. data/app/models/permission_forge/permission_set_group.rb +83 -0
  72. data/app/models/permission_forge/permission_set_group_user.rb +43 -0
  73. data/app/models/permission_forge/permission_set_rule.rb +99 -0
  74. data/app/models/permission_forge/permission_set_user.rb +158 -0
  75. data/app/models/permission_forge/resource.rb +100 -0
  76. data/app/models/permission_forge/resource_action.rb +58 -0
  77. data/app/models/permission_forge/resource_field.rb +69 -0
  78. data/app/views/layouts/permission_forge/application.html.erb +30 -0
  79. data/app/views/layouts/permission_forge/fragment.html.erb +2 -0
  80. data/app/views/permission_forge/assignments/_person_options.html.erb +26 -0
  81. data/app/views/permission_forge/assignments/confirm_destroy.html.erb +24 -0
  82. data/app/views/permission_forge/assignments/new.html.erb +99 -0
  83. data/app/views/permission_forge/audit_events/index.html.erb +161 -0
  84. data/app/views/permission_forge/audit_events/show.html.erb +146 -0
  85. data/app/views/permission_forge/bulk_assignments/confirm_revoke.html.erb +21 -0
  86. data/app/views/permission_forge/bulk_assignments/new.html.erb +69 -0
  87. data/app/views/permission_forge/field_lookups/show.html.erb +32 -0
  88. data/app/views/permission_forge/home/_audit_card.html.erb +19 -0
  89. data/app/views/permission_forge/home/_ending_card.html.erb +32 -0
  90. data/app/views/permission_forge/home/_expired_card.html.erb +34 -0
  91. data/app/views/permission_forge/home/_privacy_card.html.erb +67 -0
  92. data/app/views/permission_forge/home/_rules_card.html.erb +23 -0
  93. data/app/views/permission_forge/home/_stale_card.html.erb +39 -0
  94. data/app/views/permission_forge/home/_tidy_card.html.erb +18 -0
  95. data/app/views/permission_forge/home/index.html.erb +69 -0
  96. data/app/views/permission_forge/people/_access_tab.html.erb +58 -0
  97. data/app/views/permission_forge/people/_audit_tab.html.erb +93 -0
  98. data/app/views/permission_forge/people/_effective_field_source.html.erb +11 -0
  99. data/app/views/permission_forge/people/_effective_tab.html.erb +118 -0
  100. data/app/views/permission_forge/people/_effective_why.html.erb +48 -0
  101. data/app/views/permission_forge/people/_why_item.html.erb +19 -0
  102. data/app/views/permission_forge/people/confirm_remove_all.html.erb +27 -0
  103. data/app/views/permission_forge/people/consent_preview.html.erb +39 -0
  104. data/app/views/permission_forge/people/erasure_preview.html.erb +28 -0
  105. data/app/views/permission_forge/people/index.html.erb +78 -0
  106. data/app/views/permission_forge/people/show.html.erb +54 -0
  107. data/app/views/permission_forge/permission_set_groups/confirm_destroy.html.erb +12 -0
  108. data/app/views/permission_forge/permission_set_groups/index.html.erb +55 -0
  109. data/app/views/permission_forge/permission_set_groups/show.html.erb +100 -0
  110. data/app/views/permission_forge/permission_set_rules/_builder.html.erb +145 -0
  111. data/app/views/permission_forge/permission_set_rules/_value_input.html.erb +42 -0
  112. data/app/views/permission_forge/permission_set_rules/confirm_destroy.html.erb +13 -0
  113. data/app/views/permission_forge/permission_set_rules/edit.html.erb +3 -0
  114. data/app/views/permission_forge/permission_set_rules/new.html.erb +3 -0
  115. data/app/views/permission_forge/permission_sets/_assignments_tab.html.erb +74 -0
  116. data/app/views/permission_forge/permission_sets/_audit_tab.html.erb +80 -0
  117. data/app/views/permission_forge/permission_sets/_conditions_tab.html.erb +83 -0
  118. data/app/views/permission_forge/permission_sets/_header.html.erb +36 -0
  119. data/app/views/permission_forge/permission_sets/_records_summary.html.erb +16 -0
  120. data/app/views/permission_forge/permission_sets/_resource_summary.html.erb +11 -0
  121. data/app/views/permission_forge/permission_sets/_resources_tab.html.erb +49 -0
  122. data/app/views/permission_forge/permission_sets/confirm_destroy.html.erb +23 -0
  123. data/app/views/permission_forge/permission_sets/confirm_switch_mode.html.erb +15 -0
  124. data/app/views/permission_forge/permission_sets/index.html.erb +83 -0
  125. data/app/views/permission_forge/permission_sets/new.html.erb +63 -0
  126. data/app/views/permission_forge/permission_sets/review.html.erb +26 -0
  127. data/app/views/permission_forge/permission_sets/show.html.erb +50 -0
  128. data/app/views/permission_forge/resource_actions/_across_sets.html.erb +24 -0
  129. data/app/views/permission_forge/resource_actions/_add_select.html.erb +12 -0
  130. data/app/views/permission_forge/resource_actions/_effect.html.erb +25 -0
  131. data/app/views/permission_forge/resource_actions/_endpoints.html.erb +16 -0
  132. data/app/views/permission_forge/resource_actions/_form.html.erb +44 -0
  133. data/app/views/permission_forge/resource_actions/_managed_note.html.erb +11 -0
  134. data/app/views/permission_forge/resource_actions/confirm_destroy.html.erb +13 -0
  135. data/app/views/permission_forge/resource_actions/new.html.erb +31 -0
  136. data/app/views/permission_forge/resource_actions/show.html.erb +39 -0
  137. data/app/views/permission_forge/resource_fields/_explain.html.erb +24 -0
  138. data/app/views/permission_forge/resource_fields/show.html.erb +186 -0
  139. data/app/views/permission_forge/resources/_actions_tab.html.erb +88 -0
  140. data/app/views/permission_forge/resources/_fields_tab.html.erb +89 -0
  141. data/app/views/permission_forge/resources/_rules_tab.html.erb +55 -0
  142. data/app/views/permission_forge/resources/_sync.html.erb +57 -0
  143. data/app/views/permission_forge/resources/confirm_destroy.html.erb +17 -0
  144. data/app/views/permission_forge/resources/new.html.erb +57 -0
  145. data/app/views/permission_forge/resources/show.html.erb +117 -0
  146. data/app/views/permission_forge/settings/_ladder.html.erb +15 -0
  147. data/app/views/permission_forge/settings/_row.html.erb +38 -0
  148. data/app/views/permission_forge/settings/show.html.erb +67 -0
  149. data/app/views/permission_forge/shared/_across_sets.html.erb +31 -0
  150. data/app/views/permission_forge/shared/_audit_events.html.erb +36 -0
  151. data/app/views/permission_forge/shared/_audit_footer.html.erb +16 -0
  152. data/app/views/permission_forge/shared/_audit_off_note.html.erb +15 -0
  153. data/app/views/permission_forge/shared/_audit_pagination.html.erb +19 -0
  154. data/app/views/permission_forge/shared/_audit_refused_note.html.erb +7 -0
  155. data/app/views/permission_forge/shared/_audit_timeline.html.erb +29 -0
  156. data/app/views/permission_forge/shared/_crud_cell.html.erb +18 -0
  157. data/app/views/permission_forge/shared/_dialog_frame.html.erb +11 -0
  158. data/app/views/permission_forge/shared/_expiry_picker.html.erb +27 -0
  159. data/app/views/permission_forge/shared/_head_extra.html.erb +3 -0
  160. data/app/views/permission_forge/shared/_help_banner.html.erb +10 -0
  161. data/app/views/permission_forge/shared/_level_control.html.erb +18 -0
  162. data/app/views/permission_forge/shared/_mode_banners.html.erb +22 -0
  163. data/app/views/permission_forge/shared/_nav.html.erb +12 -0
  164. data/app/views/permission_forge/shared/_preferences.html.erb +24 -0
  165. data/app/views/permission_forge/shared/_reason_field.html.erb +20 -0
  166. data/app/views/permission_forge/shared/_savebar.html.erb +13 -0
  167. data/app/views/permission_forge/shared/_sheet_frame.html.erb +22 -0
  168. data/app/views/permission_forge/shared/_sidebar.html.erb +18 -0
  169. data/app/views/permission_forge/shared/_tech_details.html.erb +20 -0
  170. data/app/views/permission_forge/shared/_toasts.html.erb +27 -0
  171. data/app/views/permission_forge/shared/_topbar.html.erb +15 -0
  172. data/app/views/permission_forge/shared/_user_select.html.erb +16 -0
  173. data/app/views/permission_forge/shared/audit_refused.html.erb +7 -0
  174. data/config/locales/permission_forge.en.yml +2115 -0
  175. data/config/locales/permission_forge.mk.yml +2166 -0
  176. data/config/locales/permission_forge.sq.yml +2164 -0
  177. data/config/routes.rb +143 -0
  178. data/lib/generators/permission_forge/install/install_generator.rb +105 -0
  179. data/lib/generators/permission_forge/install/templates/initializer.rb.tt +148 -0
  180. data/lib/generators/permission_forge/install/templates/migration.rb.tt +198 -0
  181. data/lib/generators/permission_forge/rls/rls_generator.rb +57 -0
  182. data/lib/generators/permission_forge/rls/templates/enable_permission_forge_rls.rb.tt +15 -0
  183. data/lib/generators/permission_forge/upgrade/templates/add_admin_ui_columns.rb.tt +8 -0
  184. data/lib/generators/permission_forge/upgrade/templates/add_audit_events.rb.tt +98 -0
  185. data/lib/generators/permission_forge/upgrade/templates/add_resource_fields_unique_index.rb.tt +69 -0
  186. data/lib/generators/permission_forge/upgrade/upgrade_generator.rb +66 -0
  187. data/lib/permission_forge/admin/action_across_sets.rb +76 -0
  188. data/lib/permission_forge/admin/action_rows.rb +156 -0
  189. data/lib/permission_forge/admin/attention_items.rb +207 -0
  190. data/lib/permission_forge/admin/audit_event_detail.rb +75 -0
  191. data/lib/permission_forge/admin/audit_log.rb +193 -0
  192. data/lib/permission_forge/admin/audit_log_days.rb +41 -0
  193. data/lib/permission_forge/admin/audit_lookups.rb +105 -0
  194. data/lib/permission_forge/admin/audit_name_history.rb +85 -0
  195. data/lib/permission_forge/admin/audit_trail.rb +169 -0
  196. data/lib/permission_forge/admin/bulk_people_matcher.rb +109 -0
  197. data/lib/permission_forge/admin/change_set.rb +123 -0
  198. data/lib/permission_forge/admin/change_set_parts/actions.rb +27 -0
  199. data/lib/permission_forge/admin/change_set_parts/base.rb +58 -0
  200. data/lib/permission_forge/admin/change_set_parts/context.rb +50 -0
  201. data/lib/permission_forge/admin/change_set_parts/field_edit.rb +67 -0
  202. data/lib/permission_forge/admin/change_set_parts/field_levels.rb +71 -0
  203. data/lib/permission_forge/admin/change_set_parts/new_actions.rb +91 -0
  204. data/lib/permission_forge/admin/change_set_parts/new_fields.rb +63 -0
  205. data/lib/permission_forge/admin/change_set_parts/resource_edit.rb +55 -0
  206. data/lib/permission_forge/admin/change_set_parts/resource_flags.rb +19 -0
  207. data/lib/permission_forge/admin/change_set_parts/set_attrs.rb +45 -0
  208. data/lib/permission_forge/admin/change_set_parts/values.rb +20 -0
  209. data/lib/permission_forge/admin/column_fields.rb +48 -0
  210. data/lib/permission_forge/admin/conditions_form.rb +78 -0
  211. data/lib/permission_forge/admin/controller_catalog.rb +166 -0
  212. data/lib/permission_forge/admin/csv.rb +41 -0
  213. data/lib/permission_forge/admin/editable_lock.rb +157 -0
  214. data/lib/permission_forge/admin/effective_access.rb +158 -0
  215. data/lib/permission_forge/admin/effective_fields.rb +176 -0
  216. data/lib/permission_forge/admin/expiry_presets.rb +51 -0
  217. data/lib/permission_forge/admin/exports/document.rb +89 -0
  218. data/lib/permission_forge/admin/exports/events.rb +51 -0
  219. data/lib/permission_forge/admin/exports/holdings.rb +118 -0
  220. data/lib/permission_forge/admin/exports.rb +37 -0
  221. data/lib/permission_forge/admin/field_across_sets.rb +51 -0
  222. data/lib/permission_forge/admin/field_detail.rb +112 -0
  223. data/lib/permission_forge/admin/field_level.rb +74 -0
  224. data/lib/permission_forge/admin/field_rows.rb +137 -0
  225. data/lib/permission_forge/admin/last_changes.rb +94 -0
  226. data/lib/permission_forge/admin/model_catalog.rb +216 -0
  227. data/lib/permission_forge/admin/people_candidates.rb +108 -0
  228. data/lib/permission_forge/admin/people_directory.rb +158 -0
  229. data/lib/permission_forge/admin/permission_set_factory.rb +48 -0
  230. data/lib/permission_forge/admin/person_label.rb +95 -0
  231. data/lib/permission_forge/admin/record_id.rb +19 -0
  232. data/lib/permission_forge/admin/rule_builder.rb +58 -0
  233. data/lib/permission_forge/admin/rule_form.rb +175 -0
  234. data/lib/permission_forge/admin/rule_form_input.rb +74 -0
  235. data/lib/permission_forge/admin/sensitive_columns.rb +68 -0
  236. data/lib/permission_forge/admin/settings_catalog.rb +261 -0
  237. data/lib/permission_forge/admin/settings_values.rb +130 -0
  238. data/lib/permission_forge/api/serializers/audit_event_serializer.rb +27 -0
  239. data/lib/permission_forge/api/serializers/base_serializer.rb +36 -0
  240. data/lib/permission_forge/api/serializers/permission_set_group_serializer.rb +12 -0
  241. data/lib/permission_forge/api/serializers/permission_set_group_user_serializer.rb +11 -0
  242. data/lib/permission_forge/api/serializers/permission_set_rule_serializer.rb +25 -0
  243. data/lib/permission_forge/api/serializers/permission_set_serializer.rb +13 -0
  244. data/lib/permission_forge/api/serializers/permission_set_user_serializer.rb +11 -0
  245. data/lib/permission_forge/api/serializers/resource_action_serializer.rb +13 -0
  246. data/lib/permission_forge/api/serializers/resource_field_serializer.rb +14 -0
  247. data/lib/permission_forge/api/serializers/resource_serializer.rb +15 -0
  248. data/lib/permission_forge/audit/actor.rb +144 -0
  249. data/lib/permission_forge/audit/actor_resolver.rb +103 -0
  250. data/lib/permission_forge/audit/audited_views.rb +208 -0
  251. data/lib/permission_forge/audit/boot_warnings.rb +58 -0
  252. data/lib/permission_forge/audit/column_values.rb +56 -0
  253. data/lib/permission_forge/audit/context.rb +98 -0
  254. data/lib/permission_forge/audit/detached.rb +62 -0
  255. data/lib/permission_forge/audit/event_catalog.rb +110 -0
  256. data/lib/permission_forge/audit/event_query.rb +192 -0
  257. data/lib/permission_forge/audit/failure.rb +47 -0
  258. data/lib/permission_forge/audit/field_access.rb +186 -0
  259. data/lib/permission_forge/audit/gate.rb +167 -0
  260. data/lib/permission_forge/audit/job_context.rb +32 -0
  261. data/lib/permission_forge/audit/log_access.rb +36 -0
  262. data/lib/permission_forge/audit/recorder.rb +168 -0
  263. data/lib/permission_forge/audit/refusals.rb +61 -0
  264. data/lib/permission_forge/audit/request_context.rb +152 -0
  265. data/lib/permission_forge/audit/runtime.rb +178 -0
  266. data/lib/permission_forge/audit/runtime_emission.rb +135 -0
  267. data/lib/permission_forge/audit/settings_observer.rb +70 -0
  268. data/lib/permission_forge/audit/source.rb +54 -0
  269. data/lib/permission_forge/audit/subscribers/base.rb +69 -0
  270. data/lib/permission_forge/audit/subscribers/legacy_hooks.rb +97 -0
  271. data/lib/permission_forge/audit/subscribers/log.rb +45 -0
  272. data/lib/permission_forge/audit/subscribers.rb +58 -0
  273. data/lib/permission_forge/audit.rb +302 -0
  274. data/lib/permission_forge/boot_checks.rb +184 -0
  275. data/lib/permission_forge/cache/backlog.rb +142 -0
  276. data/lib/permission_forge/cache/base.rb +200 -0
  277. data/lib/permission_forge/cache/bulk_invalidation.rb +131 -0
  278. data/lib/permission_forge/cache/failure.rb +171 -0
  279. data/lib/permission_forge/cache/handled_errors.rb +82 -0
  280. data/lib/permission_forge/cache/holder_invalidation.rb +85 -0
  281. data/lib/permission_forge/cache/invalidation.rb +180 -0
  282. data/lib/permission_forge/cache/memory_cache.rb +77 -0
  283. data/lib/permission_forge/cache/null_cache.rb +40 -0
  284. data/lib/permission_forge/cache/rails_cache.rb +231 -0
  285. data/lib/permission_forge/cache/rebuild.rb +149 -0
  286. data/lib/permission_forge/cache/settlement.rb +63 -0
  287. data/lib/permission_forge/cache/user_keys.rb +206 -0
  288. data/lib/permission_forge/cache/variant_removal.rb +55 -0
  289. data/lib/permission_forge/cache/withheld_notice.rb +60 -0
  290. data/lib/permission_forge/concerns/audited_collection.rb +34 -0
  291. data/lib/permission_forge/concerns/auto_permission_scoping.rb +158 -0
  292. data/lib/permission_forge/concerns/controller_action_guard.rb +106 -0
  293. data/lib/permission_forge/concerns/field_mask_checker.rb +45 -0
  294. data/lib/permission_forge/concerns/has_active_scope.rb +12 -0
  295. data/lib/permission_forge/concerns/has_audit_trail.rb +59 -0
  296. data/lib/permission_forge/concerns/has_permission_set_users.rb +115 -0
  297. data/lib/permission_forge/concerns/has_tenant_scoping.rb +86 -0
  298. data/lib/permission_forge/concerns/has_uuid.rb +24 -0
  299. data/lib/permission_forge/concerns/invalidates_in_batch.rb +207 -0
  300. data/lib/permission_forge/concerns/invalidates_managed_actions.rb +80 -0
  301. data/lib/permission_forge/concerns/masked_attributes/alias_access.rb +280 -0
  302. data/lib/permission_forge/concerns/masked_attributes/store_access.rb +100 -0
  303. data/lib/permission_forge/concerns/masked_attributes/validation_reads.rb +153 -0
  304. data/lib/permission_forge/concerns/masked_attributes/virtual_readers.rb +242 -0
  305. data/lib/permission_forge/concerns/masked_attributes.rb +576 -0
  306. data/lib/permission_forge/concerns/permission_cache_accessor.rb +416 -0
  307. data/lib/permission_forge/concerns/permission_scoped_findable.rb +337 -0
  308. data/lib/permission_forge/concerns/permissioned_user.rb +78 -0
  309. data/lib/permission_forge/concerns/permissions_memo.rb +160 -0
  310. data/lib/permission_forge/concerns/permissions_per_context.rb +288 -0
  311. data/lib/permission_forge/concerns/permissions_regenerator.rb +43 -0
  312. data/lib/permission_forge/concerns/records_audit_events.rb +286 -0
  313. data/lib/permission_forge/concerns/resource_permission_checker.rb +116 -0
  314. data/lib/permission_forge/concerns/validates_column_lengths.rb +69 -0
  315. data/lib/permission_forge/concerns/validates_dependency.rb +54 -0
  316. data/lib/permission_forge/conditional_sets.rb +361 -0
  317. data/lib/permission_forge/configuration/admin_ui_settings.rb +108 -0
  318. data/lib/permission_forge/configuration/audit_settings.rb +184 -0
  319. data/lib/permission_forge/configuration/cache_settings.rb +76 -0
  320. data/lib/permission_forge/configuration/controller_action_settings.rb +112 -0
  321. data/lib/permission_forge/configuration/gdpr_settings.rb +70 -0
  322. data/lib/permission_forge/configuration/masked_attributes_settings.rb +83 -0
  323. data/lib/permission_forge/configuration/permits_attributes_settings.rb +26 -0
  324. data/lib/permission_forge/configuration/presets.rb +139 -0
  325. data/lib/permission_forge/configuration/query_scoping_settings.rb +107 -0
  326. data/lib/permission_forge/configuration/strategy_values.rb +57 -0
  327. data/lib/permission_forge/configuration/virtual_attributes_settings.rb +65 -0
  328. data/lib/permission_forge/configuration.rb +624 -0
  329. data/lib/permission_forge/controller_actions/authorizer.rb +171 -0
  330. data/lib/permission_forge/controller_actions/managed_index.rb +213 -0
  331. data/lib/permission_forge/controller_actions/mapping.rb +167 -0
  332. data/lib/permission_forge/controller_actions.rb +122 -0
  333. data/lib/permission_forge/deprecation.rb +86 -0
  334. data/lib/permission_forge/engine.rb +113 -0
  335. data/lib/permission_forge/execution_state.rb +318 -0
  336. data/lib/permission_forge/invalidation_batch.rb +206 -0
  337. data/lib/permission_forge/masked_models.rb +226 -0
  338. data/lib/permission_forge/masking_sync.rb +271 -0
  339. data/lib/permission_forge/model_bindings.rb +90 -0
  340. data/lib/permission_forge/open_transaction.rb +303 -0
  341. data/lib/permission_forge/operators/constants.rb +41 -0
  342. data/lib/permission_forge/operators/registry.rb +300 -0
  343. data/lib/permission_forge/operators/rule_column.rb +120 -0
  344. data/lib/permission_forge/operators/validations.rb +111 -0
  345. data/lib/permission_forge/operators/value_casters/number.rb +68 -0
  346. data/lib/permission_forge/operators/value_casters/temporal.rb +69 -0
  347. data/lib/permission_forge/operators/value_casters.rb +80 -0
  348. data/lib/permission_forge/operators/value_typing.rb +115 -0
  349. data/lib/permission_forge/query_scoping_warnings.rb +68 -0
  350. data/lib/permission_forge/rake_audit.rb +65 -0
  351. data/lib/permission_forge/resource_chain.rb +190 -0
  352. data/lib/permission_forge/rls.rb +209 -0
  353. data/lib/permission_forge/services/attribute_access_guard.rb +138 -0
  354. data/lib/permission_forge/services/audit_event_purger.rb +150 -0
  355. data/lib/permission_forge/services/bulk_permission_set_assignment.rb +108 -0
  356. data/lib/permission_forge/services/concerns/across_tenants.rb +69 -0
  357. data/lib/permission_forge/services/concerns/audited_operation.rb +43 -0
  358. data/lib/permission_forge/services/concerns/clonable.rb +29 -0
  359. data/lib/permission_forge/services/consent_withdrawal_handler.rb +135 -0
  360. data/lib/permission_forge/services/dashboard_stats.rb +43 -0
  361. data/lib/permission_forge/services/data_subject_erasure.rb +306 -0
  362. data/lib/permission_forge/services/expired_assignment_cleaner.rb +152 -0
  363. data/lib/permission_forge/services/extra_field_defaults.rb +71 -0
  364. data/lib/permission_forge/services/field_permission_merger.rb +83 -0
  365. data/lib/permission_forge/services/filters_response_attributes.rb +144 -0
  366. data/lib/permission_forge/services/gdpr_field_auditor/text_report.rb +63 -0
  367. data/lib/permission_forge/services/gdpr_field_auditor.rb +223 -0
  368. data/lib/permission_forge/services/host_key_guard.rb +110 -0
  369. data/lib/permission_forge/services/mask_value.rb +93 -0
  370. data/lib/permission_forge/services/permission_cache_clearer.rb +174 -0
  371. data/lib/permission_forge/services/permission_hash_builder.rb +305 -0
  372. data/lib/permission_forge/services/permission_rule_applier.rb +192 -0
  373. data/lib/permission_forge/services/permission_set_cloner.rb +89 -0
  374. data/lib/permission_forge/services/permission_set_condition_evaluator.rb +102 -0
  375. data/lib/permission_forge/services/permission_set_group_cloner.rb +52 -0
  376. data/lib/permission_forge/services/permits_attributes/nested_records.rb +106 -0
  377. data/lib/permission_forge/services/permits_attributes.rb +270 -0
  378. data/lib/permission_forge/services/resource_field_generator.rb +93 -0
  379. data/lib/permission_forge/services/resource_with_fields_builder.rb +47 -0
  380. data/lib/permission_forge/services/rls_operator_sql.rb +131 -0
  381. data/lib/permission_forge/services/rls_policy_generator.rb +365 -0
  382. data/lib/permission_forge/services/security_audit/action_check.rb +157 -0
  383. data/lib/permission_forge/services/security_audit/assignment_check.rb +120 -0
  384. data/lib/permission_forge/services/security_audit/audit_trail_check.rb +166 -0
  385. data/lib/permission_forge/services/security_audit/base_check.rb +57 -0
  386. data/lib/permission_forge/services/security_audit/config_check.rb +212 -0
  387. data/lib/permission_forge/services/security_audit/integrity_check.rb +105 -0
  388. data/lib/permission_forge/services/security_audit/personal_data_check.rb +79 -0
  389. data/lib/permission_forge/services/security_audit/privilege_check.rb +67 -0
  390. data/lib/permission_forge/services/security_audit/rule_check.rb +141 -0
  391. data/lib/permission_forge/services/security_audit/text_report.rb +46 -0
  392. data/lib/permission_forge/services/security_audit/user_model_check.rb +67 -0
  393. data/lib/permission_forge/services/security_audit.rb +75 -0
  394. data/lib/permission_forge/services/stale_assignment_auditor.rb +68 -0
  395. data/lib/permission_forge/services/unscoped_query_detector.rb +132 -0
  396. data/lib/permission_forge/version.rb +3 -0
  397. data/lib/permission_forge/virtual_fields/discovery.rb +65 -0
  398. data/lib/permission_forge/virtual_fields.rb +102 -0
  399. data/lib/permission_forge.rb +464 -0
  400. data/lib/tasks/permission_forge.rake +31 -0
  401. data/lib/tasks/permission_forge_audit_events.rake +28 -0
  402. data/lib/tasks/permission_forge_cache.rake +26 -0
  403. data/lib/tasks/permission_forge_expiration.rake +30 -0
  404. data/lib/tasks/permission_forge_gdpr.rake +69 -0
  405. data/lib/tasks/permission_forge_rls.rake +38 -0
  406. metadata +515 -0
@@ -0,0 +1,98 @@
1
+ class PermissionForgeAddAuditEvents < ActiveRecord::Migration<%= migration_version %>
2
+ # Creates the insert-only audit log and the columns the access review and the
3
+ # "This is intended" acknowledgement record. Every step is guarded, so running
4
+ # it against an install that already has them changes nothing. Existing
5
+ # assignments are not backfilled: a blank reviewed_at means "never reviewed".
6
+ def up
7
+ create_audit_events unless table_exists?(:<%= table_prefix %>audit_events)
8
+ add_tenant_column
9
+ add_audit_indexes
10
+
11
+ add_column_once :<%= table_prefix %>permission_set_users, :reviewed_at, :datetime
12
+ add_column_once :<%= table_prefix %>permission_set_users, :reviewed_by_id, user_key_type
13
+ add_column_once :<%= table_prefix %>permission_set_users, :reason, :string
14
+ add_column_once :<%= table_prefix %>permission_set_group_users, :reason, :string
15
+ add_column_once :<%= table_prefix %>resource_fields, :sensitive_acknowledged_by_id, user_key_type
16
+ end
17
+
18
+ # The audit log is evidence, and `up` may have changed nothing because the
19
+ # install already owned these objects, so a rollback must never delete it.
20
+ def down
21
+ raise ActiveRecord::IrreversibleMigration,
22
+ 'The audit log is evidence: permission_forge_add_audit_events does not drop the audit_events table ' \
23
+ 'or its columns on rollback. Remove them deliberately, by hand, if you really mean to.'
24
+ end
25
+
26
+ private
27
+
28
+ # No foreign keys: events outlive the records they describe.
29
+ def create_audit_events
30
+ create_table :<%= table_prefix %>audit_events do |t|
31
+ t.string :uuid, null: false
32
+ t.datetime :occurred_at, null: false, precision: 6
33
+ t.string :event, null: false
34
+ t.string :category, null: false
35
+ t.string :actor_type, null: false
36
+ t.string :actor_id
37
+ t.string :actor_label
38
+ t.string :source, null: false
39
+ t.string :record_type
40
+ t.string :record_id
41
+ t.string :record_uuid
42
+ t.string :record_label
43
+ t.bigint :permission_set_id
44
+ t.string :permission_set_uuid
45
+ t.string :subject_user_id
46
+ t.text :data
47
+ t.string :reason
48
+ t.string :request_id
49
+ t.string :ip
50
+ t.string :correlation_id
51
+ <% if tenant_column %> t.column :<%= tenant_column %>, tenant_key_type
52
+ <% end %> t.datetime :created_at, null: false
53
+ end
54
+ end
55
+
56
+ # A table made by an earlier run without tenancy gets the column before its index.
57
+ def add_tenant_column
58
+ <% if tenant_column %> add_column_once :<%= table_prefix %>audit_events, :<%= tenant_column %>, tenant_key_type
59
+ <% end %> end
60
+
61
+ def add_audit_indexes
62
+ table = :<%= table_prefix %>audit_events
63
+ add_index_once table, :uuid, unique: true, name: 'idx_pf_audit_uuid'
64
+ add_index_once table, :occurred_at, name: 'idx_pf_audit_occurred'
65
+ add_index_once table, %i[event occurred_at], name: 'idx_pf_audit_event'
66
+ add_index_once table, %i[subject_user_id occurred_at], name: 'idx_pf_audit_subject'
67
+ add_index_once table, %i[permission_set_id occurred_at], name: 'idx_pf_audit_pset'
68
+ add_index_once table, %i[permission_set_uuid occurred_at], name: 'idx_pf_audit_pset_uuid'
69
+ add_index_once table, %i[record_type record_id occurred_at], name: 'idx_pf_audit_record'
70
+ add_index_once table, %i[actor_id occurred_at], name: 'idx_pf_audit_actor'
71
+ add_index_once table, :correlation_id, name: 'idx_pf_audit_correlation'
72
+ <% if tenant_column %> add_index_once table, :<%= tenant_column %>, name: 'idx_pf_audit_tenant'
73
+ <% end %> end
74
+
75
+ # User and tenant ids keep the type the install already uses (a UUID or
76
+ # string key must not be truncated by a bigint column); bigint when unknown.
77
+ def user_key_type
78
+ key_type_of(:<%= table_prefix %>permission_set_users, :user_id)
79
+ end
80
+
81
+ def tenant_key_type
82
+ <% if tenant_column %> key_type_of(:<%= table_prefix %>permission_sets, :<%= tenant_column %>)
83
+ <% else %> :bigint
84
+ <% end %> end
85
+
86
+ def key_type_of(table, column)
87
+ existing = table_exists?(table) && connection.columns(table).find { |c| c.name == column.to_s }
88
+ existing ? existing.sql_type : :bigint
89
+ end
90
+
91
+ def add_column_once(table, column, type)
92
+ add_column table, column, type unless column_exists?(table, column)
93
+ end
94
+
95
+ def add_index_once(table, columns, **options)
96
+ add_index table, columns, **options unless index_name_exists?(table, options[:name])
97
+ end
98
+ end
@@ -0,0 +1,69 @@
1
+ class PermissionForgeAddResourceFieldsUniqueIndex < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ table = :<%= table_prefix %>resource_fields
4
+ index_name = 'idx_pf_resource_fields_unique'
5
+ columns = %i[resource_id name]
6
+ # Installs from 0.11 on create this unique index in their install
7
+ # migration: there is nothing to do. A same-named index that is not unique
8
+ # is replaced (add_index would fail on the existing name).
9
+ return if index_exists?(table, columns, name: index_name, unique: true)
10
+
11
+ remove_index table, name: index_name if index_name_exists?(table, index_name)
12
+
13
+ # Collapse any pre-existing duplicate (resource_id, name) rows before adding
14
+ # the unique index, otherwise index creation fails. Keep the most-restrictive
15
+ # row per group: masked over unmasked, then no-edit over edit, then lowest id.
16
+ duplicate_groups = select_rows(<<~SQL.squish)
17
+ SELECT resource_id, name, COUNT(*) AS c
18
+ FROM #{quote_table_name(table)}
19
+ GROUP BY resource_id, name
20
+ HAVING COUNT(*) > 1
21
+ SQL
22
+
23
+ duplicate_groups.each do |resource_id, name, _count|
24
+ rows = select_rows(<<~SQL.squish)
25
+ SELECT id, mask_access, edit_access
26
+ FROM #{quote_table_name(table)}
27
+ WHERE resource_id = #{quote(resource_id)}
28
+ AND name = #{quote(name)}
29
+ SQL
30
+
31
+ keeper = rows.min_by { |id, mask_access, edit_access| [truthy(mask_access) ? 0 : 1, truthy(edit_access) ? 1 : 0, id] }
32
+ loser_ids = rows.map(&:first) - [keeper.first]
33
+ next if loser_ids.empty?
34
+
35
+ # These deletes run past the models, so the audit log does not record
36
+ # them: the migration output is their trace.
37
+ say "resource_id #{resource_id}, name #{name.to_s.inspect}: kept id #{keeper[0]} " \
38
+ "(mask_access #{truthy(keeper[1])}, edit_access #{truthy(keeper[2])}); " \
39
+ "deleted ids #{loser_ids.sort.join(', ')} (not in the audit log)"
40
+
41
+ execute(<<~SQL.squish)
42
+ DELETE FROM #{quote_table_name(table)}
43
+ WHERE id IN (#{loser_ids.map { |id| quote(id) }.join(',')})
44
+ SQL
45
+ end
46
+
47
+ add_index table, columns, unique: true, name: index_name
48
+ end
49
+
50
+ # On an install from 0.11 on, `up` changed nothing, yet this removes the
51
+ # index the install migration created (as it always has).
52
+ def down
53
+ remove_index :<%= table_prefix %>resource_fields, name: 'idx_pf_resource_fields_unique'
54
+ end
55
+
56
+ private
57
+
58
+ def truthy(value)
59
+ [true, 1, '1', 't', 'true'].include?(value)
60
+ end
61
+
62
+ def select_rows(sql)
63
+ connection.select_rows(sql)
64
+ end
65
+
66
+ def quote(value)
67
+ connection.quote(value)
68
+ end
69
+ end
@@ -0,0 +1,66 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/active_record'
3
+
4
+ module PermissionForge
5
+ module Generators
6
+ # Adds schema hardening that shipped after the initial install, for apps that
7
+ # ran `permission_forge:install` before these guards existed.
8
+ #
9
+ # bin/rails generate permission_forge:upgrade
10
+ #
11
+ # The table prefix defaults to the app's configured
12
+ # PermissionForge.table_name_prefix (the initializer is loaded by
13
+ # `rails generate`), so an app on unprefixed tables gets migrations for
14
+ # its real tables. Pass --table-prefix to override.
15
+ #
16
+ # Currently: de-duplicates any (resource_id, name) collisions in
17
+ # resource_fields (keeping the most-restrictive row) and adds the unique
18
+ # index that new installs get by default; adds the nullable
19
+ # resource_fields.sensitive_acknowledged_at column used by the admin UI's
20
+ # privacy audit ("This is intended"); creates the audit_events table and
21
+ # the review/reason columns (see add_audit_events.rb.tt).
22
+ class UpgradeGenerator < Rails::Generators::Base
23
+ include ActiveRecord::Generators::Migration
24
+
25
+ source_root File.expand_path('templates', __dir__)
26
+
27
+ class_option :table_prefix, type: :string, default: nil,
28
+ desc: 'Table name prefix (default: the configured PermissionForge.table_name_prefix)'
29
+ class_option :tenant_column, type: :string, default: nil,
30
+ desc: 'Tenant column of the audit table (default: the configured tenant column, if any)'
31
+
32
+ def copy_migration
33
+ migration_template 'add_resource_fields_unique_index.rb.tt',
34
+ 'db/migrate/permission_forge_add_resource_fields_unique_index.rb'
35
+ end
36
+
37
+ # Admin UI privacy audit: "This is intended" acknowledgements.
38
+ def copy_admin_ui_migration
39
+ migration_template 'add_admin_ui_columns.rb.tt',
40
+ 'db/migrate/permission_forge_add_admin_ui_columns.rb'
41
+ end
42
+
43
+ # Insert-only audit log (permission_forge_audit_events) and the columns
44
+ # the access review and acknowledgement record. Guarded: safe to re-run.
45
+ def copy_audit_events_migration
46
+ migration_template 'add_audit_events.rb.tt',
47
+ 'db/migrate/permission_forge_add_audit_events.rb'
48
+ end
49
+
50
+ private
51
+
52
+ def table_prefix
53
+ options[:table_prefix] || PermissionForge.table_name_prefix.to_s
54
+ end
55
+
56
+ # The tenant column of the app's other tables, or nil without tenancy.
57
+ def tenant_column
58
+ options[:tenant_column].presence || PermissionForge.configuration.resolved_tenant_column&.to_s
59
+ end
60
+
61
+ def migration_version
62
+ "[#{ActiveRecord::Migration.current_version}]"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # One resource's action rows in every Permission Set that contains the
6
+ # resource: who manages an action and who permits it. Loaded on first use,
7
+ # once for all of the resource's action keys. Backs the Actions tab, the
8
+ # action drawers ("Across all Permission Sets") and the "Review and save"
9
+ # sentences.
10
+ #
11
+ # "Managed" means what ControllerActions::ManagedIndex means: a row in
12
+ # effect (ResourceAction#in_effect?) in any Permission Set, granting or
13
+ # muting. This reads the database, not the index, so it also knows *which*
14
+ # sets.
15
+ #
16
+ # @example
17
+ # across = ActionAcrossSets.new('Customer')
18
+ # across.managed?('export') # => true
19
+ # across.permitting_sets('export') # => [#<PermissionSet name: "Sales rep">]
20
+ class ActionAcrossSets
21
+ def initialize(resource_name)
22
+ @resource_name = resource_name.to_s
23
+ end
24
+
25
+ # @param key [String, Symbol] action key (e.g. 'export')
26
+ # @return [Array<Hash>] { resource:, set:, action: (ResourceAction or nil), in_effect: }
27
+ # for every set containing the resource — granting sets first, then by
28
+ # name. in_effect is whether the row (or, without a row, the resource)
29
+ # counts right now.
30
+ def rows(key)
31
+ name = key.to_s
32
+ resources.map do |resource|
33
+ action = action_in(resource, name)
34
+ { resource: resource, set: resource.permission_set, action: action, in_effect: (action || resource).in_effect? }
35
+ end
36
+ end
37
+
38
+ # @return [Boolean] whether any Permission Set manages the action right now
39
+ def managed?(key)
40
+ live_rows.key?(key.to_s)
41
+ end
42
+
43
+ # @return [Array<PermissionForge::PermissionSet>] granting sets whose row
44
+ # permits the action right now
45
+ def permitting_sets(key)
46
+ live_rows.fetch(key.to_s, []).filter_map do |permission_set, action|
47
+ permission_set if action.is_permitted && !permission_set.is_muting
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def resources
54
+ @resources ||= PermissionForge::Resource.where(name: @resource_name).includes(:permission_set, :resource_actions)
55
+ .sort_by { |resource| sort_key(resource.permission_set) }
56
+ end
57
+
58
+ def sort_key(permission_set)
59
+ [permission_set.is_muting ? 1 : 0, permission_set.name]
60
+ end
61
+
62
+ # { 'export' => [[PermissionSet, ResourceAction], ...] } — the rows in effect.
63
+ def live_rows
64
+ @live_rows ||= resources.each_with_object({}) do |resource, index|
65
+ resource.resource_actions.select(&:in_effect?).each do |action|
66
+ (index[action.name] ||= []) << [resource.permission_set, action]
67
+ end
68
+ end
69
+ end
70
+
71
+ def action_in(resource, name)
72
+ resource.resource_actions.find { |action| action.name == name }
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # One row per action on a Resource page: configured ResourceActions and —
6
+ # while controller_action_discovery is on — the app's controller actions
7
+ # that map to the resource but have no row in this set yet.
8
+ #
9
+ # Each row carries where the action runs (endpoints, with whether that
10
+ # controller runs the action guard) and what the saved configuration
11
+ # means right now for people with this set (effect).
12
+ #
13
+ # @example
14
+ # rows = ActionRows.new(resource)
15
+ # rows.configured_rows.map(&:name) # => ['export']
16
+ # rows.discovered_rows.map(&:name) # => ['archive', 'merge']
17
+ # rows.row('archive').effect # => :unmanaged_allowed
18
+ class ActionRows
19
+ # endpoints: ControllerCatalog::Entry list (empty while discovery is off).
20
+ # unlinked: configured, but no controller action maps to it — normal for
21
+ # an action the app checks in code with can?.
22
+ # standing: what the row means right now (see Standing).
23
+ Row = Struct.new(:name, :label, :action, :endpoints, :unlinked, :standing, keyword_init: true) do
24
+ delegate :effect, :permitted_by, to: :standing
25
+
26
+ def configured? = !action.nil?
27
+ end
28
+
29
+ # What one action means right now for people with this set, and which
30
+ # sets permit it. The other Permission Sets are only read when a row is
31
+ # asked for it: the Actions tab shows it only while discovery is on.
32
+ #
33
+ # effect:
34
+ # :permitted this set permits it
35
+ # :not_permitted this set has a row that does not permit it
36
+ # :blocked this muting set blocks it
37
+ # :unchanged this muting set has a row that does not block it
38
+ # :managed_elsewhere no row in effect here, and another set manages it
39
+ # :unmanaged_allowed no set manages it — everyone can run it
40
+ # :unmanaged_blocked no set manages it — blocked until a set permits it
41
+ class Standing
42
+ # @param action [PermissionForge::ResourceAction, nil] this set's row
43
+ def initialize(key, action, across:, configuration:)
44
+ @key = key
45
+ @action = action
46
+ @across = across
47
+ @configuration = configuration
48
+ end
49
+
50
+ def effect
51
+ @effect ||= @action&.in_effect? ? row_effect : effect_without_row
52
+ end
53
+
54
+ # @return [Array<PermissionForge::PermissionSet>] granting sets that
55
+ # permit the action right now
56
+ def permitted_by
57
+ @permitted_by ||= @across.permitting_sets(@key)
58
+ end
59
+
60
+ private
61
+
62
+ # This set's row is in effect. In a muting set is_permitted means "block".
63
+ def row_effect
64
+ flagged = @action.is_permitted
65
+ if @action.resource.permission_set.is_muting
66
+ flagged ? :blocked : :unchanged
67
+ else
68
+ flagged ? :permitted : :not_permitted
69
+ end
70
+ end
71
+
72
+ # No row in effect here: another set manages it, or nobody does.
73
+ def effect_without_row
74
+ return :managed_elsewhere if @across.managed?(@key)
75
+
76
+ @configuration.allow_unconfigured_actions? ? :unmanaged_allowed : :unmanaged_blocked
77
+ end
78
+ end
79
+
80
+ def initialize(resource, catalog: nil, configuration: PermissionForge.configuration,
81
+ across: ActionAcrossSets.new(resource.name))
82
+ @resource = resource
83
+ @catalog = catalog
84
+ @configuration = configuration
85
+ @across = across
86
+ end
87
+
88
+ # @return [Boolean] whether the app's controller actions are listed
89
+ def discovery?
90
+ @configuration.controller_action_discovery?
91
+ end
92
+
93
+ # @return [Array<Row>] configured rows, then discovered ones, each by key
94
+ def rows
95
+ @rows ||= configured_rows + discovered_rows
96
+ end
97
+
98
+ def configured_rows
99
+ @configured_rows ||= saved_actions.sort_by(&:name).map { |action| build_row(action.name, action) }
100
+ end
101
+
102
+ # Controller actions that map to the resource and have no row in this set.
103
+ def discovered_rows
104
+ @discovered_rows ||= (endpoints_by_key.keys - configured_names).sort.map { |key| build_row(key, nil) }
105
+ end
106
+
107
+ def row(name)
108
+ key = name.to_s
109
+ rows.find { |row| row.name == key }
110
+ end
111
+
112
+ # Discovered keys with no row yet, offered on the free-text "new action" form.
113
+ def suggestions
114
+ discovered_rows.map(&:name)
115
+ end
116
+
117
+ # @return [Boolean] whether any of the app's listed controllers runs the
118
+ # action guard (false: nothing here is enforced at runtime)
119
+ def any_guarded?
120
+ catalog.any_guarded?
121
+ end
122
+
123
+ # This action (by key) in every Permission Set containing the resource.
124
+ def across_sets(key)
125
+ @across.rows(key)
126
+ end
127
+
128
+ private
129
+
130
+ def catalog
131
+ @catalog ||= ControllerCatalog.new(configuration: @configuration)
132
+ end
133
+
134
+ # Rows a form built on the association but has not saved are not configured.
135
+ def saved_actions
136
+ @resource.resource_actions.select(&:persisted?)
137
+ end
138
+
139
+ def configured_names
140
+ saved_actions.map(&:name)
141
+ end
142
+
143
+ # { 'export' => [ControllerCatalog::Entry, ...] }; empty while discovery is off.
144
+ def endpoints_by_key
145
+ @endpoints_by_key ||= discovery? ? catalog.for_resource(@resource.name).group_by(&:key) : {}
146
+ end
147
+
148
+ def build_row(key, action)
149
+ endpoints = endpoints_by_key.fetch(key, [])
150
+ Row.new(name: key, label: action&.display_name || key.humanize, action: action, endpoints: endpoints,
151
+ unlinked: discovery? && endpoints.empty?,
152
+ standing: Standing.new(key, action, across: @across, configuration: @configuration))
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Admin
5
+ # Everything the admin home page asks the owner to look at. Each finder is
6
+ # a read-only query; actions (extend, keep, clean up, acknowledge) live in
7
+ # the controllers.
8
+ class AttentionItems
9
+ ENDING_DAYS = 7
10
+
11
+ # `field` — the field row, when there is one, switched on or off.
12
+ # `implicit_grant` — no active field row, and the field is readable and
13
+ # editable anyway because the model declares it as an extra (virtual) field.
14
+ SensitiveFinding = Struct.new(:permission_set, :resource, :field, :column, :match, :people_count, :implicit_grant,
15
+ keyword_init: true) do
16
+ # No setting in force: no row, or one that is turned off (the engine skips it).
17
+ def unconfigured? = field.nil? || turned_off?
18
+ def turned_off? = !field.nil? && !field.is_active
19
+ def implicit_grant? = implicit_grant == true
20
+ def name = field&.name || column
21
+ end
22
+
23
+ TidyFinding = Struct.new(:permission_set, :kind, :people_count, keyword_init: true) do
24
+ # A finding for an active set nobody uses, or an inactive set people
25
+ # are still assigned to; nil otherwise.
26
+ def self.for(permission_set, people)
27
+ active = permission_set.is_active
28
+ if people.zero? && active
29
+ new(permission_set: permission_set, kind: :no_people, people_count: 0)
30
+ elsif people.positive? && !active
31
+ new(permission_set: permission_set, kind: :inactive_with_people, people_count: people)
32
+ end
33
+ end
34
+ end
35
+
36
+ # Active record rules the app's query scoping won't apply to every query:
37
+ # all of them while auto_query_scoping is off, join rules when it's on.
38
+ RulesFinding = Struct.new(:permission_set, :resource, :rule_count, :people_count, keyword_init: true) do
39
+ def self.build(resource, rule_count, people_counts)
40
+ new(permission_set: resource.permission_set, resource: resource, rule_count: rule_count,
41
+ people_count: people_counts.fetch(resource.permission_set_id, 0))
42
+ end
43
+ end
44
+
45
+ def self.acknowledgement_supported?
46
+ PermissionForge::ResourceField.column_names.include?('sensitive_acknowledged_at')
47
+ rescue StandardError
48
+ false
49
+ end
50
+
51
+ def initialize(configuration: PermissionForge.configuration, catalog: ModelCatalog.new,
52
+ sensitive: SensitiveColumns.new(configuration: configuration))
53
+ @configuration = configuration
54
+ @catalog = catalog
55
+ @sensitive = sensitive
56
+ end
57
+
58
+ def total
59
+ [audit_findings.size, privacy_findings.size, unenforced_rules.size, ending_soon.size, expired.size, stale.size,
60
+ tidy.size].sum
61
+ end
62
+
63
+ def all_good?
64
+ total.zero?
65
+ end
66
+
67
+ # --- Audit log -----------------------------------------------------------
68
+
69
+ # [AuditLog.recording_problem] — :off, :failing or :missing_table —
70
+ # while the log does not record. Empty when it records.
71
+ def audit_findings
72
+ @audit_findings ||= [audit_problem].compact
73
+ end
74
+
75
+ # --- Privacy audit ------------------------------------------------------
76
+
77
+ def privacy_findings
78
+ @privacy_findings ||= unmasked_sensitive_fields + unconfigured_sensitive_fields
79
+ end
80
+
81
+ # --- Record rules --------------------------------------------------------
82
+
83
+ def unenforced_rules
84
+ @unenforced_rules ||= begin
85
+ enforced = rule_enforced_resources
86
+ counts = unenforced_rule_scope.where(resource_id: enforced.keys).group(:resource_id).count
87
+ findings = counts.map { |id, count| RulesFinding.build(enforced.fetch(id), count, people_counts) }
88
+ findings.sort_by { |finding| [finding.permission_set.name, finding.resource.name] }
89
+ end
90
+ end
91
+
92
+ def auto_query_scoping?
93
+ @configuration.auto_query_scoping_enabled?
94
+ end
95
+
96
+ # --- Assignments --------------------------------------------------------
97
+
98
+ def ending_soon
99
+ @ending_soon ||= assignments.where(expires_at: Time.current..ENDING_DAYS.days.from_now.end_of_day)
100
+ .order(:expires_at).to_a
101
+ end
102
+
103
+ def expired
104
+ @expired ||= assignments.expired.order(:expires_at).to_a
105
+ end
106
+
107
+ def stale
108
+ @stale ||= assignments.stale_before(stale_days.days.ago)
109
+ .order(PermissionForge::PermissionSetUser.last_reviewed_column).to_a
110
+ end
111
+
112
+ def stale_days
113
+ @configuration.admin_stale_assignment_days || 90
114
+ end
115
+
116
+ # --- Permission Sets to tidy up -----------------------------------------
117
+
118
+ def tidy
119
+ @tidy ||= PermissionForge::PermissionSet.order(:name).filter_map do |ps|
120
+ TidyFinding.for(ps, people_counts.fetch(ps.id, 0))
121
+ end
122
+ end
123
+
124
+ private
125
+
126
+ def audit_problem = AuditLog.recording_problem(@configuration)
127
+
128
+ def assignments
129
+ PermissionForge::PermissionSetUser.includes(:permission_set, :user)
130
+ end
131
+
132
+ # Resources whose rules are in force (View All / Modify All skip them), by id.
133
+ def rule_enforced_resources
134
+ granting_resources.reject(&:rules_bypassed?).index_by(&:id)
135
+ end
136
+
137
+ def unenforced_rule_scope
138
+ rules = PermissionForge::PermissionSetRule.active
139
+ auto_query_scoping? ? rules.join_type_rules : rules
140
+ end
141
+
142
+ def people_counts
143
+ @people_counts ||= PermissionForge::PermissionSetUser.active_assignments.group(:permission_set_id).count
144
+ end
145
+
146
+ def granting_resources
147
+ @granting_resources ||= PermissionForge::Resource.active.where(read_access: true)
148
+ .where(permission_set_id: PermissionForge::PermissionSet.active.granting.select(:id))
149
+ .includes(:permission_set, :resource_fields).to_a
150
+ end
151
+
152
+ def unmasked_sensitive_fields
153
+ acknowledged = self.class.acknowledgement_supported?
154
+ granting_resources.flat_map do |resource|
155
+ resource.resource_fields.filter_map do |field|
156
+ sensitive_finding(resource, field.name, field: field) if exposed?(field, acknowledged)
157
+ end
158
+ end
159
+ end
160
+
161
+ # Readable and unmasked, and not acknowledged as intentional.
162
+ def exposed?(field, acknowledged)
163
+ return false unless field.is_active && field.read_access && !field.mask_access
164
+
165
+ !(acknowledged && field.sensitive_acknowledged_at.present?)
166
+ end
167
+
168
+ # Sensitive columns and virtual fields with no active field row are a
169
+ # leak when they are readable unmasked anyway: the unconfigured-field
170
+ # strategy lets them through, or the model declares them as extra fields
171
+ # and extra_fields_default_access grants those. A switched-off row
172
+ # counts as no row, because the engine skips it; its finding keeps the
173
+ # row, so the card can say "turned off" instead of "not configured".
174
+ def unconfigured_sensitive_fields
175
+ passes = %w[pass_through warn].include?(@configuration.unconfigured_field_strategy.to_s)
176
+ return [] unless passes || @configuration.grant_extra_fields_by_default?
177
+
178
+ granting_resources.flat_map { |resource| unconfigured_findings(resource, passes) }
179
+ end
180
+
181
+ # `passes` — whether unconfigured_field_strategy lets a field with no row through.
182
+ def unconfigured_findings(resource, passes)
183
+ model = @catalog.find(resource.name)
184
+ return [] unless model
185
+
186
+ rows = resource.resource_fields.index_by(&:name)
187
+ model.managed_field_names.filter_map do |name|
188
+ row = rows[name]
189
+ next if row&.is_active
190
+
191
+ implicit = model.implicit_grant?(name)
192
+ sensitive_finding(resource, name, field: row, implicit_grant: implicit) if implicit || passes
193
+ end
194
+ end
195
+
196
+ # A finding when `column` looks sensitive, else nil. `details` are the
197
+ # finding's own attributes (field:, implicit_grant:).
198
+ def sensitive_finding(resource, column, **details)
199
+ match = @sensitive.match(column)
200
+ return unless match
201
+
202
+ SensitiveFinding.new(permission_set: resource.permission_set, resource: resource, column: column, match: match,
203
+ people_count: people_counts.fetch(resource.permission_set_id, 0), **details)
204
+ end
205
+ end
206
+ end
207
+ end