permission_forge 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (347) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2372 -0
  3. data/README.md +76 -25
  4. data/app/controllers/concerns/permission_forge/api/crud_actions.rb +3 -2
  5. data/app/controllers/concerns/permission_forge/api/input_shapes.rb +200 -0
  6. data/app/controllers/concerns/permission_forge/api/pagination.rb +12 -5
  7. data/app/controllers/concerns/permission_forge/api/response_renderer.rb +37 -1
  8. data/app/controllers/concerns/permission_forge/api_authentication.rb +13 -2
  9. data/app/controllers/concerns/permission_forge/cache_confirmation.rb +42 -11
  10. data/app/controllers/concerns/permission_forge/change_review.rb +42 -0
  11. data/app/controllers/concerns/permission_forge/copy_refusal.rb +39 -0
  12. data/app/controllers/concerns/permission_forge/param_shapes.rb +207 -7
  13. data/app/controllers/concerns/permission_forge/save_notice.rb +69 -0
  14. data/app/controllers/permission_forge/api/audit_events_controller.rb +1 -1
  15. data/app/controllers/permission_forge/api/base_controller.rb +163 -12
  16. data/app/controllers/permission_forge/api/dashboard_controller.rb +29 -4
  17. data/app/controllers/permission_forge/api/permission_set_group_users_controller.rb +5 -2
  18. data/app/controllers/permission_forge/api/permission_set_groups_controller.rb +5 -4
  19. data/app/controllers/permission_forge/api/permission_set_rules_controller.rb +7 -5
  20. data/app/controllers/permission_forge/api/permission_set_users_controller.rb +80 -19
  21. data/app/controllers/permission_forge/api/permission_sets_controller.rb +12 -6
  22. data/app/controllers/permission_forge/api/resource_actions_controller.rb +1 -1
  23. data/app/controllers/permission_forge/api/resource_fields_controller.rb +3 -4
  24. data/app/controllers/permission_forge/api/resources_controller.rb +26 -12
  25. data/app/controllers/permission_forge/api/unmatched_routes_controller.rb +20 -0
  26. data/app/controllers/permission_forge/application_controller.rb +39 -5
  27. data/app/controllers/permission_forge/assignments_controller.rb +36 -34
  28. data/app/controllers/permission_forge/bulk_assignments_controller.rb +8 -4
  29. data/app/controllers/permission_forge/field_lookups_controller.rb +1 -1
  30. data/app/controllers/permission_forge/maintenance_controller.rb +6 -2
  31. data/app/controllers/permission_forge/people_controller.rb +14 -14
  32. data/app/controllers/permission_forge/permission_set_editor.rb +20 -6
  33. data/app/controllers/permission_forge/permission_set_group_users_controller.rb +3 -1
  34. data/app/controllers/permission_forge/permission_set_groups_controller.rb +23 -9
  35. data/app/controllers/permission_forge/permission_set_rules_controller.rb +20 -10
  36. data/app/controllers/permission_forge/permission_sets_controller.rb +23 -24
  37. data/app/controllers/permission_forge/resource_actions_controller.rb +11 -6
  38. data/app/controllers/permission_forge/resource_fields_controller.rb +37 -19
  39. data/app/controllers/permission_forge/resources_controller.rb +12 -11
  40. data/app/frontend/permission_forge/pf_admin.css +99 -17
  41. data/app/frontend/permission_forge/pf_admin.js +36 -0
  42. data/app/helpers/permission_forge/application_helper.rb +22 -72
  43. data/app/helpers/permission_forge/audit_attribution_helper.rb +77 -0
  44. data/app/helpers/permission_forge/audit_helper.rb +57 -119
  45. data/app/helpers/permission_forge/audit_records_helper.rb +103 -0
  46. data/app/helpers/permission_forge/audit_sentences_helper.rb +56 -2
  47. data/app/helpers/permission_forge/badges_helper.rb +74 -17
  48. data/app/helpers/permission_forge/changes_helper.rb +24 -6
  49. data/app/helpers/permission_forge/conditions_helper.rb +119 -0
  50. data/app/helpers/permission_forge/labels_helper.rb +134 -0
  51. data/app/helpers/permission_forge/people_helper.rb +11 -19
  52. data/app/helpers/permission_forge/rule_problems_helper.rb +73 -0
  53. data/app/helpers/permission_forge/sentences_helper.rb +119 -98
  54. data/app/helpers/permission_forge/settings_helper.rb +2 -2
  55. data/app/helpers/permission_forge/ui_helper.rb +65 -4
  56. data/app/models/permission_forge/audit_event.rb +5 -1
  57. data/app/models/permission_forge/permission_set.rb +208 -18
  58. data/app/models/permission_forge/permission_set_group.rb +10 -4
  59. data/app/models/permission_forge/permission_set_group_user.rb +3 -1
  60. data/app/models/permission_forge/permission_set_rule.rb +254 -17
  61. data/app/models/permission_forge/permission_set_user.rb +38 -3
  62. data/app/models/permission_forge/resource.rb +18 -8
  63. data/app/models/permission_forge/resource_action.rb +15 -6
  64. data/app/models/permission_forge/resource_field.rb +97 -11
  65. data/app/views/permission_forge/assignments/confirm_destroy.html.erb +4 -4
  66. data/app/views/permission_forge/assignments/new.html.erb +10 -6
  67. data/app/views/permission_forge/audit_events/index.html.erb +1 -1
  68. data/app/views/permission_forge/audit_events/show.html.erb +16 -9
  69. data/app/views/permission_forge/bulk_assignments/confirm_revoke.html.erb +3 -3
  70. data/app/views/permission_forge/bulk_assignments/new.html.erb +3 -3
  71. data/app/views/permission_forge/field_lookups/show.html.erb +2 -2
  72. data/app/views/permission_forge/home/_broken_rules_card.html.erb +35 -0
  73. data/app/views/permission_forge/home/_conditions_card.html.erb +28 -0
  74. data/app/views/permission_forge/home/_ending_card.html.erb +11 -5
  75. data/app/views/permission_forge/home/_expired_card.html.erb +1 -1
  76. data/app/views/permission_forge/home/_mask_strategies_card.html.erb +27 -0
  77. data/app/views/permission_forge/home/_privacy_card.html.erb +21 -7
  78. data/app/views/permission_forge/home/_rules_card.html.erb +1 -1
  79. data/app/views/permission_forge/home/_secret_rules_card.html.erb +33 -0
  80. data/app/views/permission_forge/home/_stale_card.html.erb +7 -5
  81. data/app/views/permission_forge/home/_tidy_card.html.erb +1 -1
  82. data/app/views/permission_forge/home/index.html.erb +5 -6
  83. data/app/views/permission_forge/people/_access_tab.html.erb +26 -19
  84. data/app/views/permission_forge/people/_audit_tab.html.erb +8 -8
  85. data/app/views/permission_forge/people/_effective_field_source.html.erb +13 -7
  86. data/app/views/permission_forge/people/_effective_tab.html.erb +16 -15
  87. data/app/views/permission_forge/people/_effective_why.html.erb +20 -16
  88. data/app/views/permission_forge/people/_why_item.html.erb +1 -1
  89. data/app/views/permission_forge/people/confirm_remove_all.html.erb +3 -3
  90. data/app/views/permission_forge/people/erasure_preview.html.erb +1 -1
  91. data/app/views/permission_forge/permission_set_groups/confirm_destroy.html.erb +3 -3
  92. data/app/views/permission_forge/permission_set_groups/index.html.erb +3 -3
  93. data/app/views/permission_forge/permission_set_groups/show.html.erb +28 -19
  94. data/app/views/permission_forge/permission_set_rules/_builder.html.erb +35 -15
  95. data/app/views/permission_forge/permission_set_rules/_preview.html.erb +17 -0
  96. data/app/views/permission_forge/permission_set_rules/confirm_destroy.html.erb +3 -3
  97. data/app/views/permission_forge/permission_set_rules/preview.html.erb +12 -0
  98. data/app/views/permission_forge/permission_sets/_assignments_tab.html.erb +37 -22
  99. data/app/views/permission_forge/permission_sets/_audit_tab.html.erb +4 -4
  100. data/app/views/permission_forge/permission_sets/_condition_problems.html.erb +15 -0
  101. data/app/views/permission_forge/permission_sets/_conditions_tab.html.erb +14 -8
  102. data/app/views/permission_forge/permission_sets/_header.html.erb +6 -5
  103. data/app/views/permission_forge/permission_sets/_records_summary.html.erb +2 -2
  104. data/app/views/permission_forge/permission_sets/_resources_tab.html.erb +4 -4
  105. data/app/views/permission_forge/permission_sets/confirm_destroy.html.erb +4 -4
  106. data/app/views/permission_forge/permission_sets/confirm_switch_mode.html.erb +3 -3
  107. data/app/views/permission_forge/permission_sets/index.html.erb +6 -6
  108. data/app/views/permission_forge/permission_sets/new.html.erb +2 -2
  109. data/app/views/permission_forge/permission_sets/review.html.erb +13 -3
  110. data/app/views/permission_forge/permission_sets/show.html.erb +7 -5
  111. data/app/views/permission_forge/resource_actions/_across_sets.html.erb +7 -10
  112. data/app/views/permission_forge/resource_actions/_effect.html.erb +5 -5
  113. data/app/views/permission_forge/resource_actions/_form.html.erb +1 -1
  114. data/app/views/permission_forge/resource_actions/_managed_note.html.erb +5 -3
  115. data/app/views/permission_forge/resource_actions/confirm_destroy.html.erb +1 -1
  116. data/app/views/permission_forge/resource_actions/new.html.erb +1 -1
  117. data/app/views/permission_forge/resource_actions/show.html.erb +2 -2
  118. data/app/views/permission_forge/resource_fields/_explain.html.erb +19 -15
  119. data/app/views/permission_forge/resource_fields/show.html.erb +21 -12
  120. data/app/views/permission_forge/resources/_actions_tab.html.erb +4 -4
  121. data/app/views/permission_forge/resources/_fields_tab.html.erb +17 -9
  122. data/app/views/permission_forge/resources/_rules_tab.html.erb +9 -6
  123. data/app/views/permission_forge/resources/_sync.html.erb +8 -5
  124. data/app/views/permission_forge/resources/confirm_destroy.html.erb +3 -3
  125. data/app/views/permission_forge/resources/new.html.erb +3 -2
  126. data/app/views/permission_forge/resources/show.html.erb +6 -6
  127. data/app/views/permission_forge/settings/_row.html.erb +2 -2
  128. data/app/views/permission_forge/settings/show.html.erb +10 -1
  129. data/app/views/permission_forge/shared/_across_sets.html.erb +8 -9
  130. data/app/views/permission_forge/shared/_audit_events.html.erb +2 -2
  131. data/app/views/permission_forge/shared/_audit_footer.html.erb +13 -2
  132. data/app/views/permission_forge/shared/_crud_cell.html.erb +6 -6
  133. data/app/views/permission_forge/shared/_mode_banners.html.erb +1 -1
  134. data/app/views/permission_forge/shared/_savebar.html.erb +4 -2
  135. data/config/locales/permission_forge.en.yml +480 -69
  136. data/config/locales/permission_forge.mk.yml +499 -79
  137. data/config/locales/permission_forge.sq.yml +509 -80
  138. data/config/routes.rb +38 -23
  139. data/lib/generators/permission_forge/install/install_generator.rb +21 -0
  140. data/lib/generators/permission_forge/install/templates/initializer.rb.tt +35 -34
  141. data/lib/generators/permission_forge/install/templates/migration.rb.tt +5 -5
  142. data/lib/permission_forge/activation_conditions.rb +200 -0
  143. data/lib/permission_forge/admin/action_label.rb +33 -0
  144. data/lib/permission_forge/admin/action_rows.rb +3 -1
  145. data/lib/permission_forge/admin/actor_label.rb +52 -0
  146. data/lib/permission_forge/admin/attention_items.rb +152 -31
  147. data/lib/permission_forge/admin/audit_log.rb +4 -7
  148. data/lib/permission_forge/admin/audit_trail.rb +33 -17
  149. data/lib/permission_forge/admin/bulk_people_matcher.rb +32 -14
  150. data/lib/permission_forge/admin/chain_entries.rb +42 -0
  151. data/lib/permission_forge/admin/change_set.rb +12 -1
  152. data/lib/permission_forge/admin/change_set_parts/base.rb +5 -4
  153. data/lib/permission_forge/admin/change_set_parts/context.rb +3 -1
  154. data/lib/permission_forge/admin/change_set_parts/field_edit.rb +4 -1
  155. data/lib/permission_forge/admin/change_set_parts/field_levels.rb +1 -1
  156. data/lib/permission_forge/admin/change_set_parts/new_actions.rb +1 -1
  157. data/lib/permission_forge/admin/change_set_parts/new_fields.rb +38 -11
  158. data/lib/permission_forge/admin/change_set_parts/resource_edit.rb +17 -1
  159. data/lib/permission_forge/admin/change_set_parts/resource_flags.rb +1 -1
  160. data/lib/permission_forge/admin/change_set_parts/set_attrs.rb +4 -0
  161. data/lib/permission_forge/admin/change_set_parts/values.rb +10 -4
  162. data/lib/permission_forge/admin/column_fields.rb +22 -21
  163. data/lib/permission_forge/admin/conditions_form.rb +47 -15
  164. data/lib/permission_forge/admin/controller_catalog.rb +42 -14
  165. data/lib/permission_forge/admin/editable_lock.rb +83 -3
  166. data/lib/permission_forge/admin/effective_access.rb +147 -48
  167. data/lib/permission_forge/admin/effective_fields.rb +75 -67
  168. data/lib/permission_forge/admin/error_sentence.rb +47 -0
  169. data/lib/permission_forge/admin/expiry_presets.rb +17 -0
  170. data/lib/permission_forge/admin/exports/holdings.rb +6 -3
  171. data/lib/permission_forge/admin/field_across_sets.rb +7 -4
  172. data/lib/permission_forge/admin/field_chain.rb +109 -0
  173. data/lib/permission_forge/admin/field_detail.rb +24 -11
  174. data/lib/permission_forge/admin/field_label.rb +109 -0
  175. data/lib/permission_forge/admin/field_level.rb +4 -68
  176. data/lib/permission_forge/admin/field_provenance.rb +132 -0
  177. data/lib/permission_forge/admin/field_rows.rb +26 -11
  178. data/lib/permission_forge/admin/last_changes.rb +14 -0
  179. data/lib/permission_forge/admin/list_sentence.rb +32 -0
  180. data/lib/permission_forge/admin/mask_strategy_choices.rb +57 -0
  181. data/lib/permission_forge/admin/model_catalog.rb +83 -39
  182. data/lib/permission_forge/admin/model_label.rb +66 -0
  183. data/lib/permission_forge/admin/people_candidates.rb +21 -9
  184. data/lib/permission_forge/admin/people_directory.rb +23 -5
  185. data/lib/permission_forge/admin/permission_set_factory.rb +6 -15
  186. data/lib/permission_forge/admin/person_label.rb +105 -16
  187. data/lib/permission_forge/admin/prefill.rb +92 -0
  188. data/lib/permission_forge/admin/record_name.rb +45 -0
  189. data/lib/permission_forge/admin/rule_builder.rb +77 -3
  190. data/lib/permission_forge/admin/rule_form.rb +44 -10
  191. data/lib/permission_forge/admin/rule_form_input.rb +27 -11
  192. data/lib/permission_forge/admin/rule_notes.rb +43 -0
  193. data/lib/permission_forge/admin/rule_preview.rb +96 -0
  194. data/lib/permission_forge/admin/sensitive_columns.rb +10 -62
  195. data/lib/permission_forge/admin/settings_catalog.rb +64 -12
  196. data/lib/permission_forge/admin/state_label.rb +97 -0
  197. data/lib/permission_forge/admin_method_override.rb +44 -0
  198. data/lib/permission_forge/api/schema.rb +322 -0
  199. data/lib/permission_forge/api/serializers/base_serializer.rb +87 -1
  200. data/lib/permission_forge/api/serializers/permission_set_group_user_serializer.rb +1 -1
  201. data/lib/permission_forge/api/serializers/permission_set_rule_serializer.rb +1 -1
  202. data/lib/permission_forge/api/serializers/permission_set_serializer.rb +5 -2
  203. data/lib/permission_forge/api/serializers/permission_set_user_serializer.rb +1 -1
  204. data/lib/permission_forge/api/serializers/resource_action_serializer.rb +1 -1
  205. data/lib/permission_forge/api/serializers/resource_field_serializer.rb +1 -1
  206. data/lib/permission_forge/api/serializers/resource_serializer.rb +1 -1
  207. data/lib/permission_forge/api_authorizations.rb +59 -0
  208. data/lib/permission_forge/api_only_admin.rb +81 -0
  209. data/lib/permission_forge/audit/actor_codes.rb +32 -0
  210. data/lib/permission_forge/audit/actor_resolver.rb +1 -2
  211. data/lib/permission_forge/audit/boot_warnings.rb +29 -9
  212. data/lib/permission_forge/audit/event_catalog.rb +16 -6
  213. data/lib/permission_forge/audit/event_query.rb +3 -3
  214. data/lib/permission_forge/audit/reason_codes.rb +25 -0
  215. data/lib/permission_forge/audit/runtime.rb +9 -2
  216. data/lib/permission_forge/audit/settings_observer.rb +2 -2
  217. data/lib/permission_forge/audit/unmasked_without_user.rb +169 -0
  218. data/lib/permission_forge/audit.rb +24 -10
  219. data/lib/permission_forge/boot_checks.rb +180 -63
  220. data/lib/permission_forge/cache/base.rb +10 -0
  221. data/lib/permission_forge/cache/null_cache.rb +5 -0
  222. data/lib/permission_forge/cache/outcome.rb +119 -0
  223. data/lib/permission_forge/cache/rails_cache.rb +6 -0
  224. data/lib/permission_forge/cache/report.rb +150 -0
  225. data/lib/permission_forge/cache/user_keys.rb +8 -4
  226. data/lib/permission_forge/cache_warnings.rb +140 -0
  227. data/lib/permission_forge/concerns/auto_permission_scoping.rb +19 -2
  228. data/lib/permission_forge/concerns/has_tenant_scoping.rb +1 -1
  229. data/lib/permission_forge/concerns/has_uuid.rb +1 -1
  230. data/lib/permission_forge/concerns/keeps_copied_values.rb +48 -0
  231. data/lib/permission_forge/concerns/masked_attributes/alias_access.rb +12 -2
  232. data/lib/permission_forge/concerns/masked_attributes/virtual_readers.rb +6 -4
  233. data/lib/permission_forge/concerns/masked_attributes.rb +150 -34
  234. data/lib/permission_forge/concerns/normalizes_label.rb +78 -0
  235. data/lib/permission_forge/concerns/permission_cache_accessor.rb +3 -3
  236. data/lib/permission_forge/concerns/permission_scoped_findable.rb +121 -29
  237. data/lib/permission_forge/concerns/permissioned_user.rb +1 -1
  238. data/lib/permission_forge/concerns/records_audit_events.rb +13 -6
  239. data/lib/permission_forge/concerns/requires_name.rb +37 -0
  240. data/lib/permission_forge/concerns/validates_column_lengths.rb +24 -0
  241. data/lib/permission_forge/concerns/validates_dependency.rb +5 -2
  242. data/lib/permission_forge/concerns/validates_required_columns.rb +74 -0
  243. data/lib/permission_forge/conditional_sets.rb +13 -6
  244. data/lib/permission_forge/configuration/admin_ui_settings.rb +18 -4
  245. data/lib/permission_forge/configuration/audit_settings.rb +2 -1
  246. data/lib/permission_forge/configuration/controller_action_settings.rb +5 -4
  247. data/lib/permission_forge/configuration/masked_attributes_settings.rb +73 -19
  248. data/lib/permission_forge/configuration/presets.rb +20 -11
  249. data/lib/permission_forge/configuration/query_scoping_settings.rb +55 -0
  250. data/lib/permission_forge/configuration/strategy_values.rb +2 -1
  251. data/lib/permission_forge/configuration.rb +41 -18
  252. data/lib/permission_forge/controller_actions/managed_index.rb +8 -7
  253. data/lib/permission_forge/database_reach.rb +93 -0
  254. data/lib/permission_forge/deprecation.rb +1 -1
  255. data/lib/permission_forge/docs/api_samples.rb +250 -0
  256. data/lib/permission_forge/docs/configuration_reference.rb +172 -0
  257. data/lib/permission_forge/encrypted_attributes.rb +150 -0
  258. data/lib/permission_forge/engine.rb +46 -14
  259. data/lib/permission_forge/execution_state/restore.rb +74 -0
  260. data/lib/permission_forge/execution_state.rb +8 -1
  261. data/lib/permission_forge/field_level.rb +123 -0
  262. data/lib/permission_forge/field_names.rb +145 -0
  263. data/lib/permission_forge/field_variants.rb +96 -0
  264. data/lib/permission_forge/generated_text.rb +95 -0
  265. data/lib/permission_forge/inspection_filters.rb +83 -0
  266. data/lib/permission_forge/managed_fields.rb +66 -0
  267. data/lib/permission_forge/masked_models.rb +25 -19
  268. data/lib/permission_forge/masking_sync.rb +100 -25
  269. data/lib/permission_forge/model_bindings.rb +30 -19
  270. data/lib/permission_forge/model_lookup.rb +42 -0
  271. data/lib/permission_forge/operators/encrypted_alias.rb +67 -0
  272. data/lib/permission_forge/operators/registry.rb +6 -0
  273. data/lib/permission_forge/operators/rule_column.rb +242 -27
  274. data/lib/permission_forge/operators/rule_path.rb +179 -0
  275. data/lib/permission_forge/operators/rule_problem.rb +309 -0
  276. data/lib/permission_forge/operators/rule_sources.rb +96 -0
  277. data/lib/permission_forge/operators/value_casters/postgresql_types.rb +237 -0
  278. data/lib/permission_forge/operators/value_casters/temporal.rb +9 -20
  279. data/lib/permission_forge/operators/value_casters.rb +61 -13
  280. data/lib/permission_forge/operators/value_typing.rb +83 -7
  281. data/lib/permission_forge/query_scoping_warnings.rb +22 -17
  282. data/lib/permission_forge/rake_audit.rb +5 -4
  283. data/lib/permission_forge/rake_dry_run.rb +32 -0
  284. data/lib/permission_forge/rake_output.rb +189 -0
  285. data/lib/permission_forge/record_read_access.rb +51 -0
  286. data/lib/permission_forge/resource_chain.rb +14 -2
  287. data/lib/permission_forge/schema_read/ordered_types.rb +72 -0
  288. data/lib/permission_forge/schema_read.rb +335 -0
  289. data/lib/permission_forge/secret_columns.rb +49 -0
  290. data/lib/permission_forge/sensitive_columns.rb +114 -0
  291. data/lib/permission_forge/services/attribute_access_guard.rb +22 -14
  292. data/lib/permission_forge/services/bulk_permission_set_assignment.rb +16 -8
  293. data/lib/permission_forge/services/concerns/clonable.rb +9 -0
  294. data/lib/permission_forge/services/concerns/settles_cache.rb +58 -0
  295. data/lib/permission_forge/services/consent_withdrawal_handler.rb +37 -19
  296. data/lib/permission_forge/services/data_subject_erasure.rb +83 -45
  297. data/lib/permission_forge/services/expired_assignment_cleaner.rb +62 -25
  298. data/lib/permission_forge/services/extra_field_defaults.rb +1 -1
  299. data/lib/permission_forge/services/field_permission_merger.rb +44 -17
  300. data/lib/permission_forge/services/filters_response_attributes.rb +20 -5
  301. data/lib/permission_forge/services/gdpr_field_auditor/text_report.rb +33 -5
  302. data/lib/permission_forge/services/gdpr_field_auditor.rb +68 -48
  303. data/lib/permission_forge/services/host_key_guard.rb +8 -5
  304. data/lib/permission_forge/services/mask_value.rb +72 -11
  305. data/lib/permission_forge/services/permission_cache_clearer.rb +36 -15
  306. data/lib/permission_forge/services/permission_hash_builder.rb +48 -19
  307. data/lib/permission_forge/services/permission_rule_applier.rb +131 -68
  308. data/lib/permission_forge/services/permission_set_cloner.rb +32 -8
  309. data/lib/permission_forge/services/permission_set_condition_evaluator.rb +38 -36
  310. data/lib/permission_forge/services/permission_set_group_cloner.rb +2 -1
  311. data/lib/permission_forge/services/permits_attributes/result.rb +31 -0
  312. data/lib/permission_forge/services/permits_attributes.rb +48 -27
  313. data/lib/permission_forge/services/resource_field_generator/row_builder.rb +96 -0
  314. data/lib/permission_forge/services/resource_field_generator.rb +71 -42
  315. data/lib/permission_forge/services/resource_with_fields_builder.rb +2 -1
  316. data/lib/permission_forge/services/rls_operator_sql.rb +83 -24
  317. data/lib/permission_forge/services/rls_policy_generator.rb +83 -39
  318. data/lib/permission_forge/services/rls_read_access_sql.rb +131 -0
  319. data/lib/permission_forge/services/security_audit/action_check.rb +49 -5
  320. data/lib/permission_forge/services/security_audit/assignment_check.rb +2 -1
  321. data/lib/permission_forge/services/security_audit/audit_trail_check.rb +1 -3
  322. data/lib/permission_forge/services/security_audit/conditions_check.rb +92 -0
  323. data/lib/permission_forge/services/security_audit/config_check.rb +41 -18
  324. data/lib/permission_forge/services/security_audit/integrity_check.rb +71 -4
  325. data/lib/permission_forge/services/security_audit/masking_without_user.rb +66 -0
  326. data/lib/permission_forge/services/security_audit/personal_data_check.rb +7 -11
  327. data/lib/permission_forge/services/security_audit/rule_check.rb +180 -50
  328. data/lib/permission_forge/services/security_audit/text_report.rb +14 -2
  329. data/lib/permission_forge/services/security_audit/unmanaged_actions.rb +145 -0
  330. data/lib/permission_forge/services/security_audit/user_model_check.rb +4 -3
  331. data/lib/permission_forge/services/security_audit.rb +16 -4
  332. data/lib/permission_forge/services/stale_assignment_auditor.rb +8 -5
  333. data/lib/permission_forge/timestamp_fields.rb +60 -0
  334. data/lib/permission_forge/unique_conflict.rb +117 -0
  335. data/lib/permission_forge/version.rb +1 -1
  336. data/lib/permission_forge/virtual_fields/discovery.rb +2 -1
  337. data/lib/permission_forge/virtual_fields.rb +15 -5
  338. data/lib/permission_forge/warnings.rb +263 -0
  339. data/lib/permission_forge/web_request.rb +248 -0
  340. data/lib/permission_forge.rb +54 -0
  341. data/lib/tasks/permission_forge.rake +26 -6
  342. data/lib/tasks/permission_forge_audit_events.rake +8 -4
  343. data/lib/tasks/permission_forge_cache.rake +12 -12
  344. data/lib/tasks/permission_forge_docs.rake +48 -0
  345. data/lib/tasks/permission_forge_expiration.rake +11 -5
  346. data/lib/tasks/permission_forge_gdpr.rake +36 -20
  347. metadata +84 -1
