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
@@ -20,6 +20,23 @@ module PermissionForge
20
20
  module RlsOperatorSql
21
21
  # `current_user` in a policy: the SQL the session user's id is read with.
22
22
  SessionUser = Struct.new(:sql)
23
+ # A value the policy computes when it runs (a relative date): its SQL.
24
+ Dynamic = Struct.new(:sql)
25
+
26
+ # PostgreSQL's relative date and time words, as the expression each
27
+ # stands for when a statement runs. Quoted into a policy, PostgreSQL
28
+ # resolves the word once, when it parses CREATE POLICY: the policy kept
29
+ # the sync's date while the app layer, which sends the word with every
30
+ # query, reads it anew. So the policy computes it per statement instead.
31
+ RELATIVE_VALUES = {
32
+ 'now' => 'now()', 'today' => 'CURRENT_DATE', 'tomorrow' => '(CURRENT_DATE + 1)', 'yesterday' => '(CURRENT_DATE - 1)'
33
+ }.freeze
34
+ # A relative word anywhere in the text (`today 10:00`, `tomorrow UTC`):
35
+ # no exact per-statement form is built for those — the rule is FALSE in
36
+ # the policy (narrower than the app layer).
37
+ RELATIVE_WORD = /\b(?:#{RELATIVE_VALUES.keys.join('|')})\b/i
38
+ # The column types PostgreSQL reads those words for.
39
+ TEMPORAL_TYPES = %i[date time datetime timestamp timestamptz].freeze
23
40
 
24
41
  UNUSABLE = Operators::ValueTyping::UNUSABLE
25
42
 
@@ -44,48 +61,51 @@ module PermissionForge
44
61
  }.freeze
45
62
 
46
63
  # The operators whose app-layer strategy adds the NULL rows under
47
- # include_nulls. The others ignore the flag, and so does the policy.
48
- NULL_INCLUDING = [
49
- Operators::Constants::EQUAL, Operators::Constants::NOT_EQUAL,
50
- Operators::Constants::IN, Operators::Constants::NOT_IN
51
- ].freeze
52
-
53
- # The column types PostgreSQL has ILIKE for. On a column of any other
54
- # type the app layer's LIKE query raises, so the rule compiles to FALSE.
55
- LIKE_TYPES = %i[string text citext].freeze
64
+ # include_nulls (Operators::Registry::NULL_INCLUDING, the one list). The
65
+ # others ignore the flag, and so does the policy.
66
+ NULL_INCLUDING = Operators::Registry::NULL_INCLUDING
56
67
 
57
68
  # How the session user's id is read for a comparison, by the caster the
58
69
  # column's type has in the app layer: the id itself where the column is
59
- # a number or of a type nothing is known about, its text on a string
60
- # column. A column of any other type (a boolean, a date) cannot equal an
61
- # id — the rule is UNUSABLE there, as it is in the app layer.
70
+ # a number (or a uuid) the id is one of, or of a type nothing is known
71
+ # about, its text on a string column. A column of any other type (a
72
+ # boolean, a date, an address, a json or array column, a native enum)
73
+ # cannot equal an id — the rule is UNUSABLE there, as it is in the app
74
+ # layer.
75
+ # The database a policy is written for.
76
+ POLICY_ADAPTER = 'PostgreSQL'
77
+
62
78
  SESSION_USER_SQL = {
63
79
  Operators::ValueCasters::UNTYPED => :uid_sql,
64
80
  Operators::ValueCasters::NUMBER => :numeric_uid_sql,
65
81
  Operators::ValueCasters::Number::SINGLE_PRECISION => :numeric_uid_sql,
82
+ Operators::ValueCasters::PostgresqlTypes::UUID => :uuid_uid_sql,
66
83
  Operators::ValueCasters::TEXT => :uid_text_sql
67
84
  }.freeze
68
85
 
69
- # A rule's value as the app layer compares it with the column.
86
+ # A rule's value as the app layer compares it with the column. A column
87
+ # that holds ciphertext (ValueTyping.encrypted?) takes no value, the
88
+ # user's id included.
70
89
  #
71
90
  # @param target [Operators::RuleColumn] the column the rule names
72
91
  # @return [Object] a typed literal, an Array of them, a SessionUser, or UNUSABLE
73
92
  def typed_value(operator, value, target)