@@ -4,18 +4,29 @@ module PermissionForge
4
4
  module Services
5
5
  module SecurityAuditChecks
6
6
  # Record (row-level) rules: whether the app's query scoping applies them,
7
- # rules whose value makes them match nothing, rules View All makes
8
- # pointless, and likely misconfigurations.
7
+ # rules that match nothing (Operators::RuleProblem, the one taxonomy —
8
+ # each kind lands in one finding), rules naming a table of another
9
+ # model, rules View All makes pointless, and likely misconfigurations.
9
10
  class RuleCheck < BaseCheck
10
11
  TEXT_OPERATORS = %w[contains starts_with ends_with].freeze
11
- # What `current_user` is typed as here: an id is a number or its text,
12
- # whoever the user is, and never a boolean or a date.
13
- ANY_USER_ID = 0
12
+
13
+ # The kinds of Operators::RuleProblem each finding reports: one
14
+ # finding per kind. A new kind goes in exactly one list (the
15
+ # taxonomy guard fails for a kind no finding names).
16
+ FINDING_KINDS = {
17
+ missing_rule_columns: %i[missing_column unknown_association unjoinable_association unknown_table],
18
+ unknown_rule_operators: %i[unknown_operator unknown_join_type],
19
+ unusable_rule_values: %i[unusable_value],
20
+ foreign_table_filter_rules: %i[foreign_table],
21
+ encrypted_rule_columns: %i[encrypted_column],
22
+ non_text_pattern_rules: %i[pattern_on_non_text]
23
+ }.freeze
14
24
 