74
- return UNUSABLE if target.malformed? || unmatchable?(operator, target.column)
93
+ return UNUSABLE if Operators::ValueTyping.no_column?(operator, target) || unmatchable?(operator, target) ||
94
+ Operators::ValueTyping.encrypted?(operator, target)
75
95
  return session_user(operator, target) if Operators::ValueTyping.current_user?(operator, value)
76
96
 
77
- Operators::ValueTyping.call(operator, value, target)
97
+ relative(Operators::ValueTyping.call(operator, value, target), temporal_sql_type(target.column))
78
98
  end
79
99
 
80
100
  def value_sql(value)
81
101
  case value
82
- when SessionUser then value.sql
102
+ when SessionUser, Dynamic then value.sql
83
103
  else connection.quote(value)
84
104
  end
85
105
  end
86
106
 
87
107
  def list_sql(values, separator = ', ')
88
- values.map { |value| connection.quote(value) }.join(separator)
108
+ values.map { |value| value_sql(value) }.join(separator)
89
109
  end
90
110
 
91
111
  def like_sql(col, value, prefix:, suffix:)
@@ -99,12 +119,39 @@ module PermissionForge
99
119
 
100
120
  private
101
121
 
102
- # A LIKE operator on a column PostgreSQL cannot match a pattern with. (A
103
- # column the model does not have fails the sync, as it fails the query.)
104
- def unmatchable?(operator, column)
105
- return false unless column && Operators::ValueTyping::SHAPES[operator] == :pattern
122
+ # A typed value (or each of a list's) with PostgreSQL's relative words
123
+ # (RELATIVE_VALUES) as the expression PostgreSQL computes per statement,
124
+ # cast to the column's type; text with such a word that is not the
125
+ # word alone is UNUSABLE (FALSE). Only on a date or time column: on
126
+ # any other a word is text, or was refused by the read back.
127
+ def relative(value, sql_type)
128
+ return value unless sql_type
129
+ return relative_one(value, sql_type) unless value.is_a?(Array)
130
+
131
+ typed = value.map { |element| relative_one(element, sql_type) }
132
+ typed.any? { |element| element.equal?(UNUSABLE) } ? UNUSABLE : typed
133
+ end
134
+
135
+ # The column's SQL type when it is a date or time column, else nil.
136
+ def temporal_sql_type(column)
137
+ column.sql_type if column && TEMPORAL_TYPES.include?(column.type)
138
+ end
139
+
140
+ def relative_one(value, sql_type)
141
+ return value unless value.is_a?(String) && value.match?(RELATIVE_WORD)
106
142
 
107
- column.try(:array?) || LIKE_TYPES.exclude?(column.type)
143
+ expression = RELATIVE_VALUES[value.strip.downcase]
144
+ expression ? Dynamic.new("CAST(#{expression} AS #{sql_type})") : UNUSABLE
145
+ end
146
+
147
+ # A LIKE operator on a column PostgreSQL cannot match a pattern with
148
+ # (ValueCasters.pattern? for PostgreSQL, the database every policy is
149
+ # for — as the app layer decides on PostgreSQL). A column the model
150
+ # does not have is UNUSABLE, as in the app layer.
151
+ def unmatchable?(operator, target)
152
+ return false unless target.column && Operators::ValueTyping::SHAPES[operator] == :pattern
153
+
154
+ !Operators::ValueCasters.pattern?(target.caster, POLICY_ADAPTER)
108
155
  end
109
156
 
110
157
  # A comparison takes the id as the column's type lets it. A LIKE pattern
@@ -123,8 +170,20 @@ module PermissionForge
123
170
 
124
171
  # An id that is not a number (a uuid) is never the value of a number column.
125
172
  def numeric_uid_sql
126
- column = PermissionForge::PermissionSetUser.columns_hash['user_id']
127
- uid_sql if column.nil? || Operators::ValueCasters.for(column).equal?(Operators::ValueCasters::NUMBER)
173
+ uid_sql if user_id_caster.nil? || user_id_caster.equal?(Operators::ValueCasters::NUMBER)
174
+ end
175
+
176
+ # A uuid column equals the id only where user ids are uuids: compared
177
+ # with the text of any other id, PostgreSQL raises (or the app layer,
178
+ # with an id that is no uuid, matches nothing). A user_id column of
179
+ # text that holds uuids is narrower here than in the app layer: FALSE.
180
+ def uuid_uid_sql
181
+ uid_sql if user_id_caster.equal?(Operators::ValueCasters::PostgresqlTypes::UUID)
182
+ end
183
+
184
+ def user_id_caster
185
+ column = PermissionForge::SchemaRead.column(PermissionForge::PermissionSetUser, 'user_id')
186
+ column && Operators::ValueCasters.for(column)
128
187
  end