15
25
  def call
16
26
  return [no_rules] unless active_rules.exists?
17
27
 
18
- [unenforced, unusable_values, bypassed, text_operator_on_ids].compact
28
+ [unenforced, missing_columns, unknown_operators, unusable_values, foreign_table_filters, encrypted_columns,
29
+ non_text_patterns, secret_columns, foreign_table_names, bypassed, text_operator_on_ids].compact
19
30
  end
20
31
 
21
32
  private
@@ -24,14 +35,26 @@ module PermissionForge
24
35
  PermissionForge::PermissionSetRule.active
25
36
  end
26
37
 
38
+ # The advice follows the setting: with auto_query_scoping on, rules
39
+ # are all that is missing.
27
40
  def no_rules
41
+ advice = if configuration.auto_query_scoping_enabled?
42
+ 'otherwise add rules (auto_query_scoping is on, so a filter rule applies to every query).'
43
+ else
44
+ 'otherwise add rules and enable auto_query_scoping.'
45
+ end
28
46
  finding(:info, :no_rules, 'No record rules are in use',
29
47
  'Anyone with read access to a resource sees every record of it. That is fine if your app ' \
30
- 'limits records itself; otherwise add rules and enable auto_query_scoping.')
48
+ "limits records itself; #{advice}")
49
+ end
50
+
51
+ # What the admin's Home shows: the same rules (AttentionItems).
52
+ def attention
53
+ @attention ||= PermissionForge::Admin::AttentionItems.new(configuration: configuration)
31
54
  end
32
55
 
33
56
  def unenforced
34
- findings = PermissionForge::Admin::AttentionItems.new(configuration: configuration).unenforced_rules
57
+ findings = attention.unenforced_rules
35
58
  return if findings.empty?
36
59
 
37
60
  held = findings.any? { |item| item.people_count.positive? }
@@ -55,65 +78,172 @@ module PermissionForge
55
78
  "#{people_label(item.people_count)}"
56
79
  end
57
80
 
81
+ # The rules in effect Home lists as matching nothing
82
+ # (AttentionItems#broken_rules), of the kinds one finding reports.
83
+ def broken(code)
84
+ attention.broken_rules.select { |item| FINDING_KINDS.fetch(code).include?(item.problem.kind) }
85
+ end
86
+
87
+ # The finding's severity: the first while people hold one of the rules, else the second.
88
+ def weight(findings, held, unheld)
89
+ findings.any? { |item| item.people_count.positive? } ? held : unheld
90
+ end
91
+
92
+ # Rules saved before PermissionSetRule checked the column, the table
93
+ # and the path, or written past the model (the tables are shared with
94
+ # other applications).
95
+ def missing_columns
96
+ findings = broken(:missing_rule_columns)
97
+ return if findings.empty?
98
+
99
+ finding(weight(findings, :high, :medium), :missing_rule_columns,
100
+ 'Rules naming a column, table or association that does not exist',
101
+ 'The field name of these rules is not a column of their table (or not a name: a trailing or doubled ' \
102
+ 'dot), names a table this database does not have, or their association path names an association ' \
103
+ 'their model does not have (or an empty segment) or one a join cannot be built through ' \
104
+ '(polymorphic, a scope that takes the record — also along a :through —, a :through, :source or ' \
105
+ 'inverse_of naming nothing, a class that cannot be loaded) — a typo, or a column or association ' \
106
+ 'since renamed or removed. Such a rule ' \
107
+ 'matches nothing: everyone it applies to is denied every record of the resource. Correct the rule ' \
108
+ 'or remove it (see "Using the wrong field name" in docs/row-level-rules.md).',
109
+ items: findings.map { |item| rule_item(item.rule) })
110
+ end
111
+
112
+ # Rules whose operator or join type this version does not have:
113
+ # written by a newer version (a rolling deploy), by another
114
+ # application on the shared tables, or past the model.
115
+ def unknown_operators
116
+ findings = broken(:unknown_rule_operators)
117
+ return if findings.empty?
118
+
119
+ finding(weight(findings, :high, :medium), :unknown_rule_operators,
120
+ 'Rules with an operator or join type this version does not know',
121
+ 'The operator or join type of these rules is none this version of PermissionForge has — written ' \
122
+ 'by a newer version (a rolling deploy), by another application on the shared tables, or past the ' \
123
+ 'model. Such a rule matches nothing: everyone it applies to is denied every record of the resource ' \
124
+ '(never an error). Finish the upgrade, or correct the rule.',
125
+ items: findings.map { |item| unknown_operator_item(item) })
126
+ end
127
+
128
+ def unknown_operator_item(item)
129
+ rule = item.rule
130
+ note = " (join type #{rule.join_type.inspect})" if item.problem.kind == :unknown_join_type
131
+ rule_item(rule, note)
132
+ end
133
+
58
134
  def unusable_values