129
188
  end
130
189
  end
@@ -32,14 +32,19 @@ module PermissionForge
32
32
  # resource row's is_active; the membership EXISTS reads both states
33
33
  # per statement, so switching a set or a resource off or on needs no
34
34
  # re-sync and the policy is never wider than the app layer in between.
35
- # - A user in no rule-bearing set sees all rows (rules only restrict).
35
+ # - A user in no rule-bearing set sees all rows (rules only restrict),
36
+ # under no_read_access_strategy :rules_only (the default). Under :deny
37
+ # a signed-in user sees a row only with read access to the model's
38
+ # Resource chain, as RecordReadAccess decides it (RlsReadAccessSql,
39
+ # read per statement); the strategy is part of the digest.
36
40
  # - Muting sets contribute no rules; conditional sets' rules are included
37
41
  # (more restrictive than the app, never less — documented).
38
42
  # - Missing session variable follows nil_user_strategy: :bypass allows,
39
43
  # :deny/:raise hides every row.
40
44
  # - A rule's value is typed for its column by the app layer's own
41
- # Operators::ValueTyping; a value the column cannot hold compiles to
42
- # FALSE, as the app layer matches nothing for it (see RlsOperatorSql).
45
+ # Operators::ValueTyping; a value the column cannot hold, or a column
46
+ # the table does not have, compiles to FALSE, as the app layer matches
47
+ # nothing for it (see RlsOperatorSql).
43
48
  # - Join-type rules and dotted field names are NOT translatable — they are
44
49
  # reported in :skipped_rules, never silently dropped. So are the rules
45
50
  # of a Resource named after an STI subclass of the model: a policy is
@@ -70,9 +75,22 @@ module PermissionForge
70
75
  # 5: a rule set's membership EXISTS joins its resource row and needs it
71
76
  # active; the rules are compiled whatever the row's — or the set's —
72
77
  # state (the set's is_active was already read live).
73
- GENERATOR_VERSION = 5
78
+ # 6: a rule naming a column the table does not have compiles to FALSE
79
+ # (the sync failed on it), as the app layer matches nothing for it.
80
+ # 7: every Operators::RuleProblem kind compiles to FALSE (an unknown
81
+ # operator, join type or table, a path no join is built through); a
82
+ # value is typed for a uuid, inet, cidr, json/jsonb, array or native
83
+ # enum column (one it cannot hold is FALSE); an assignment counts until
84
+ # its expires_at, that instant included (`>= now()`); and only a set
85
+ # without an activation condition as PermissionSet.unconditional reads
86
+ # it (a text of blanks is none, one hour of the two is one) grants the
87
+ # view_all bypass. Under no_read_access_strategy :deny, a session user
88
+ # without read access to the model's Resource chain sees no row
89
+ # (RlsReadAccessSql; the strategy is part of the digest).
90
+ GENERATOR_VERSION = 7
74
91
 
75
92
  include RlsOperatorSql
93
+ include RlsReadAccessSql
76
94
 
77
95
  def self.call(model_class:, configuration: PermissionForge.configuration)
78
96
  new(model_class: model_class, configuration: configuration).call
@@ -81,7 +99,10 @@ module PermissionForge
81
99
  def initialize(model_class:, configuration: PermissionForge.configuration)
82
100
  @model_class = model_class
83
101
  @configuration = configuration
84
- @conn = model_class.connection
102
+ # The pool's lease, never the model's `connection` (it raises under
103
+ # Rails 7.2+ `permanent_connection_checkout = :disallowed`).
104
+ pool = model_class.connection_pool
105
+ @conn = pool.respond_to?(:lease_connection) ? pool.lease_connection : pool.connection
85
106
  @skipped_rules = []
86
107
  end
87
108
 
@@ -105,8 +126,8 @@ module PermissionForge
105
126
  def digest