59
- rules = rules_in_effect.select { |rule| unusable_value?(rule) }
60
- return if rules.empty?
135
+ findings = broken(:unusable_rule_values)
136
+ return if findings.empty?
61
137
 
62
- held = rules.any? { |rule| people_counts.fetch(rule.resource.permission_set_id, 0).positive? }
63
- finding(held ? :medium : :low, :unusable_rule_values, 'Rules that match nothing',
138
+ finding(weight(findings, :medium, :low), :unusable_rule_values, 'Rules that match nothing',
64
139
  'The value of these rules is not one their column can hold: text on a number column, a word that ' \
65
140
  'is no boolean, an unknown enum label, a date that is not ISO 8601 (on MySQL and SQLite), a list ' \
66
- 'or range with the wrong number of values — or the field name is not a name (a trailing or ' \
67
- 'doubled dot). Such a rule matches nothing: everyone it applies to is denied every record of ' \
68
- 'the resource. Correct the rule or remove it (see "How Values Are Compared" in ' \
69
- 'docs/row-level-rules.md).',
70
- items: rules.map { |rule| unusable_item(rule) })
141
+ 'or range with the wrong number of values. Such a rule matches nothing: everyone it applies to is ' \
142
+ 'denied every record of the resource. Correct the rule or remove it (see "How Values Are ' \
143
+ 'Compared" in docs/row-level-rules.md).',
144
+ items: findings.map { |item| rule_item(item.rule) })
71
145
  end
72
146
 
73
- # Active rules of active resources in active granting sets: the rules
74
- # that are applied right now (a muting set's rules never are). Not the
75
- # ones View All skips — nobody is denied by those; they are listed
76
- # under bypassed_rules.
77
- def rules_in_effect
78
- resources = PermissionForge::Resource.in_effect.merge(PermissionForge::PermissionSet.granting)
79
- active_rules.where(resource_id: resources.select(:id)).includes(resource: :permission_set).order(:resource_id, :id)
80
- .reject { |rule| rule.resource.rules_bypassed? }
147
+ # Filter rules qualified with another model's table (RuleProblem
148
+ # :foreign_table): a filter rule compares its model's own table, so
149
+ # such a rule matches nothing (it raised in every query of its holders).
150
+ def foreign_table_filters
151
+ findings = broken(:foreign_table_filter_rules).presence or return
152
+
153
+ finding(weight(findings, :high, :medium), :foreign_table_filter_rules,
154
+ 'Filter rules naming a table of another model',
155
+ 'The field name of these filter rules is qualified with the table of another model. A filter rule ' \
156
+ 'compares its own model\'s table, so such a rule matches nothing: everyone it applies to is denied ' \
157
+ 'every record of the resource. For a column of another table, use a join rule (a join type and the ' \
158
+ 'relationship path in field_accessor_path).', items: findings.map { |item| rule_item(item.rule) })
159
+ end
160
+
161
+ # Rules comparing a value with an encrypted attribute (RuleProblem
162
+ # :encrypted_column): the column holds ciphertext, so such a rule
163
+ # matches nothing. Saved before the host encrypted the attribute, or
164
+ # written past the model.
165
+ def encrypted_columns
166
+ findings = broken(:encrypted_rule_columns).presence or return
167
+
168
+ finding(weight(findings, :high, :medium), :encrypted_rule_columns,
169
+ 'Rules comparing an encrypted attribute',
170
+ 'These rules compare a value with an attribute the model declares with `encrypts` (Active Record ' \
171
+ 'encryption): the column holds its ciphertext, so the comparison never matches and everyone the rule ' \
172
+ 'applies to is denied every record of the resource. Only is_null / is_not_null can be asked of such a ' \
173
+ 'column. Compare another column, or remove the rule (see "Encrypted attributes" in ' \
174
+ 'docs/row-level-rules.md).', items: findings.map { |item| rule_item(item.rule) })
81
175
  end
82
176
 
83
- # Typed the way the application layer types it (Operators::ValueTyping).
84
- # A model that is not there, or cannot be read, says nothing about the
85
- # value — that is the integrity check's finding, not one of this check.
86
- def unusable_value?(rule)
87
- target = rule_column(rule)
88
- return false unless target
177
+ # LIKE rules on a column no pattern can be matched against (RuleProblem
178
+ # :pattern_on_non_text): a json column, or on PostgreSQL any column
179
+ # that is not text. Reported as unusable_rule_values before 0.15.
180
+ def non_text_patterns
181
+ findings = broken(:non_text_pattern_rules).presence or return
89
182
 
90
- operator = rule.operator
91
- value = rule.field_value
92
- value = ANY_USER_ID if Operators::ValueTyping.current_user?(operator, value)
93
- Operators::ValueTyping.call(operator, value, target).equal?(Operators::ValueTyping::UNUSABLE)
94
- rescue ActiveRecord::ActiveRecordError, NameError, ArgumentError
95
- false
183
+ finding(weight(findings, :medium, :low), :non_text_pattern_rules,
184
+ 'Pattern rules on a column that is not text',
185
+ 'These rules use contains, not_contains, starts_with or ends_with on a column that holds no text ' \
186
+ '(a number, a date, a boolean, a uuid or a json document on PostgreSQL; a json column everywhere): such ' \
187
+ 'a rule matches nothing, so everyone it applies to is denied every record of the resource. Use a ' \
188
+ 'condition that compares values (equal, in, between), or remove the rule.',
189
+ items: findings.map { |item| rule_item(item.rule) })
96
190
  end
97
191
 
98
- # The column the rule names, found the way PermissionRuleApplier finds
99
- # it. A rule whose association path cannot be followed is left out: the
100
- # engine raises for it, it does not match nothing. Tables are checked
101
- # first: asking a missing one for its columns fails the statement, and
102
- # with it a transaction the audit may run in.
103
- def rule_column(rule)
104
- model = rule.resource.name.safe_constantize
105
- return unless model.is_a?(Class) && model < ActiveRecord::Base
192
+ # Rules in effect comparing a column that holds a secret
193
+ # (PermissionForge::SecretColumns) — what Home lists
194
+ # (AttentionItems#secret_rules). Stored before the model refused them;
195
+ # they still apply. The item leaves the value out: it is a guess at the secret.
196
+ def secret_columns
197
+ findings = attention.secret_rules.presence or return
106
198
 
107
- path = rule.join_rule? ? rule.field_accessor_path.to_s.split('.') : []
108
- models = Operators::RuleColumn.models_along(model, path)
109
- Operators::RuleColumn.find(rule.field_name, models) if models.any? && models.all?(&:table_exists?)
199
+ finding(:medium, :secret_rule_columns, 'Rules comparing a column that holds a secret',
200
+ 'These rules compare a password digest, a token, an `encrypted_*` column or a secret ' \
201
+ '(config.rule_secret_column_patterns; an attribute declared with `encrypts` is ' \
202
+ 'encrypted_rule_columns). They still apply, but who can read which record tells the ' \
203
+ 'secret apart, and the rule value is stored and shown in the admin and the API. Compare another ' \
204
+ 'column, or remove the rule; a new rule on such a column is refused.',
205
+ items: findings.map { |item| secret_rule_item(item.rule) })
206
+ end
207
+
208
+ def secret_rule_item(rule)
209
+ resource = rule.resource
210
+ path = rule.field_accessor_path.presence if rule.join_rule?
211
+ "#{resource.permission_set.name} › #{[resource.name, path, rule.field_name].compact.join('.')}: #{rule.name} · " \
212
+ "#{people(resource.permission_set_id)}"
213
+ end
214
+
215
+ # Join rules in effect whose field name is qualified with a real table
216
+ # that is none of the rule's (Operators::RuleColumn::FOREIGN): the
217
+ # query has to join that table itself (a default scope's join) — the
218
+ # value is compared untyped, and where the query does not join it the
219
+ # rule matches nothing. (A filter rule's is a RuleProblem.)
220
+ def foreign_table_names
221
+ rules = rules_in_effect.select { |rule| foreign_table?(rule) }
222
+ return if rules.empty?
223
+
224
+ finding(:info, :foreign_table_names, 'Join rules naming a table of another model',
225
+ 'The field name of these rules is qualified with a table that is none of the rule\'s — not the ' \
226
+ 'model\'s, nor one along its relationship path — so it is left to the database: the query has to ' \
227
+ 'join that table itself (where it does not, the rule matches nothing), and the value is compared as ' \
228
+ 'text. Name the relationship in field_accessor_path instead, so the column is checked and the value ' \
229
+ 'typed.', items: rules.map { |rule| rule_item(rule) })
230
+ end
231
+
232
+ def foreign_table?(rule)
233
+ rule.join_rule? && Operators::RuleColumn.for_rule(rule)&.foreign?
234
+ end
235
+
236
+ # The rules applied right now (AttentionItems#rules_in_effect).
237
+ def rules_in_effect
238
+ attention.rules_in_effect
110
239
  end
111
240
 
112
- def unusable_item(rule)
241
+ # "Sales › Customer.supplier.owner_id equal "no": Off · 1 person"
242
+ def rule_item(rule, note = nil)
113
243
  resource = rule.resource
114
244
  path = rule.field_accessor_path.presence if rule.join_rule?
115
245
  field = [resource.name, path, rule.field_name].compact.join('.')
116
- "#{resource.permission_set.name} › #{field} #{rule.operator} #{rule.field_value.inspect}: #{rule.name} · " \
246
+ "#{resource.permission_set.name} › #{field} #{rule.operator} #{rule.field_value.inspect}: #{rule.name}#{note} · " \
117
247
  "#{people(resource.permission_set_id)}"
118
248
  end
119
249
 
@@ -5,8 +5,11 @@ module PermissionForge
5
5
  module SecurityAuditChecks
6
6
  # Plain-text rendering of a SecurityAudit::Report for terminals and logs.
7
7
  class TextReport
8
- def initialize(report)
8
+ # @param logged [Boolean, nil] whether the audit log records this run
9
+ # (report.generated); nil asks Audit.enabled?
10
+ def initialize(report, logged: nil)
9
11
  @report = report
12
+ @logged = logged
10
13
  end
11
14
 
12
15
  def to_s
@@ -38,7 +41,16 @@ module PermissionForge
38
41
  def footer
39
42
  return 'No findings.' if @report.findings.empty?
40
43
 
41
- 'Read-only: nothing was changed. FORMAT=json for machine output; FAIL_ON=high to fail CI.'
44
+ "#{unchanged} FORMAT=json for machine output; FAIL_ON=high to fail CI."
45
+ end
46
+
47
+ # The report changes no permission data, but its run is an audit
48
+ # event (RakeAudit.reporting) whenever the log records.
49
+ def unchanged
50
+ logged = @logged.nil? ? PermissionForge::Audit.enabled? : @logged
51
+ return 'No permission data was changed.' unless logged
52
+
53
+ 'No permission data was changed; this run is recorded in the audit log (report.generated).'
42
54
  end
43
55
  end