106
127
  parts = [
107
128
  GENERATOR_VERSION,
108
- @configuration.nil_user_strategy,
109
- @configuration.rls_session_variable,
129
+ *strategy_fingerprint,
130
+ read_access_sql,
110
131
  table_name,
111
132
  tenant_predicate_sql,
112
133
  rule_sets.map { |set| [set[:id], set[:resource_id], set[:rules].map { |r| rule_fingerprint(r) }.sort] }.sort
@@ -116,6 +137,11 @@ module PermissionForge
116
137
 
117
138
  private
118
139
 
140
+ # The settings the policy is generated from.
141
+ def strategy_fingerprint
142
+ [@configuration.nil_user_strategy, @configuration.no_read_access_strategy, @configuration.rls_session_variable]
143
+ end
144
+
119
145
  # Adapter connection used for quoting by RlsOperatorSql helpers too.
120
146
  def connection
121
147
  @conn
@@ -150,11 +176,17 @@ module PermissionForge
150
176
 
151
177
  # :bypass -> uid IS NULL OR (view_all OR rules)
152
178
  # :deny/:raise -> uid IS NOT NULL AND (view_all OR rules)
179
+ # Under no_read_access_strategy :deny the signed-in branch is
180
+ # (read_access AND (view_all OR rules)) — RlsReadAccessSql, checked
181
+ # before the rules as RecordReadAccess is: View All never stands in
182
+ # for read.
153
183
  # The explicit uid guard matters: with a NULL uid every membership EXISTS
154
184
  # is false, which would make the rules conjunction vacuously TRUE — the
155
185
  # deny strategies must therefore hide rows before that branch is reached.
156
186
  def using_expression
157
- guarded = "(#{view_all_bypass_sql} OR #{rules_conjunction_sql})"
187
+ rows = "(#{view_all_bypass_sql} OR #{rules_conjunction_sql})"
188
+ read = read_access_sql
189
+ guarded = read ? "(#{read} AND #{rows})" : rows
158
190
  if @configuration.nil_user_strategy == :bypass
159
191
  "((#{uid_sql}) IS NULL OR #{guarded})"
160
192
  else
@@ -171,11 +203,12 @@ module PermissionForge
171
203
  end
172
204
 
173
205
  def user_id_cast_type
174
- PermissionForge::PermissionSetUser.columns_hash['user_id']&.sql_type || 'bigint'
206
+ PermissionForge::SchemaRead.column(PermissionForge::PermissionSetUser, 'user_id')&.sql_type || 'bigint'
175
207
  end
176
208
 
177
- # Granting-set view_all/modify_all bypasses rules; a muting set with
178
- # view_all/modify_all cancels the bypass. Both checked dynamically.
209
+ # A granting set's View All bypasses the rules (bypass_flags_sql); a
210
+ # muting set with View All or Modify All cancels the bypass. Both
211
+ # checked dynamically.
179
212
  #
180
213
  # GDPR/consent guard: activation conditions (session flag / IP / login
181
214
  # hours) cannot be evaluated inside a database policy, so only
@@ -187,41 +220,41 @@ module PermissionForge
187
220
  "(#{view_all_exists_sql(muting: false)} AND NOT #{view_all_exists_sql(muting: true)})"
188
221
  end
189
222
 
223
+ # Built without squish: the condition SQL quotes a newline, a carriage
224
+ # return and a tab (PermissionSet.conditions_present_sql), which squish
225
+ # would turn into spaces.
190
226
  def view_all_exists_sql(muting:)
191
- <<~SQL.squish
192
- EXISTS (
193
- SELECT 1 FROM #{psu_table} psu
194
- JOIN #{ps_table} ps ON ps.id = psu.permission_set_id
195
- JOIN #{resources_table} r ON r.permission_set_id = ps.id
196
- WHERE psu.user_id = (#{uid_sql})
197
- AND #{assignment_active_sql}
198
- AND ps.is_active = TRUE AND ps.is_muting = #{muting ? 'TRUE' : 'FALSE'}
199
- #{"AND #{unconditional_set_sql}" unless muting}
200
- AND r.name = #{@conn.quote(resource_name)}
201
- AND r.is_active = TRUE
202
- #{tenant_predicate_sql}
203
- AND (r.view_all = TRUE OR r.modify_all = TRUE)
204
- )
205
- SQL
227
+ resource_row_exists_sql(muting: muting, names: [resource_name],
228
+ extra: [(unconditional_set_sql unless muting), bypass_flags_sql(muting)])
229
+ end
230
+
231
+ # A granting row bypasses the rules with View All alone: the app layer
232
+ # grants Modify All only together with View All
233
+ # (PermissionHashBuilder.granted_flags), so Modify All without it — a
234
+ # row written past the validation — bypasses nothing. A muting row
235
+ # cancels the bypass with either flag (narrower than the app layer,
236
+ # whose muting Modify All leaves View All standing).
237
+ def bypass_flags_sql(muting)
238
+ muting ? '(r.view_all = TRUE OR r.modify_all = TRUE)' : 'r.view_all = TRUE'
206
239
  end
207
240
 
208
241
  # Ties the resource row to the protected row's tenant, when tenancy is
209
- # on and both tables have the tenant column: `AND r.account_id =
242
+ # on and both tables have the tenant column (SchemaRead: read again once
243
+ # before "no"): `AND r.account_id =
210
244
  # "customers"."account_id"`. A NULL tenant on either side matches
211
245
  # nothing. Empty otherwise (no tenant to compare).
212
246
  def tenant_predicate_sql
213
247
  column = @configuration.tenant_enabled? && @configuration.resolved_tenant_column.to_s
214
- return '' unless column && [@model_class, PermissionForge::Resource].all? { |klass| klass.column_names.include?(column) }
248
+ return '' unless column && [@model_class, PermissionForge::Resource].all? { |klass| SchemaRead.column?(klass, column, ignored: false) }
215
249
 
216
250
  quoted = @conn.quote_column_name(column)
217
251
  "AND r.#{quoted} = #{quoted_table}.#{quoted}"
218
252
  end
219
253
 
220
- # Mirrors PermissionSet.unconditional (activation conditions absent).
254
+ # PermissionSet.unconditional, on the `ps` alias: the one definition of
255
+ # "no activation condition".
221
256
  def unconditional_set_sql
222
- "(ps.requires_session_flag IS NULL OR ps.requires_session_flag = '') " \
223
- "AND (ps.allowed_ips IS NULL OR ps.allowed_ips = '') " \
224
- 'AND ps.allowed_hours_start IS NULL'
257
+ "NOT #{PermissionForge::PermissionSet.conditions_present_sql(table: 'ps')}"
225
258
  end
226
259
 
227
260
  # One conjunct per rule-bearing granting set:
@@ -255,9 +288,11 @@ module PermissionForge
255
288
  SQL
256
289
  end
257
290
 
291
+ # PermissionSetUser.active_assignments: an assignment counts until its
292
+ # expires_at, that instant included.
258
293
  def assignment_active_sql
259
- clauses = ['(psu.expires_at IS NULL OR psu.expires_at > now())']
260
- clauses << 'psu.is_active = TRUE' if PermissionForge::PermissionSetUser.column_names.include?('is_active')
294
+ clauses = ['(psu.expires_at IS NULL OR psu.expires_at >= now())']
295
+ clauses << 'psu.is_active = TRUE' if PermissionForge::SchemaRead.column?(PermissionForge::PermissionSetUser, 'is_active', ignored: false)
261
296
  clauses.join(' AND ')
262
297
  end
263
298
 
@@ -273,9 +308,14 @@ module PermissionForge
273
308
  @conn.quote_table_name(PermissionForge::Resource.table_name)
274
309
  end
275
310
 
276
- # A value the column cannot hold makes the rule FALSE — the app layer
277
- # matches nothing for it, NULL rows included.
311
+ # A rule the app layer matches nothing for (Operators::RuleProblem: the
312
+ # one taxonomy — a join type or operator this version does not know, a
313
+ # path no join is built through, a qualifier naming no table, a column
314
+ # the table does not have, a value it cannot hold) makes the rule FALSE
315
+ # — NULL rows included.
278
316
  def rule_sql(rule)
317
+ return 'FALSE' if Operators::RuleProblem.for(rule)
318
+
279
319
  operator, field_name = rule.values_at(:operator, :field_name)
280
320
  value = typed_value(operator, rule.field_value, Operators::RuleColumn.find(field_name, [@model_class]))
281
321
  return 'FALSE' if value.equal?(UNUSABLE)
@@ -343,10 +383,14 @@ module PermissionForge
343
383
  PermissionForge::PermissionSetRule.active.where(resource_id: resources.select(:id)).includes(:permission_set)
344
384
  end
345
385
 
346
- # A field_name that is not a name is translated, whatever the rule is:
347
- # to FALSE, as the app layer matches nothing for it.
386
+ # nil for a rule that is compiled, else why it is skipped (reported in
387
+ # :skipped_rules). A rule the app layer matches nothing for
388
+ # (Operators::RuleProblem) is compiled whatever it is — to FALSE
389
+ # (rule_sql) — never skipped, which would leave the policy wider than
390
+ # the app layer. An operator the app layer has (a custom one registered
391
+ # in Operators::Registry) but this module has no SQL for is skipped.
348
392
  def untranslatable_reason(rule)
349
- return unless Operators::RuleColumn.name?(rule.field_name)
393
+ return if Operators::RuleProblem.for(rule)
350
394
  return :join_rule if rule.join_rule?
351
395
  return :dotted_field_name if rule.field_name.to_s.include?('.')
352
396
  return :unknown_operator unless OPERATOR_SQL.key?(rule.operator)
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Services
5
+ # The read-access gate of an RLS policy under
6
+ # `config.no_read_access_strategy = :deny`: the session user sees a row
7
+ # only when RecordReadAccess would let them read the model's records at
8
+ # all (ResourceChain.readable?) — an entry along the model's Resource
9
+ # chain, and every entry there grants read_access. Nothing under
10
+ # :rules_only (the policy applies the rules alone, as the app layer
11
+ # does). Extracted from {RlsPolicyGenerator} for SRP; it calls back into
12
+ # the generator's quoting, session-user and table helpers.
13
+ #
14
+ # Read per statement, as the app layer's hash is built from the rows:
15
+ # - an entry for a name: an active, unexpired assignment to an active
16
+ # granting set with an active Resource row of that name (with tenancy
17
+ # on, of the protected row's tenant, as the view_all bypass);
18
+ # - it grants read: such a row with read_access, in a set without an
19
+ # activation condition (PermissionSet.unconditional: a condition
20
+ # cannot be judged in a policy, so a conditional set never grants
21
+ # read there — its row counts as an entry without read);
22
+ # - read taken: an active Resource row with read_access in an active
23
+ # muting set the user holds (conditional or not) naming the entry's
24
+ # name or an ancestor's — a muting row reaches every entry whose
25
+ # chain names it (PermissionHashBuilder's muting phase).
26
+ # Each difference is narrower than the app layer, never wider.
27
+ #
28
+ # Single-table inheritance: the policy is per table and compiled for the
29
+ # class that includes the concern, so every row needs that class's chain
30
+ # readable; a row of a loaded STI subclass (its inheritance column holds
31
+ # the subclass's sti_name) also needs each entry of the subclass's own
32
+ # names to grant read when the user has one. That is the subclass's
33
+ # chain rule, never wider than a read through the base class or through
34
+ # the subclass (narrower than the subclass's when only its own Resource
35
+ # grants read: the base class's reads see nothing then).
36
+ #
37
+ # @api private
38
+ module RlsReadAccessSql
39
+ private
40
+
41
+ # The gate, or nil under :rules_only. Part of the digest: switching the
42
+ # strategy makes the policy stale.
43
+ def read_access_sql
44
+ return unless @configuration.deny_record_reads_without_read_access?
45
+
46
+ own = ResourceChain.names(@model_class)
47
+ gates = [chain_readable_sql(own), *sti_subclasses.map { |klass| subclass_readable_sql(klass, own) }]
48
+ "(#{gates.join(' AND ')})"
49
+ end
50
+
51
+ # ResourceChain.readable?: an entry along the chain, none without read.
52
+ # One name: its entry grants read (an entry that grants read exists).
53
+ def chain_readable_sql(names)
54
+ return readable_entry_sql(names.first, names) if names.one?
55
+
56
+ any_entry = names.map { |name| entry_exists_sql(name) }.join(' OR ')
57
+ "((#{any_entry}) AND #{entries_readable_sql(names, names)})"
58
+ end
59
+
60
+ # A row of the subclass: each of its own names (those the compiled
61
+ # class does not answer to) grants read when the user has an entry.
62
+ def subclass_readable_sql(klass, own)
63
+ chain = ResourceChain.names(klass)
64
+ "(#{inheritance_column_sql} IS DISTINCT FROM #{connection.quote(klass.sti_name)} " \
65
+ "OR #{entries_readable_sql(chain - own, chain)})"
66
+ end
67
+
68
+ # For each name: no entry, or an entry that grants read.
69
+ def entries_readable_sql(names, chain)
70
+ checks = names.map do |name|
71
+ "(NOT #{entry_exists_sql(name)} OR #{readable_entry_sql(name, chain)})"
72
+ end
73
+ "(#{checks.join(' AND ')})"
74
+ end
75
+
76
+ # The entry of the name grants read and no muting row takes it: one
77
+ # naming it or a name after it along the chain (an ancestor's).
78
+ def readable_entry_sql(name, chain)
79
+ "(#{entry_exists_sql(name, read: true)} AND NOT #{read_muted_sql(chain.drop(chain.index(name)))})"
80
+ end
81
+
82
+ def entry_exists_sql(name, read: false)
83
+ resource_row_exists_sql(
84
+ muting: false, names: [name],
85
+ extra: read ? [unconditional_set_sql, 'r.read_access = TRUE'] : []
86
+ )
87
+ end
88
+
89
+ def read_muted_sql(names)
90
+ resource_row_exists_sql(muting: true, names: names, extra: ['r.read_access = TRUE'])
91
+ end
92
+
93
+ # The loaded STI subclasses on the table (a subclass with a table of
94
+ # its own is another policy's business), each with a name the compiled
95
+ # class does not answer to, in name order (the digest is stable).
96
+ # None without an inheritance column.
97
+ def sti_subclasses
98
+ return [] unless SchemaRead.column?(@model_class, @model_class.inheritance_column, ignored: false)
99
+
100
+ own = ResourceChain.names(@model_class).map(&:to_s)
101
+ @model_class.descendants.select { |klass| klass.table_name == table_name && klass.name && own.exclude?(klass.name) }
102
+ .sort_by(&:name)
103
+ end
104
+
105
+ # Whether the session user holds, through an active, unexpired
106
+ # assignment, an active granting (or muting) set with an active
107
+ # Resource row of one of the names — of the protected row's tenant
108
+ # when tenancy is on — meeting the extra conditions. The read-access
109
+ # gate reads it, and so does the generator's view_all bypass.
110
+ def resource_row_exists_sql(muting:, names:, extra: [])
111
+ conditions = [
112
+ "psu.user_id = (#{uid_sql})", assignment_active_sql,
113
+ "ps.is_active = TRUE AND ps.is_muting = #{muting ? 'TRUE' : 'FALSE'}",
114
+ resource_names_sql(names), 'r.is_active = TRUE',
115
+ tenant_predicate_sql.delete_prefix('AND ').presence,
116
+ *extra
117
+ ]
118
+ "EXISTS ( SELECT 1 FROM #{psu_table} psu JOIN #{ps_table} ps ON ps.id = psu.permission_set_id " \
119
+ "JOIN #{resources_table} r ON r.permission_set_id = ps.id WHERE #{conditions.compact.join(' AND ')} )"
120
+ end
121
+
122
+ def resource_names_sql(names)
123
+ "r.name IN (#{names.map { |name| connection.quote(name.to_s) }.join(', ')})"
124
+ end
125
+
126
+ def inheritance_column_sql
127
+ "#{quoted_table}.#{connection.quote_column_name(@model_class.inheritance_column)}"
128
+ end
129
+ end
130
+ end
131
+ end
@@ -3,10 +3,12 @@
3
3
  module PermissionForge
4
4
  module Services
5
5
  module SecurityAuditChecks
6
- # Controller actions as permissions: managed actions nobody is permitted
7
- # to run, managed actions whose controller does not run the guard, rows
8
- # whose name can never be an action key, rows that match no controller
9
- # action, and discovery switched on while no controller includes the guard.
6
+ # Controller actions as permissions: guarded controller actions no
7
+ # Permission Set manages (refused under the default strategy, open under
8
+ # :allow), managed actions nobody is permitted to run, managed actions
9
+ # whose controller does not run the guard, rows whose name can never be
10
+ # an action key, rows that match no controller action, and discovery
11
+ # switched on while no controller includes the guard.
10
12
  class ActionCheck < BaseCheck
11
13
  # One action row in effect (PermissionForge::ResourceAction.in_effect).
12
14
  LiveRow = Struct.new(:tenant, :resource, :action, :permitted, :muting, :set) do
@@ -28,7 +30,8 @@ module PermissionForge
28
30
  end
29
31
 
30
32
  def call
31
- [ungranted_actions, unguarded_actions, invalid_keys, unlinked_actions, guard_missing].compact
33
+ [unmanaged_actions, actions_not_checked, ungranted_actions, unguarded_actions, invalid_keys, unlinked_actions,
34
+ guard_missing].compact
32
35
  end
33
36
 
34
37
  private
@@ -70,6 +73,47 @@ module PermissionForge
70
73
  "#{resource} › #{action}: #{unguarded}" unless unguarded.empty?
71
74
  end
72
75
 
76
+ # Mapped controller actions, in controllers that run the guard, that
77
+ # some tenant's Permission Sets do not manage: the guard hands them to
78
+ # unconfigured_action_strategy. Read from the routes whether or not
79
+ # discovery is on, as the guard runs either way, so an app upgrading
80
+ # past the :allow default sees what now answers 403 before it deploys.
81
+ def unmanaged_actions
82
+ items = UnmanagedActions.build(entries: catalog.mapped_entries, configuration: configuration).items
83
+ return if items.empty?
84
+
85
+ if configuration.allow_unconfigured_actions?
86
+ finding(:info, :unmanaged_actions_allowed, 'Guarded actions no Permission Set manages run for everyone',
87
+ 'unconfigured_action_strategy is :allow, so every signed-in user can run these controller ' \
88
+ 'actions: no Permission Set manages them (unconfigured_actions_allowed rates the setting). ' \
89
+ 'Manage each one in a Permission Set before you switch the strategy back to :block, the default.',
90
+ items: items)
91
+ else
92
+ finding(:low, :unmanaged_actions_refused, 'Guarded actions no Permission Set manages are refused',
93
+ 'These controller actions map to a Resource and run the guard, but no Permission Set manages ' \
94
+ "them (with tenancy: in the tenants named), so the guard refuses them (403) #{UnmanagedActions.refused_to(configuration)} " \
95
+ '(unconfigured_action_strategy :block, the default since 0.15) — unless the controller skips ' \
96
+ 'the guard for that action (skip_before_action :pf_authorize_action!, only: …), which the audit ' \
97
+ 'does not see. Give each one a row in the Permission Sets that should run it; an action whose ' \
98
+ 'name is not a valid action key needs a key first (permission_forge_actions actions: { ... }). ' \
99
+ 'Or set unconfigured_action_strategy = :allow to let every signed-in user run them, as before 0.15.',
100
+ items: items)
101
+ end
102
+ end
103
+
104
+ # The routes the catalog could not judge: the lists above may miss them.
105
+ def actions_not_checked
106
+ skipped = catalog.skipped
107
+ count = skipped.size
108
+ return if count.zero?
109
+
110
+ finding(:low, :actions_not_checked, "#{count} #{'controller action'.pluralize(count)} could not be checked (see the log)",
111
+ 'A controller failed to load, or the controller_action_resolver or the mapping raised, so the ' \
112
+ 'audit could not tell whether these run the guard or what they map to. They are missing from ' \
113
+ 'unmanaged_actions_refused (and the other action findings); at run time the same error fails the ' \
114
+ 'request. Fix the error the log names and run the audit again.', items: skipped)
115
+ end
116
+
73
117
  # Managed (a row is in effect) but permitted by no granting set: the
74
118
  # guard and can? refuse everyone.
75
119
  def ungranted_actions
@@ -19,8 +19,9 @@ module PermissionForge
19
19
  PermissionForge::PermissionSetUser.active_assignments
20
20
  end
21
21
 
22
+ # The admin's threshold (Admin::AttentionItems.stale_days, the one definition).
22
23
  def stale_days
23
- configuration.try(:admin_stale_assignment_days) || 90
24
+ PermissionForge::Admin::AttentionItems.stale_days(configuration)
24
25
  end
25
26
 
26
27
  def stale
@@ -29,9 +29,7 @@ module PermissionForge
29
29
  def table?
30
30
  return @table if defined?(@table)
31
31
 
32
- @table = audit_events.table_exists?
33
- rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
34
- @table = nil
32
+ @table = PermissionForge::DatabaseReach.table_exists?(audit_events) { return @table = nil }
35
33
  end
36
34
 
37
35
  def table_missing