44
56
  end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ module SecurityAuditChecks
6
+ # The guarded controller actions no Permission Set manages, as the
7
+ # ActionCheck lists them: the ones the guard hands to
8
+ # unconfigured_action_strategy (ControllerActions::Authorizer rows 4 and 8).
9
+ #
10
+ # "Managed" is per tenant (ManagedIndex): with tenancy, an action is
11
+ # listed with the tenants that do not manage it, among those that have
12
+ # an active Permission Set.
13
+ #
14
+ # UnmanagedActions.new(entries: catalog.mapped_entries, managed: { nil => { 'Customer' => Set['export'] } }).items
15
+ # # => ["Invoice (resource not managed): invoices#index, invoices#show",
16
+ # # "Customer › archive: customers#archive"]
17
+ # # with tenants: [3, 4, 9]
18
+ # # => ["Customer (resource not managed in 2 of 3 tenants: 3, 9): customers#index, customers#archive",
19
+ # # "Customer › archive (not managed in 1 of 3 tenants: 4): customers#archive"]
20
+ class UnmanagedActions
21
+ # Tenant ids named in one item; the count says how many there are.
22
+ TENANT_LIMIT = 10
23
+
24
+ class << self
25
+ # Reads what is managed in each tenant from the rows in effect
26
+ # (Resource.in_effect, ResourceAction.in_effect: what ManagedIndex
27
+ # reads, for every tenant at once). The tenants judged are those
28
+ # with an active Permission Set; with none, every action is listed
29
+ # without a tenant.
30
+ #
31
+ # @return [UnmanagedActions]
32
+ def build(entries:, configuration:)
33
+ tenant = configuration.resolved_tenant_column
34
+ new(entries: entries, managed: managed_index(tenant), tenants: tenant && tenants_with_sets(tenant))
35
+ end
36
+
37
+ # Who the guard refuses them to: signed-out requests follow
38
+ # action_guard_nil_user_strategy (else nil_user_strategy) before the
39
+ # strategy is asked.
40
+ #
41
+ # @return [String]
42
+ def refused_to(configuration)
43
+ signed_out = if configuration.action_guard_nil_user_bypass?
44
+ ' (signed-out requests run them: action_guard_nil_user_strategy, else nil_user_strategy, is :bypass)'
45
+ else
46
+ ', and to signed-out visitors'
47
+ end
48
+ "to every signed-in user action_guard_bypass does not let through#{signed_out}"
49
+ end
50
+
51
+ private
52
+
53
+ # A resource row is [tenant, name], an action row [tenant, name, key].
54
+ def managed_index(tenant)
55
+ (resource_rows(tenant) + action_rows(tenant)).each_with_object({}) do |(id, name, key), index|
56
+ keys = ((index[id] ||= {})[name] ||= Set.new)
57
+ keys << key if key
58
+ end
59
+ end
60
+
61
+ def resource_rows(tenant)
62
+ PermissionForge::Resource.in_effect.distinct.pluck(tenant_of(PermissionForge::Resource, tenant), resource_name)
63
+ end
64
+
65
+ def action_rows(tenant)
66
+ PermissionForge::ResourceAction.in_effect.distinct
67
+ .pluck(tenant_of(PermissionForge::Resource, tenant), resource_name,
68
+ PermissionForge::ResourceAction.arel_table[:name])
69
+ end
70
+
71
+ def resource_name = PermissionForge::Resource.arel_table[:name]
72
+
73
+ def tenants_with_sets(tenant)
74
+ ids = PermissionForge::PermissionSet.active.distinct.pluck(tenant_of(PermissionForge::PermissionSet, tenant))
75
+ (ids.compact.sort + ids.select(&:nil?)).presence
76
+ end
77
+
78
+ def tenant_of(model, tenant)
79
+ tenant ? model.arel_table[tenant] : Arel.sql('NULL')
80
+ end
81
+ end
82
+
83
+ # @param entries [Array<PermissionForge::Admin::ControllerCatalog::Entry>]
84
+ # every mapped controller action (ControllerCatalog#mapped_entries)
85
+ # @param managed [Hash{Object => Hash{String => Set<String>}}] tenant id
86
+ # (nil without tenancy) => the managed action keys of each resource
87
+ # some Permission Set of that tenant manages (ManagedIndex's shape)
88
+ # @param tenants [Array, nil] the tenants to judge, nil without tenancy
89
+ def initialize(entries:, managed:, tenants: nil)
90
+ @entries = entries.select(&:guarded?)
91
+ @managed = managed
92
+ @tenants = tenants
93
+ end
94
+
95
+ # @return [Array<String>] one item per resource some tenant does not
96
+ # manage, then one per custom action of a resource a tenant manages
97
+ # without managing the action
98
+ def items
99
+ resource_items + action_items
100
+ end
101
+
102
+ private
103
+
104
+ def judged = @tenants || [nil]
105
+
106
+ def index(tenant) = @managed.fetch(tenant, {})
107
+
108
+ def resource_items
109
+ @entries.group_by(&:resource).filter_map do |resource, group|
110
+ missing = judged.reject { |tenant| index(tenant).key?(resource) }
111
+ "#{resource} (resource not managed#{where(missing)}): #{endpoints(group)}" unless missing.empty?
112
+ end
113
+ end
114
+
115
+ # An action whose name is not a valid key can never get a row: it says so.
116
+ def action_items
117
+ @entries.reject(&:crud?).group_by(&:pair).filter_map do |(resource, key), group|
118
+ missing = judged.select { |tenant| index(tenant).fetch(resource, nil)&.exclude?(key) }
119
+ action_item(group, missing) unless missing.empty?
120
+ end
121
+ end
122
+
123
+ def action_item(group, missing)
124
+ first = group.first
125
+ note = ' (not a valid action key)' unless first.manageable?
126
+ "#{first.resource} › #{first.key}#{" (not managed#{where(missing)})" if @tenants}: #{endpoints(group)}#{note}"
127
+ end
128
+
129
+ # " in 2 of 5 tenants: 3, 9", or nothing without tenancy.
130
+ def where(missing)
131
+ return '' unless @tenants
132
+
133
+ shown = missing.first(TENANT_LIMIT).map { |tenant| tenant_label(tenant) }
134
+ " in #{missing.size} of #{@tenants.size} tenants: #{shown.join(', ')}#{', …' if missing.size > TENANT_LIMIT}"
135
+ end
136
+
137
+ def tenant_label(tenant) = tenant.nil? ? 'none' : tenant.to_s
138
+
139
+ def endpoints(group)
140
+ group.map(&:endpoint).uniq.join(', ')
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -37,15 +37,16 @@ module PermissionForge
37
37
  'PermissionForge.without_permission_scope.')
38
38
  end
39
39
 
40
- # :hide exempts the user model so sign-in can read it.
40
+ # :hide and :hide_in_requests exempt the user model so sign-in can read it.
41
41
  def user_model_unmasked_without_user
42
- return unless configuration.hide_masked_attributes_without_user?
42
+ return unless configuration.hide_masked_attributes_without_user_in_requests?
43
43
 
44
44
  names = user_model_resource_names
45
45
  return if names.empty?
46
46
 
47
47
  finding(:low, :user_model_unmasked_without_user, 'The user model is not masked with nobody signed in',
48
- 'masked_attributes_nil_user_strategy is :hide, but the user model is exempt so that sign-in ' \
48
+ "masked_attributes_nil_user_strategy is :#{configuration.masked_attributes_nil_user_strategy}, " \
49
+ 'but the user model is exempt so that sign-in ' \
49
50
  "can read it: with nobody signed in, #{user_model.name}'s columns — password digests and " \
50
51
  'reset or confirmation tokens included — read as stored through its getters and as_json. ' \
51
52
  'Do not render it with no current user. Another authenticatable model (a second Devise ' \
@@ -9,8 +9,8 @@ module PermissionForge
9
9
  #
10
10
  # Each check is a class answering `#call` with an array of Finding,
11
11
  # listed by `SecurityAudit.checks` (config, privilege, assignment, rule,
12
- # integrity, personal data, controller actions, the audit trail and the
13
- # user model);
12
+ # integrity, activation conditions, personal data, controller actions,
13
+ # the audit trail and the user model);
14
14
  # add one by passing `checks:` to `call` or by overriding `.checks`
15
15
  # (OCP). Nothing is ever changed.
16
16
  #
@@ -35,20 +35,32 @@ module PermissionForge
35
35
  Report = Struct.new(:findings, :generated_at, :preset, keyword_init: true) do
36
36
  def counts = SEVERITIES.index_with { |severity| findings.count { |finding| finding.severity == severity } }
37
37
 
38
+ # Findings of `severity` or worse. The name is read in any case
39
+ # (:high, 'HIGH'); one that is not a severity counts as :info.
38
40
  def at_least(severity)
39
- limit = SEVERITIES.index(severity.to_sym) || SEVERITIES.size
41
+ limit = SEVERITIES.index(SecurityAudit.severity_named(severity)) || SEVERITIES.size
40
42
  findings.select { |finding| finding.rank <= limit }
41
43
  end
42
44
 
43
45
  def to_h = { generated_at: generated_at.iso8601, preset: preset, counts: counts, findings: findings.map(&:to_h) }
44
46
  end
45
47
 
48
+ # The severity a name stands for, in any case and with surrounding
49
+ # blanks ignored (`FAIL_ON=HIGH`, `' medium'`).
50
+ #
51
+ # @param name [String, Symbol, nil]
52
+ # @return [Symbol, nil] one of SEVERITIES, nil for anything else
53
+ def self.severity_named(name)
54
+ wanted = name.to_s.strip.downcase
55
+ SEVERITIES.find { |severity| severity.to_s == wanted }
56
+ end
57
+
46
58
  # @return [Array<Class>] the check classes run by default
47
59
  def self.checks
48
60
  [
49
61
  SecurityAuditChecks::ConfigCheck, SecurityAuditChecks::PrivilegeCheck,
50
62
  SecurityAuditChecks::AssignmentCheck, SecurityAuditChecks::RuleCheck,
51
- SecurityAuditChecks::IntegrityCheck, SecurityAuditChecks::PersonalDataCheck,
63
+ SecurityAuditChecks::IntegrityCheck, SecurityAuditChecks::ConditionsCheck, SecurityAuditChecks::PersonalDataCheck,
52
64
  SecurityAuditChecks::ActionCheck, SecurityAuditChecks::AuditTrailCheck,
53
65
  SecurityAuditChecks::UserModelCheck
54
66
  ]
@@ -1,8 +1,10 @@
1
1
  module PermissionForge
2
2
  module Services
3
3
  # Audits permanent permission set assignments that haven't been reviewed
4
- # within a configurable threshold (default: 90 days). Reviewed means the
5
- # last access review "Keep" (reviewed_at), else the grant (created_at) —
4
+ # within a threshold (default: config.admin_stale_assignment_days, the
5
+ # threshold of the admin's Stale list; when that is nil,
6
+ # Configuration::AdminUiSettings::DEFAULT_STALE_ASSIGNMENT_DAYS, 90).
7
+ # Reviewed means the last access review "Keep" (reviewed_at), else the grant (created_at) —
6
8
  # PermissionSetUser.stale_before; editing an assignment is not a review.
7
9
  # Supports GDPR Art. 5(1)(e) — Storage Limitation.
8
10
  #
@@ -21,12 +23,13 @@ module PermissionForge
21
23
  class StaleAssignmentAuditor
22
24
  include Concerns::AcrossTenants
23
25
 
24
- # @param threshold_days [Integer] how long a permanent assignment may go unreviewed
26
+ # @param threshold_days [Integer, nil] how long a permanent assignment may go
27
+ # unreviewed; nil: config.admin_stale_assignment_days
25
28
  # @return [Hash] see {#call}
26
29
  def self.call(...) = new(...).call
27
30
 
28
- def initialize(threshold_days: 90)
29
- @threshold_days = threshold_days
31
+ def initialize(threshold_days: nil, configuration: PermissionForge.configuration)
32
+ @threshold_days = threshold_days || PermissionForge::Admin::AttentionItems.stale_days(configuration)
30
33
  end
31
34
 
32
35
  # @return [Hash] `{ stale_count:, affected_user_ids:, assignments: }`
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ # Rails' own timestamps (created_at / updated_at, created_on / updated_on)
5
+ # in the field rows the gem generates: ResourceFieldGenerator (also
6
+ # Resource.grant_full_access! and the API's generate_fields) and the
7
+ # admin's presets and "Sync fields" (Admin::ColumnFields).
8
+ #
9
+ # They are metadata a reader of the record may see and nobody edits, so on
10
+ # a granting Permission Set a generated timestamp row is at most Read-only:
11
+ # a level that grants read (Masked, Read-only, Editable) gives it read on,
12
+ # edit off and no mask; a level that grants nothing (Hidden, "No access",
13
+ # the generator without grant_all) leaves it hidden like every other column
14
+ # (a granting row adds access, so a readable timestamp there would widen
15
+ # what the set's holders see). On a Muting Permission Set read on means
16
+ # "take read away": its rows are left as the level says.
17
+ #
18
+ # Rows written one by one (the admin's field editor, the API, the console)
19
+ # are not changed.
20
+ #
21
+ # @api private
22
+ class TimestampFields
23
+ # Rails' default names (ActiveRecord::Timestamp); only those the model has
24
+ # as columns count (PermissionForge::SchemaRead: a timestamp a migration
25
+ # just added is read-only too).
26
+ NAMES = %w[created_at created_on updated_at updated_on].freeze
27
+
28
+ READ_ONLY = { read_access: true, edit_access: false, mask_access: false }.freeze
29
+
30
+ # When the columns cannot be read (no table, a database that cannot be
31
+ # asked) every name counts: a generated row of one is then at most
32
+ # Read-only, never Editable (fail closed — the names that are not
33
+ # columns get no row anyway).
34
+ #
35
+ # @param model_class [Class, nil]
36
+ # @return [Array<String>] the timestamp columns of the model
37
+ def self.names(model_class)
38
+ return [] unless model_class.respond_to?(:column_names)
39
+
40
+ SchemaRead.columns_among(model_class, NAMES)
41
+ rescue ActiveRecord::ActiveRecordError
42
+ NAMES
43
+ end
44
+
45
+ # @param model_class [Class, nil] the Resource's model
46
+ # @param muting [Boolean] the rows are a Muting Permission Set's
47
+ def initialize(model_class, muting:)
48
+ @names = muting ? [] : self.class.names(model_class)
49
+ end
50
+
51
+ # What a generated row of `name` gets over the flags of its level.
52
+ #
53
+ # @param name [String] the field name
54
+ # @param read [Boolean] the level gives the row read_access
55
+ # @return [Hash] Read-only for a readable timestamp of a granting set, else nothing
56
+ def overrides(name, read:)
57
+ read && @names.include?(name.to_s) ? READ_ONLY : {}
58
+ end
59
+ end
60
+ end