permission_forge 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (406) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +1138 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +356 -0
  5. data/app/controllers/concerns/permission_forge/acting_admin.rb +17 -0
  6. data/app/controllers/concerns/permission_forge/api/crud_actions.rb +84 -0
  7. data/app/controllers/concerns/permission_forge/api/nested_resource_lookup.rb +18 -0
  8. data/app/controllers/concerns/permission_forge/api/pagination.rb +50 -0
  9. data/app/controllers/concerns/permission_forge/api/response_renderer.rb +65 -0
  10. data/app/controllers/concerns/permission_forge/api/serializer_resolver.rb +30 -0
  11. data/app/controllers/concerns/permission_forge/api_authentication.rb +40 -0
  12. data/app/controllers/concerns/permission_forge/assignment_authorization.rb +97 -0
  13. data/app/controllers/concerns/permission_forge/audit_log_gate.rb +55 -0
  14. data/app/controllers/concerns/permission_forge/basic_authentication.rb +77 -0
  15. data/app/controllers/concerns/permission_forge/cache_confirmation.rb +32 -0
  16. data/app/controllers/concerns/permission_forge/forgery_protection.rb +29 -0
  17. data/app/controllers/concerns/permission_forge/html_format.rb +40 -0
  18. data/app/controllers/concerns/permission_forge/param_shapes.rb +87 -0
  19. data/app/controllers/concerns/permission_forge/tenant_required.rb +42 -0
  20. data/app/controllers/permission_forge/api/audit_events_controller.rb +33 -0
  21. data/app/controllers/permission_forge/api/base_controller.rb +220 -0
  22. data/app/controllers/permission_forge/api/dashboard_controller.rb +30 -0
  23. data/app/controllers/permission_forge/api/permission_set_group_users_controller.rb +51 -0
  24. data/app/controllers/permission_forge/api/permission_set_groups_controller.rb +71 -0
  25. data/app/controllers/permission_forge/api/permission_set_rules_controller.rb +37 -0
  26. data/app/controllers/permission_forge/api/permission_set_users_controller.rb +105 -0
  27. data/app/controllers/permission_forge/api/permission_sets_controller.rb +87 -0
  28. data/app/controllers/permission_forge/api/resource_actions_controller.rb +58 -0
  29. data/app/controllers/permission_forge/api/resource_fields_controller.rb +30 -0
  30. data/app/controllers/permission_forge/api/resources_controller.rb +56 -0
  31. data/app/controllers/permission_forge/application_controller.rb +198 -0
  32. data/app/controllers/permission_forge/assets_controller.rb +97 -0
  33. data/app/controllers/permission_forge/assignments_controller.rb +266 -0
  34. data/app/controllers/permission_forge/audit_events_controller.rb +94 -0
  35. data/app/controllers/permission_forge/bulk_assignments_controller.rb +108 -0
  36. data/app/controllers/permission_forge/field_lookups_controller.rb +38 -0
  37. data/app/controllers/permission_forge/home_controller.rb +24 -0
  38. data/app/controllers/permission_forge/maintenance_controller.rb +13 -0
  39. data/app/controllers/permission_forge/people_controller.rb +190 -0
  40. data/app/controllers/permission_forge/permission_set_editor.rb +71 -0
  41. data/app/controllers/permission_forge/permission_set_group_users_controller.rb +57 -0
  42. data/app/controllers/permission_forge/permission_set_groups_controller.rb +111 -0
  43. data/app/controllers/permission_forge/permission_set_rules_controller.rb +97 -0
  44. data/app/controllers/permission_forge/permission_sets_controller.rb +227 -0
  45. data/app/controllers/permission_forge/preferences_controller.rb +26 -0
  46. data/app/controllers/permission_forge/resource_actions_controller.rb +106 -0
  47. data/app/controllers/permission_forge/resource_fields_controller.rb +129 -0
  48. data/app/controllers/permission_forge/resources_controller.rb +189 -0
  49. data/app/controllers/permission_forge/settings_controller.rb +33 -0
  50. data/app/frontend/permission_forge/brand/apple-touch-icon.png +0 -0
  51. data/app/frontend/permission_forge/brand/chevron.svg +1 -0
  52. data/app/frontend/permission_forge/brand/favicon.ico +0 -0
  53. data/app/frontend/permission_forge/brand/favicon.svg +1 -0
  54. data/app/frontend/permission_forge/brand/logo-horizontal-dark.svg +1 -0
  55. data/app/frontend/permission_forge/brand/logo-horizontal-light.svg +1 -0
  56. data/app/frontend/permission_forge/brand/mark-small.svg +1 -0
  57. data/app/frontend/permission_forge/brand/mark.svg +1 -0
  58. data/app/frontend/permission_forge/pf_admin.css +766 -0
  59. data/app/frontend/permission_forge/pf_admin.js +482 -0
  60. data/app/helpers/permission_forge/application_helper.rb +167 -0
  61. data/app/helpers/permission_forge/audit_helper.rb +230 -0
  62. data/app/helpers/permission_forge/audit_sentences_helper.rb +84 -0
  63. data/app/helpers/permission_forge/badges_helper.rb +134 -0
  64. data/app/helpers/permission_forge/changes_helper.rb +117 -0
  65. data/app/helpers/permission_forge/people_helper.rb +118 -0
  66. data/app/helpers/permission_forge/sentences_helper.rb +175 -0
  67. data/app/helpers/permission_forge/settings_helper.rb +152 -0
  68. data/app/helpers/permission_forge/ui_helper.rb +184 -0
  69. data/app/models/permission_forge/audit_event.rb +159 -0
  70. data/app/models/permission_forge/permission_set.rb +100 -0
  71. data/app/models/permission_forge/permission_set_group.rb +83 -0
  72. data/app/models/permission_forge/permission_set_group_user.rb +43 -0
  73. data/app/models/permission_forge/permission_set_rule.rb +99 -0
  74. data/app/models/permission_forge/permission_set_user.rb +158 -0
  75. data/app/models/permission_forge/resource.rb +100 -0
  76. data/app/models/permission_forge/resource_action.rb +58 -0
  77. data/app/models/permission_forge/resource_field.rb +69 -0
  78. data/app/views/layouts/permission_forge/application.html.erb +30 -0
  79. data/app/views/layouts/permission_forge/fragment.html.erb +2 -0
  80. data/app/views/permission_forge/assignments/_person_options.html.erb +26 -0
  81. data/app/views/permission_forge/assignments/confirm_destroy.html.erb +24 -0
  82. data/app/views/permission_forge/assignments/new.html.erb +99 -0
  83. data/app/views/permission_forge/audit_events/index.html.erb +161 -0
  84. data/app/views/permission_forge/audit_events/show.html.erb +146 -0
  85. data/app/views/permission_forge/bulk_assignments/confirm_revoke.html.erb +21 -0
  86. data/app/views/permission_forge/bulk_assignments/new.html.erb +69 -0
  87. data/app/views/permission_forge/field_lookups/show.html.erb +32 -0
  88. data/app/views/permission_forge/home/_audit_card.html.erb +19 -0
  89. data/app/views/permission_forge/home/_ending_card.html.erb +32 -0
  90. data/app/views/permission_forge/home/_expired_card.html.erb +34 -0
  91. data/app/views/permission_forge/home/_privacy_card.html.erb +67 -0
  92. data/app/views/permission_forge/home/_rules_card.html.erb +23 -0
  93. data/app/views/permission_forge/home/_stale_card.html.erb +39 -0
  94. data/app/views/permission_forge/home/_tidy_card.html.erb +18 -0
  95. data/app/views/permission_forge/home/index.html.erb +69 -0
  96. data/app/views/permission_forge/people/_access_tab.html.erb +58 -0
  97. data/app/views/permission_forge/people/_audit_tab.html.erb +93 -0
  98. data/app/views/permission_forge/people/_effective_field_source.html.erb +11 -0
  99. data/app/views/permission_forge/people/_effective_tab.html.erb +118 -0
  100. data/app/views/permission_forge/people/_effective_why.html.erb +48 -0
  101. data/app/views/permission_forge/people/_why_item.html.erb +19 -0
  102. data/app/views/permission_forge/people/confirm_remove_all.html.erb +27 -0
  103. data/app/views/permission_forge/people/consent_preview.html.erb +39 -0
  104. data/app/views/permission_forge/people/erasure_preview.html.erb +28 -0
  105. data/app/views/permission_forge/people/index.html.erb +78 -0
  106. data/app/views/permission_forge/people/show.html.erb +54 -0
  107. data/app/views/permission_forge/permission_set_groups/confirm_destroy.html.erb +12 -0
  108. data/app/views/permission_forge/permission_set_groups/index.html.erb +55 -0
  109. data/app/views/permission_forge/permission_set_groups/show.html.erb +100 -0
  110. data/app/views/permission_forge/permission_set_rules/_builder.html.erb +145 -0
  111. data/app/views/permission_forge/permission_set_rules/_value_input.html.erb +42 -0
  112. data/app/views/permission_forge/permission_set_rules/confirm_destroy.html.erb +13 -0
  113. data/app/views/permission_forge/permission_set_rules/edit.html.erb +3 -0
  114. data/app/views/permission_forge/permission_set_rules/new.html.erb +3 -0
  115. data/app/views/permission_forge/permission_sets/_assignments_tab.html.erb +74 -0
  116. data/app/views/permission_forge/permission_sets/_audit_tab.html.erb +80 -0
  117. data/app/views/permission_forge/permission_sets/_conditions_tab.html.erb +83 -0
  118. data/app/views/permission_forge/permission_sets/_header.html.erb +36 -0
  119. data/app/views/permission_forge/permission_sets/_records_summary.html.erb +16 -0
  120. data/app/views/permission_forge/permission_sets/_resource_summary.html.erb +11 -0
  121. data/app/views/permission_forge/permission_sets/_resources_tab.html.erb +49 -0
  122. data/app/views/permission_forge/permission_sets/confirm_destroy.html.erb +23 -0
  123. data/app/views/permission_forge/permission_sets/confirm_switch_mode.html.erb +15 -0
  124. data/app/views/permission_forge/permission_sets/index.html.erb +83 -0
  125. data/app/views/permission_forge/permission_sets/new.html.erb +63 -0
  126. data/app/views/permission_forge/permission_sets/review.html.erb +26 -0
  127. data/app/views/permission_forge/permission_sets/show.html.erb +50 -0
  128. data/app/views/permission_forge/resource_actions/_across_sets.html.erb +24 -0
  129. data/app/views/permission_forge/resource_actions/_add_select.html.erb +12 -0
  130. data/app/views/permission_forge/resource_actions/_effect.html.erb +25 -0
  131. data/app/views/permission_forge/resource_actions/_endpoints.html.erb +16 -0
  132. data/app/views/permission_forge/resource_actions/_form.html.erb +44 -0
  133. data/app/views/permission_forge/resource_actions/_managed_note.html.erb +11 -0
  134. data/app/views/permission_forge/resource_actions/confirm_destroy.html.erb +13 -0
  135. data/app/views/permission_forge/resource_actions/new.html.erb +31 -0
  136. data/app/views/permission_forge/resource_actions/show.html.erb +39 -0
  137. data/app/views/permission_forge/resource_fields/_explain.html.erb +24 -0
  138. data/app/views/permission_forge/resource_fields/show.html.erb +186 -0
  139. data/app/views/permission_forge/resources/_actions_tab.html.erb +88 -0
  140. data/app/views/permission_forge/resources/_fields_tab.html.erb +89 -0
  141. data/app/views/permission_forge/resources/_rules_tab.html.erb +55 -0
  142. data/app/views/permission_forge/resources/_sync.html.erb +57 -0
  143. data/app/views/permission_forge/resources/confirm_destroy.html.erb +17 -0
  144. data/app/views/permission_forge/resources/new.html.erb +57 -0
  145. data/app/views/permission_forge/resources/show.html.erb +117 -0
  146. data/app/views/permission_forge/settings/_ladder.html.erb +15 -0
  147. data/app/views/permission_forge/settings/_row.html.erb +38 -0
  148. data/app/views/permission_forge/settings/show.html.erb +67 -0
  149. data/app/views/permission_forge/shared/_across_sets.html.erb +31 -0
  150. data/app/views/permission_forge/shared/_audit_events.html.erb +36 -0
  151. data/app/views/permission_forge/shared/_audit_footer.html.erb +16 -0
  152. data/app/views/permission_forge/shared/_audit_off_note.html.erb +15 -0
  153. data/app/views/permission_forge/shared/_audit_pagination.html.erb +19 -0
  154. data/app/views/permission_forge/shared/_audit_refused_note.html.erb +7 -0
  155. data/app/views/permission_forge/shared/_audit_timeline.html.erb +29 -0
  156. data/app/views/permission_forge/shared/_crud_cell.html.erb +18 -0
  157. data/app/views/permission_forge/shared/_dialog_frame.html.erb +11 -0
  158. data/app/views/permission_forge/shared/_expiry_picker.html.erb +27 -0
  159. data/app/views/permission_forge/shared/_head_extra.html.erb +3 -0
  160. data/app/views/permission_forge/shared/_help_banner.html.erb +10 -0
  161. data/app/views/permission_forge/shared/_level_control.html.erb +18 -0
  162. data/app/views/permission_forge/shared/_mode_banners.html.erb +22 -0
  163. data/app/views/permission_forge/shared/_nav.html.erb +12 -0
  164. data/app/views/permission_forge/shared/_preferences.html.erb +24 -0
  165. data/app/views/permission_forge/shared/_reason_field.html.erb +20 -0
  166. data/app/views/permission_forge/shared/_savebar.html.erb +13 -0
  167. data/app/views/permission_forge/shared/_sheet_frame.html.erb +22 -0
  168. data/app/views/permission_forge/shared/_sidebar.html.erb +18 -0
  169. data/app/views/permission_forge/shared/_tech_details.html.erb +20 -0
  170. data/app/views/permission_forge/shared/_toasts.html.erb +27 -0
  171. data/app/views/permission_forge/shared/_topbar.html.erb +15 -0
  172. data/app/views/permission_forge/shared/_user_select.html.erb +16 -0
  173. data/app/views/permission_forge/shared/audit_refused.html.erb +7 -0
  174. data/config/locales/permission_forge.en.yml +2115 -0
  175. data/config/locales/permission_forge.mk.yml +2166 -0
  176. data/config/locales/permission_forge.sq.yml +2164 -0
  177. data/config/routes.rb +143 -0
  178. data/lib/generators/permission_forge/install/install_generator.rb +105 -0
  179. data/lib/generators/permission_forge/install/templates/initializer.rb.tt +148 -0
  180. data/lib/generators/permission_forge/install/templates/migration.rb.tt +198 -0
  181. data/lib/generators/permission_forge/rls/rls_generator.rb +57 -0
  182. data/lib/generators/permission_forge/rls/templates/enable_permission_forge_rls.rb.tt +15 -0
  183. data/lib/generators/permission_forge/upgrade/templates/add_admin_ui_columns.rb.tt +8 -0
  184. data/lib/generators/permission_forge/upgrade/templates/add_audit_events.rb.tt +98 -0
  185. data/lib/generators/permission_forge/upgrade/templates/add_resource_fields_unique_index.rb.tt +69 -0
  186. data/lib/generators/permission_forge/upgrade/upgrade_generator.rb +66 -0
  187. data/lib/permission_forge/admin/action_across_sets.rb +76 -0
  188. data/lib/permission_forge/admin/action_rows.rb +156 -0
  189. data/lib/permission_forge/admin/attention_items.rb +207 -0
  190. data/lib/permission_forge/admin/audit_event_detail.rb +75 -0
  191. data/lib/permission_forge/admin/audit_log.rb +193 -0
  192. data/lib/permission_forge/admin/audit_log_days.rb +41 -0
  193. data/lib/permission_forge/admin/audit_lookups.rb +105 -0
  194. data/lib/permission_forge/admin/audit_name_history.rb +85 -0
  195. data/lib/permission_forge/admin/audit_trail.rb +169 -0
  196. data/lib/permission_forge/admin/bulk_people_matcher.rb +109 -0
  197. data/lib/permission_forge/admin/change_set.rb +123 -0
  198. data/lib/permission_forge/admin/change_set_parts/actions.rb +27 -0
  199. data/lib/permission_forge/admin/change_set_parts/base.rb +58 -0
  200. data/lib/permission_forge/admin/change_set_parts/context.rb +50 -0
  201. data/lib/permission_forge/admin/change_set_parts/field_edit.rb +67 -0
  202. data/lib/permission_forge/admin/change_set_parts/field_levels.rb +71 -0
  203. data/lib/permission_forge/admin/change_set_parts/new_actions.rb +91 -0
  204. data/lib/permission_forge/admin/change_set_parts/new_fields.rb +63 -0
  205. data/lib/permission_forge/admin/change_set_parts/resource_edit.rb +55 -0
  206. data/lib/permission_forge/admin/change_set_parts/resource_flags.rb +19 -0
  207. data/lib/permission_forge/admin/change_set_parts/set_attrs.rb +45 -0
  208. data/lib/permission_forge/admin/change_set_parts/values.rb +20 -0
  209. data/lib/permission_forge/admin/column_fields.rb +48 -0
  210. data/lib/permission_forge/admin/conditions_form.rb +78 -0
  211. data/lib/permission_forge/admin/controller_catalog.rb +166 -0
  212. data/lib/permission_forge/admin/csv.rb +41 -0
  213. data/lib/permission_forge/admin/editable_lock.rb +157 -0
  214. data/lib/permission_forge/admin/effective_access.rb +158 -0
  215. data/lib/permission_forge/admin/effective_fields.rb +176 -0
  216. data/lib/permission_forge/admin/expiry_presets.rb +51 -0
  217. data/lib/permission_forge/admin/exports/document.rb +89 -0
  218. data/lib/permission_forge/admin/exports/events.rb +51 -0
  219. data/lib/permission_forge/admin/exports/holdings.rb +118 -0
  220. data/lib/permission_forge/admin/exports.rb +37 -0
  221. data/lib/permission_forge/admin/field_across_sets.rb +51 -0
  222. data/lib/permission_forge/admin/field_detail.rb +112 -0
  223. data/lib/permission_forge/admin/field_level.rb +74 -0
  224. data/lib/permission_forge/admin/field_rows.rb +137 -0
  225. data/lib/permission_forge/admin/last_changes.rb +94 -0
  226. data/lib/permission_forge/admin/model_catalog.rb +216 -0
  227. data/lib/permission_forge/admin/people_candidates.rb +108 -0
  228. data/lib/permission_forge/admin/people_directory.rb +158 -0
  229. data/lib/permission_forge/admin/permission_set_factory.rb +48 -0
  230. data/lib/permission_forge/admin/person_label.rb +95 -0
  231. data/lib/permission_forge/admin/record_id.rb +19 -0
  232. data/lib/permission_forge/admin/rule_builder.rb +58 -0
  233. data/lib/permission_forge/admin/rule_form.rb +175 -0
  234. data/lib/permission_forge/admin/rule_form_input.rb +74 -0
  235. data/lib/permission_forge/admin/sensitive_columns.rb +68 -0
  236. data/lib/permission_forge/admin/settings_catalog.rb +261 -0
  237. data/lib/permission_forge/admin/settings_values.rb +130 -0
  238. data/lib/permission_forge/api/serializers/audit_event_serializer.rb +27 -0
  239. data/lib/permission_forge/api/serializers/base_serializer.rb +36 -0
  240. data/lib/permission_forge/api/serializers/permission_set_group_serializer.rb +12 -0
  241. data/lib/permission_forge/api/serializers/permission_set_group_user_serializer.rb +11 -0
  242. data/lib/permission_forge/api/serializers/permission_set_rule_serializer.rb +25 -0
  243. data/lib/permission_forge/api/serializers/permission_set_serializer.rb +13 -0
  244. data/lib/permission_forge/api/serializers/permission_set_user_serializer.rb +11 -0
  245. data/lib/permission_forge/api/serializers/resource_action_serializer.rb +13 -0
  246. data/lib/permission_forge/api/serializers/resource_field_serializer.rb +14 -0
  247. data/lib/permission_forge/api/serializers/resource_serializer.rb +15 -0
  248. data/lib/permission_forge/audit/actor.rb +144 -0
  249. data/lib/permission_forge/audit/actor_resolver.rb +103 -0
  250. data/lib/permission_forge/audit/audited_views.rb +208 -0
  251. data/lib/permission_forge/audit/boot_warnings.rb +58 -0
  252. data/lib/permission_forge/audit/column_values.rb +56 -0
  253. data/lib/permission_forge/audit/context.rb +98 -0
  254. data/lib/permission_forge/audit/detached.rb +62 -0
  255. data/lib/permission_forge/audit/event_catalog.rb +110 -0
  256. data/lib/permission_forge/audit/event_query.rb +192 -0
  257. data/lib/permission_forge/audit/failure.rb +47 -0
  258. data/lib/permission_forge/audit/field_access.rb +186 -0
  259. data/lib/permission_forge/audit/gate.rb +167 -0
  260. data/lib/permission_forge/audit/job_context.rb +32 -0
  261. data/lib/permission_forge/audit/log_access.rb +36 -0
  262. data/lib/permission_forge/audit/recorder.rb +168 -0
  263. data/lib/permission_forge/audit/refusals.rb +61 -0
  264. data/lib/permission_forge/audit/request_context.rb +152 -0
  265. data/lib/permission_forge/audit/runtime.rb +178 -0
  266. data/lib/permission_forge/audit/runtime_emission.rb +135 -0
  267. data/lib/permission_forge/audit/settings_observer.rb +70 -0
  268. data/lib/permission_forge/audit/source.rb +54 -0
  269. data/lib/permission_forge/audit/subscribers/base.rb +69 -0
  270. data/lib/permission_forge/audit/subscribers/legacy_hooks.rb +97 -0
  271. data/lib/permission_forge/audit/subscribers/log.rb +45 -0
  272. data/lib/permission_forge/audit/subscribers.rb +58 -0
  273. data/lib/permission_forge/audit.rb +302 -0
  274. data/lib/permission_forge/boot_checks.rb +184 -0
  275. data/lib/permission_forge/cache/backlog.rb +142 -0
  276. data/lib/permission_forge/cache/base.rb +200 -0
  277. data/lib/permission_forge/cache/bulk_invalidation.rb +131 -0
  278. data/lib/permission_forge/cache/failure.rb +171 -0
  279. data/lib/permission_forge/cache/handled_errors.rb +82 -0
  280. data/lib/permission_forge/cache/holder_invalidation.rb +85 -0
  281. data/lib/permission_forge/cache/invalidation.rb +180 -0
  282. data/lib/permission_forge/cache/memory_cache.rb +77 -0
  283. data/lib/permission_forge/cache/null_cache.rb +40 -0
  284. data/lib/permission_forge/cache/rails_cache.rb +231 -0
  285. data/lib/permission_forge/cache/rebuild.rb +149 -0
  286. data/lib/permission_forge/cache/settlement.rb +63 -0
  287. data/lib/permission_forge/cache/user_keys.rb +206 -0
  288. data/lib/permission_forge/cache/variant_removal.rb +55 -0
  289. data/lib/permission_forge/cache/withheld_notice.rb +60 -0
  290. data/lib/permission_forge/concerns/audited_collection.rb +34 -0
  291. data/lib/permission_forge/concerns/auto_permission_scoping.rb +158 -0
  292. data/lib/permission_forge/concerns/controller_action_guard.rb +106 -0
  293. data/lib/permission_forge/concerns/field_mask_checker.rb +45 -0
  294. data/lib/permission_forge/concerns/has_active_scope.rb +12 -0
  295. data/lib/permission_forge/concerns/has_audit_trail.rb +59 -0
  296. data/lib/permission_forge/concerns/has_permission_set_users.rb +115 -0
  297. data/lib/permission_forge/concerns/has_tenant_scoping.rb +86 -0
  298. data/lib/permission_forge/concerns/has_uuid.rb +24 -0
  299. data/lib/permission_forge/concerns/invalidates_in_batch.rb +207 -0
  300. data/lib/permission_forge/concerns/invalidates_managed_actions.rb +80 -0
  301. data/lib/permission_forge/concerns/masked_attributes/alias_access.rb +280 -0
  302. data/lib/permission_forge/concerns/masked_attributes/store_access.rb +100 -0
  303. data/lib/permission_forge/concerns/masked_attributes/validation_reads.rb +153 -0
  304. data/lib/permission_forge/concerns/masked_attributes/virtual_readers.rb +242 -0
  305. data/lib/permission_forge/concerns/masked_attributes.rb +576 -0
  306. data/lib/permission_forge/concerns/permission_cache_accessor.rb +416 -0
  307. data/lib/permission_forge/concerns/permission_scoped_findable.rb +337 -0
  308. data/lib/permission_forge/concerns/permissioned_user.rb +78 -0
  309. data/lib/permission_forge/concerns/permissions_memo.rb +160 -0
  310. data/lib/permission_forge/concerns/permissions_per_context.rb +288 -0
  311. data/lib/permission_forge/concerns/permissions_regenerator.rb +43 -0
  312. data/lib/permission_forge/concerns/records_audit_events.rb +286 -0
  313. data/lib/permission_forge/concerns/resource_permission_checker.rb +116 -0
  314. data/lib/permission_forge/concerns/validates_column_lengths.rb +69 -0
  315. data/lib/permission_forge/concerns/validates_dependency.rb +54 -0
  316. data/lib/permission_forge/conditional_sets.rb +361 -0
  317. data/lib/permission_forge/configuration/admin_ui_settings.rb +108 -0
  318. data/lib/permission_forge/configuration/audit_settings.rb +184 -0
  319. data/lib/permission_forge/configuration/cache_settings.rb +76 -0
  320. data/lib/permission_forge/configuration/controller_action_settings.rb +112 -0
  321. data/lib/permission_forge/configuration/gdpr_settings.rb +70 -0
  322. data/lib/permission_forge/configuration/masked_attributes_settings.rb +83 -0
  323. data/lib/permission_forge/configuration/permits_attributes_settings.rb +26 -0
  324. data/lib/permission_forge/configuration/presets.rb +139 -0
  325. data/lib/permission_forge/configuration/query_scoping_settings.rb +107 -0
  326. data/lib/permission_forge/configuration/strategy_values.rb +57 -0
  327. data/lib/permission_forge/configuration/virtual_attributes_settings.rb +65 -0
  328. data/lib/permission_forge/configuration.rb +624 -0
  329. data/lib/permission_forge/controller_actions/authorizer.rb +171 -0
  330. data/lib/permission_forge/controller_actions/managed_index.rb +213 -0
  331. data/lib/permission_forge/controller_actions/mapping.rb +167 -0
  332. data/lib/permission_forge/controller_actions.rb +122 -0
  333. data/lib/permission_forge/deprecation.rb +86 -0
  334. data/lib/permission_forge/engine.rb +113 -0
  335. data/lib/permission_forge/execution_state.rb +318 -0
  336. data/lib/permission_forge/invalidation_batch.rb +206 -0
  337. data/lib/permission_forge/masked_models.rb +226 -0
  338. data/lib/permission_forge/masking_sync.rb +271 -0
  339. data/lib/permission_forge/model_bindings.rb +90 -0
  340. data/lib/permission_forge/open_transaction.rb +303 -0
  341. data/lib/permission_forge/operators/constants.rb +41 -0
  342. data/lib/permission_forge/operators/registry.rb +300 -0
  343. data/lib/permission_forge/operators/rule_column.rb +120 -0
  344. data/lib/permission_forge/operators/validations.rb +111 -0
  345. data/lib/permission_forge/operators/value_casters/number.rb +68 -0
  346. data/lib/permission_forge/operators/value_casters/temporal.rb +69 -0
  347. data/lib/permission_forge/operators/value_casters.rb +80 -0
  348. data/lib/permission_forge/operators/value_typing.rb +115 -0
  349. data/lib/permission_forge/query_scoping_warnings.rb +68 -0
  350. data/lib/permission_forge/rake_audit.rb +65 -0
  351. data/lib/permission_forge/resource_chain.rb +190 -0
  352. data/lib/permission_forge/rls.rb +209 -0
  353. data/lib/permission_forge/services/attribute_access_guard.rb +138 -0
  354. data/lib/permission_forge/services/audit_event_purger.rb +150 -0
  355. data/lib/permission_forge/services/bulk_permission_set_assignment.rb +108 -0
  356. data/lib/permission_forge/services/concerns/across_tenants.rb +69 -0
  357. data/lib/permission_forge/services/concerns/audited_operation.rb +43 -0
  358. data/lib/permission_forge/services/concerns/clonable.rb +29 -0
  359. data/lib/permission_forge/services/consent_withdrawal_handler.rb +135 -0
  360. data/lib/permission_forge/services/dashboard_stats.rb +43 -0
  361. data/lib/permission_forge/services/data_subject_erasure.rb +306 -0
  362. data/lib/permission_forge/services/expired_assignment_cleaner.rb +152 -0
  363. data/lib/permission_forge/services/extra_field_defaults.rb +71 -0
  364. data/lib/permission_forge/services/field_permission_merger.rb +83 -0
  365. data/lib/permission_forge/services/filters_response_attributes.rb +144 -0
  366. data/lib/permission_forge/services/gdpr_field_auditor/text_report.rb +63 -0
  367. data/lib/permission_forge/services/gdpr_field_auditor.rb +223 -0
  368. data/lib/permission_forge/services/host_key_guard.rb +110 -0
  369. data/lib/permission_forge/services/mask_value.rb +93 -0
  370. data/lib/permission_forge/services/permission_cache_clearer.rb +174 -0
  371. data/lib/permission_forge/services/permission_hash_builder.rb +305 -0
  372. data/lib/permission_forge/services/permission_rule_applier.rb +192 -0
  373. data/lib/permission_forge/services/permission_set_cloner.rb +89 -0
  374. data/lib/permission_forge/services/permission_set_condition_evaluator.rb +102 -0
  375. data/lib/permission_forge/services/permission_set_group_cloner.rb +52 -0
  376. data/lib/permission_forge/services/permits_attributes/nested_records.rb +106 -0
  377. data/lib/permission_forge/services/permits_attributes.rb +270 -0
  378. data/lib/permission_forge/services/resource_field_generator.rb +93 -0
  379. data/lib/permission_forge/services/resource_with_fields_builder.rb +47 -0
  380. data/lib/permission_forge/services/rls_operator_sql.rb +131 -0
  381. data/lib/permission_forge/services/rls_policy_generator.rb +365 -0
  382. data/lib/permission_forge/services/security_audit/action_check.rb +157 -0
  383. data/lib/permission_forge/services/security_audit/assignment_check.rb +120 -0
  384. data/lib/permission_forge/services/security_audit/audit_trail_check.rb +166 -0
  385. data/lib/permission_forge/services/security_audit/base_check.rb +57 -0
  386. data/lib/permission_forge/services/security_audit/config_check.rb +212 -0
  387. data/lib/permission_forge/services/security_audit/integrity_check.rb +105 -0
  388. data/lib/permission_forge/services/security_audit/personal_data_check.rb +79 -0
  389. data/lib/permission_forge/services/security_audit/privilege_check.rb +67 -0
  390. data/lib/permission_forge/services/security_audit/rule_check.rb +141 -0
  391. data/lib/permission_forge/services/security_audit/text_report.rb +46 -0
  392. data/lib/permission_forge/services/security_audit/user_model_check.rb +67 -0
  393. data/lib/permission_forge/services/security_audit.rb +75 -0
  394. data/lib/permission_forge/services/stale_assignment_auditor.rb +68 -0
  395. data/lib/permission_forge/services/unscoped_query_detector.rb +132 -0
  396. data/lib/permission_forge/version.rb +3 -0
  397. data/lib/permission_forge/virtual_fields/discovery.rb +65 -0
  398. data/lib/permission_forge/virtual_fields.rb +102 -0
  399. data/lib/permission_forge.rb +464 -0
  400. data/lib/tasks/permission_forge.rake +31 -0
  401. data/lib/tasks/permission_forge_audit_events.rake +28 -0
  402. data/lib/tasks/permission_forge_cache.rake +26 -0
  403. data/lib/tasks/permission_forge_expiration.rake +30 -0
  404. data/lib/tasks/permission_forge_gdpr.rake +69 -0
  405. data/lib/tasks/permission_forge_rls.rake +38 -0
  406. metadata +515 -0
@@ -0,0 +1,116 @@
1
+ module PermissionForge
2
+ module Concerns
3
+ # CRUD permission checks, generic action dispatch, and resource-level hash accessors.
4
+ # Depends on PermissionCacheAccessor for shared helpers.
5
+ #
6
+ # @api public
7
+ module ResourcePermissionChecker
8
+ extend ActiveSupport::Concern
9
+
10
+ # The words can? answers from the resource's CRUD flags, and the
11
+ # permission each one means. Anything else is a custom action.
12
+ # ControllerActions reads this table too, so the guard and can? always
13
+ # agree on which keys are CRUD.
14
+ CRUD_ACTIONS = { read: :read, create: :create, edit: :edit, update: :edit, delete: :delete, destroy: :delete }.freeze
15
+ CRUD_PREDICATES = { read: :can_read?, create: :can_create?, edit: :can_edit?, delete: :can_delete? }.freeze
16
+
17
+ # @param resource [Symbol, String] resource name (e.g., :Customer)
18
+ # @return [Boolean]
19
+ def can_read?(resource)
20
+ pf_check_resource_permission(resource, :read_access)
21
+ end
22
+
23
+ alias can_show? can_read?
24
+ alias has_read_access? can_read?
25
+
26
+ # @param resource [Symbol, String] resource name
27
+ # @return [Boolean]
28
+ def can_create?(resource)
29
+ pf_check_resource_permission(resource, :create_access)
30
+ end
31
+
32
+ alias can_create_new? can_create?
33
+ alias has_create_access? can_create?
34
+
35
+ # @param resource [Symbol, String] resource name
36
+ # @return [Boolean]
37
+ def can_edit?(resource)
38
+ pf_check_resource_permission(resource, :edit_access)
39
+ end
40
+
41
+ alias can_update? can_edit?
42
+ alias has_edit_access? can_edit?
43
+
44
+ # @param resource [Symbol, String] resource name
45
+ # @return [Boolean]
46
+ def can_delete?(resource)
47
+ pf_check_resource_permission(resource, :delete_access)
48
+ end
49
+
50
+ alias can_destroy? can_delete?
51
+ alias has_delete_access? can_delete?
52
+
53
+ # @param resource [Symbol, String] resource name
54
+ # @return [Boolean]
55
+ def can_view_all?(resource)
56
+ pf_check_resource_permission(resource, :view_all)
57
+ end
58
+
59
+ # @param resource [Symbol, String] resource name
60
+ # @return [Boolean]
61
+ def can_modify_all?(resource)
62
+ pf_check_resource_permission(resource, :modify_all)
63
+ end
64
+
65
+ # Returns the resource-level permissions hash (read_access, create_access, etc.).
66
+ #
67
+ # @param resource [Symbol, String] resource name
68
+ # @return [Hash] permission flags, or empty hash if resource not found
69
+ def permissions_for(resource)
70
+ perms = pf_resource_permissions(resource)
71
+ return {} if perms.blank?
72
+
73
+ perms
74
+ end
75
+
76
+ # Returns the full resource entry including permissions, fields, actions, and rules.
77
+ #
78
+ # @param resource [Symbol, String] resource name
79
+ # @return [Hash] full resource entry, or empty hash if not found
80
+ def permissions_hash_for(resource)
81
+ entry = pf_resource_name_permissions(resource)
82
+ return {} if entry.blank?
83
+
84
+ entry
85
+ end
86
+
87
+ # Checks if the user can perform an action on a resource.
88
+ # Delegates CRUD actions to specific helpers; custom actions check resource_actions.
89
+ #
90
+ # @param action [Symbol, String] action name (:read, :create, :edit, :delete, or custom)
91
+ # @param resource [Symbol, String] resource name
92
+ # @return [Boolean]
93
+ def can?(action, resource)
94
+ action = action.to_sym
95
+ resource = resource.to_s.to_sym unless resource.is_a?(Symbol)
96
+
97
+ crud = CRUD_ACTIONS[action]
98
+ return public_send(CRUD_PREDICATES.fetch(crud), resource) if crud
99
+
100
+ resource_hash = pf_resource_name_permissions(resource)
101
+ return false unless resource_hash
102
+
103
+ resource_hash.dig(:actions, action, :is_permitted) == true
104
+ end
105
+
106
+ private
107
+
108
+ def pf_check_resource_permission(resource, action)
109
+ perms = pf_resource_permissions(resource)
110
+ return false if perms.blank?
111
+
112
+ perms[action]
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,69 @@
1
+ module PermissionForge
2
+ module Concerns
3
+ # Refuses a string the database cannot hold as a validation error, so the
4
+ # admin and the API answer 422 where a strict MySQL raised ValueTooLong
5
+ # (a 500). The limit is the column's own, read from the schema: a
6
+ # `string` column's in characters, a `text` column's in bytes (MySQL
7
+ # TEXT holds 65,535 of them). A column that declares no limit (a
8
+ # PostgreSQL or SQLite `string`) is not checked, and an attribute with a
9
+ # length rule of its own at most as long as the column (PermissionSet#name)
10
+ # keeps that rule alone.
11
+ #
12
+ # Only attributes being written are checked: a longer value stored
13
+ # before (on a database that held it) leaves the row's other attributes
14
+ # editable.
15
+ #
16
+ # @api private
17
+ module ValidatesColumnLengths
18
+ extend ActiveSupport::Concern
19
+
20
+ # How each column type measures its limit.
21
+ MEASURES = {
22
+ string: :length,
23
+ text: :bytesize
24
+ }.freeze
25
+
26
+ included do
27
+ validate :validate_column_lengths
28
+ end
29
+
30
+ # The longest value a validator lets through; any other validator
31
+ # lets through anything.
32
+ def self.maximum_of(validator)
33
+ return Float::INFINITY unless validator.is_a?(ActiveModel::Validations::LengthValidator)
34
+
35
+ options = validator.options
36
+ options[:maximum] || options[:in]&.max || Float::INFINITY
37
+ end
38
+
39
+ private
40
+
41
+ def validate_column_lengths
42
+ self.class.columns.each do |column|
43
+ name = column.name
44
+ next unless column.limit && will_save_change_to_attribute?(name)
45
+
46
+ check_column_length(name, column)
47
+ end
48
+ end
49
+
50
+ def check_column_length(name, column)
51
+ measure = MEASURES[column.type]
52
+ value = read_attribute(name)
53
+ return unless measure && value.is_a?(String) && value.public_send(measure) > column.limit
54
+
55
+ limit = column.limit
56
+ return if stricter_length_rule?(name, limit)
57
+
58
+ errors.add(name, :too_long, count: limit,
59
+ message: ->(*) { I18n.t('permission_forge.validations.too_long', count: limit) })
60
+ end
61
+
62
+ # The attribute's own length rule already refuses everything the column
63
+ # cannot hold (its maximum is the column's or less): its message alone.
64
+ def stricter_length_rule?(name, limit)
65
+ self.class.validators_on(name).any? { |validator| ValidatesColumnLengths.maximum_of(validator) <= limit }
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PermissionForge
4
+ module Concerns
5
+ # Declarative validation for boolean permission field dependencies.
6
+ #
7
+ # @example
8
+ # class Resource < ActiveRecord::Base
9
+ # include PermissionForge::Concerns::ValidatesDependency
10
+ #
11
+ # validates_dependency :edit_access, requires: :read_access
12
+ # validates_dependency :delete_access, requires: :read_access
13
+ # end
14
+ #
15
+ # resource = Resource.new(edit_access: true, read_access: false)
16
+ # resource.valid? # => false
17
+ # resource.errors[:edit_access] # => ["requires read_access to be enabled"]
18
+ #
19
+ # @api private
20
+ module ValidatesDependency
21
+ extend ActiveSupport::Concern
22
+
23
+ private
24
+
25
+ # Checks if this record belongs to a muting permission set.
26
+ # Works for both Resource (direct) and ResourceField (via resource).
27
+ def belongs_to_muting_permission_set?
28
+ ps = if respond_to?(:permission_set)
29
+ permission_set
30
+ elsif respond_to?(:resource) && resource.respond_to?(:permission_set)
31
+ resource.permission_set
32
+ end
33
+ ps&.is_muting == true
34
+ end
35
+
36
+ class_methods do
37
+ # @param field [Symbol] the dependent boolean field
38
+ # @param requires [Symbol] the prerequisite boolean field
39
+ def validates_dependency(field, requires:)
40
+ method_name = :"validate_#{field}_requires_#{requires}"
41
+
42
+ validate method_name
43
+
44
+ define_method(method_name) do
45
+ return if belongs_to_muting_permission_set?
46
+
47
+ errors.add(field, "requires #{requires} to be enabled") if send(field) && !send(requires)
48
+ end
49
+ private method_name
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/sha2'
4
+ require 'securerandom'
5
+
6
+ module PermissionForge
7
+ # The permission sets with activation conditions (session flag, IP
8
+ # allowlist, login hours) among those a user holds, and the variant of the
9
+ # permission hash a request context selects among them.
10
+ #
11
+ # A condition is checked against the request that READS the hash, never
12
+ # against the one that happened to build it (an MFA-verified session, an
13
+ # office IP, a job without a context). So the shared cache keeps, per user:
14
+ #
15
+ # - one base entry under permission_cache_key: the hash of the
16
+ # unconditional sets, in the public format, plus — only when the user
17
+ # holds conditional sets — this object under ENTRY_KEY (the conditions,
18
+ # and a token minted at that build);
19
+ # - one variant per combination of conditional sets that a context
20
+ # selects, in a slot of the base key: base key + KEY_INFIX + the
21
+ # bitmask of the selected sets among the user's conditional sets,
22
+ # sorted by id (#slot, 1..2^MAX_CACHED_SETS - 1). It holds the hash of
23
+ # the unconditional sets and those, in the public format, plus under
24
+ # VARIANT_KEY the fingerprint — a digest of the base's token and the
25
+ # selected ids — of the base entry and selection it was built for.
26
+ #
27
+ # A read serves a variant only when its fingerprint is the one the current
28
+ # base entry names (.variant_hash): a variant is reachable only through
29
+ # the base entry that minted its token. Anything else in the slot — the
30
+ # variant of a base entry that is gone — is a miss, rebuilt and written
31
+ # over. Every variant is stored through Cache::Rebuild under the user's
32
+ # invalidation stamp, so one built across an invalidation is discarded
33
+ # like a base.
34
+ #
35
+ # So an invalidation (PermissionCacheAccessor#invalidate_permissions,
36
+ # Cache::UserKeys) removes the base entries only: the variants it leaves
37
+ # can no longer be reached — every base build mints a new token — and go
38
+ # with their TTL (clamped to config.conditional_set_cache_ttl like the
39
+ # base's), or are written over. They still hold the profile, so where
40
+ # personal data must go now — Services::DataSubjectErasure, the
41
+ # enumerated cache:clear — Cache::VariantRemoval removes them: the slots
42
+ # of a base key (.variant_keys: a fixed set, whatever the token). Erasure
43
+ # removes every slot of each of the person's base keys without reading
44
+ # anything; the clear, over every user, only those of a base key that has
45
+ # an index entry (.index_key). The index entry says no more than that
46
+ # variants may be stored under the base key (INDEX), so concurrent writers
47
+ # write the same value; it is written after the variant's rebuild read
48
+ # the user's stamp and before the variant is written (a variant whose
49
+ # index entry could not be written is not stored), and outlives every
50
+ # variant written after it (INDEX_TTL_MARGIN). A user who never had a
51
+ # variant stored has none: the clear reads it and removes nothing.
52
+ #
53
+ # A user who holds more than MAX_CACHED_SETS conditional sets has the
54
+ # variants built for the request that asks and kept on its instance only,
55
+ # never stored or looked for — one build per request for such a user.
56
+ #
57
+ # Which conditional sets a context selects (#passing):
58
+ # - a context with an IP or session flags: those whose conditions pass
59
+ # (Services::PermissionSetConditionEvaluator), granting and muting alike;
60
+ # - no context at all — the resolver returned nothing (no resolver, as
61
+ # shipped): every conditional set, as before;
62
+ # - under config.conditional_sets_strict_mode, no request context — the
63
+ # resolver returned nothing, or neither an IP nor session flags (a job,
64
+ # the console; the time alone is not a request context): only the
65
+ # conditional MUTING sets, since a missing context must never grant and
66
+ # must never lift a lockdown either. With a request context, a muting
67
+ # set whose condition cannot be judged in it (an IP allowlist and no
68
+ # IP; a session flag and no :session_flags at all) applies too, for the
69
+ # same reason; granting sets are evaluated as they are. Without strict
70
+ # mode such a context is evaluated per set, as before: a job gets no
71
+ # MFA- or IP-gated grant, an IP-gated set fails without an IP, and the
72
+ # hours are checked.
73
+ # The time the conditions are checked at is fixed once per selection
74
+ # (.clocked), so the signature names the hour the evaluation saw.
75
+ #
76
+ # Everything here is plain data (Strings, Integers, Arrays, Data): it is
77
+ # written to the store as a Hash (#dump) and kept on a user instance in
78
+ # the memo (PermissionsMemo), which marshals with the user.
79
+ #
80
+ # @api private
81
+ class ConditionalSets < Data.define(:sets, :token)
82
+ # Where the base entry carries it.
83
+ ENTRY_KEY = :conditional_sets
84
+ # Where a stored variant carries the fingerprint of the base entry and
85
+ # selection it was built for: taken off on every read, never part of
86
+ # the hash.
87
+ VARIANT_KEY = :variant_of
88
+ # Between the base key and the slot in a variant's key.
89
+ KEY_INFIX = '_c_'
90
+ # After KEY_INFIX in the key of the index entry (a slot is a number).
91
+ INDEX_NAME = 'index'
92
+ # What the index entry holds: variants may be stored under the base key.
93
+ INDEX = true
94
+ # Seconds the index entry lives beyond config.cache_ttl, the longest a
95
+ # variant written after it lives.
96
+ INDEX_TTL_MARGIN = 60
97
+ # The most conditional sets a user may hold for the variants to be cached:
98
+ # erasure removes one slot per combination of them.
99
+ MAX_CACHED_SETS = 6
100
+ # Every slot a base key has.
101
+ SLOTS = (1..((1 << MAX_CACHED_SETS) - 1))
102
+
103
+ # One conditional set: what the evaluator reads of a PermissionSet.
104
+ #
105
+ # @api private
106
+ Member = Data.define(:id, :muting, :requires_session_flag, :allowed_ips, :allowed_hours_start, :allowed_hours_end) do
107
+ def self.of(permission_set)
108
+ new(id: permission_set.id, muting: permission_set.is_muting == true,
109
+ requires_session_flag: permission_set.requires_session_flag, allowed_ips: permission_set.allowed_ips,
110
+ allowed_hours_start: permission_set.allowed_hours_start, allowed_hours_end: permission_set.allowed_hours_end)
111
+ end
112
+
113
+ # A stored member — also one written by a version with fewer conditions.
114
+ #
115
+ # @raise [ArgumentError] for anything but a Hash
116
+ def self.load(data)
117
+ raise ArgumentError, "a conditional set is stored as a Hash, not #{data.class}" unless data.is_a?(Hash)
118
+
119
+ new(**members.to_h { |name| [name, data[name]] })
120
+ end
121
+
122
+ def muting? = muting
123
+
124
+ # Whether the set counts in `context`: its conditions pass — or, in
125
+ # strict mode, it is a muting set whose condition cannot be judged
126
+ # there (no IP for an IP allowlist, no flags at all for a session flag).
127
+ def applies?(context, strict)
128
+ evaluator = Services::PermissionSetConditionEvaluator
129
+ evaluator.call(permission_set: self, context: context) ||
130
+ (strict && muting && evaluator.unjudgeable?(permission_set: self, context: context))
131
+ end
132
+ end
133
+
134
+ # The conditional sets a context selected: their ids, the fingerprint
135
+ # of the base entry (token) and those ids, and whether the variant is
136
+ # kept in the shared cache (MAX_CACHED_SETS).
137
+ #
138
+ # @api private
139
+ Variant = Data.define(:ids, :fingerprint, :cached)
140
+
141
+ # What a memo keeps about the hash it holds: the user's conditional sets,
142
+ # the variant the hash was built for (nil: the base), and the signature
143
+ # of the context that selected it.
144
+ #
145
+ # @api private
146
+ Served = Data.define(:conditional, :variant, :signature) do
147
+ # Whether `context` is, as far as the conditions go, the one that
148
+ # selected the hash — cheaper than selecting again.
149
+ def selected_by?(context)
150
+ signature == ConditionalSets.signature(context)
151
+ end
152
+
153
+ # This selection, when `context` is the one that made it; a copy that
154
+ # remembers `context`, when it selects the same variant (so the next
155
+ # check is cheap again); nil when it selects another variant.
156
+ #
157
+ # @return [Served, nil]
158
+ def reselect(context)
159
+ return self if selected_by?(context)
160
+
161
+ again = conditional.serve(context)
162
+ again if again.variant == variant
163
+ end
164
+
165
+ # The store key of the hash: the base key, or the variant's slot
166
+ # (one of ConditionalSets.variant_keys).
167
+ def store_key(base_key)
168
+ variant ? "#{base_key}#{KEY_INFIX}#{conditional.slot(variant.ids)}" : base_key
169
+ end
170
+ end
171
+
172
+ class << self
173
+ # @param permission_sets [Enumerable<PermissionSet>] the sets a user holds
174
+ # @return [ConditionalSets, nil] nil when none is conditional
175
+ def of(permission_sets)
176
+ sets = permission_sets.select(&:conditional?).map { |set| Member.of(set) }
177
+ new(sets: sets) if sets.any?
178
+ end
179
+
180
+ # @param data [Hash] what #dump wrote
181
+ # @return [ConditionalSets]
182
+ # @raise [ArgumentError] for data without a token — another version of
183
+ # the gem, or a foreign writer, left it: without the token every base
184
+ # would name the same variants
185
+ def load(data)
186
+ token = data[:token] if data.is_a?(Hash)
187
+ raise ArgumentError, 'conditional sets are stored with a token' unless token.is_a?(String) && !token.empty?
188
+
189
+ new(sets: Array(data[:sets]).map { |member| Member.load(member) }, token: token)
190
+ end
191
+
192
+ # Separates a stored base entry into the public hash and the conditional
193
+ # sets it carries. An entry without them is returned as it is; one whose
194
+ # conditional sets cannot be read is no entry at all.
195
+ #
196
+ # @return [Array(Hash, ConditionalSets|nil), nil]
197
+ def split(entry)
198
+ data = entry[ENTRY_KEY]
199
+ return [entry, nil] unless data
200
+
201
+ [entry.except(ENTRY_KEY), load(data)]
202
+ rescue ArgumentError
203
+ nil
204
+ end
205
+
206
+ # The sets a hash is built from: the unconditional ones, and those of
207
+ # the variant when there is one.
208
+ #
209
+ # @param permission_sets [Enumerable<PermissionSet>]
210
+ # @param variant [Variant, nil]
211
+ # @return [Array<PermissionSet>]
212
+ def build_sets(permission_sets, variant)
213
+ return permission_sets.reject(&:conditional?) unless variant
214
+
215
+ ids = variant.ids
216
+ permission_sets.select { |set| set.unconditional? || ids.include?(set.id) }
217
+ end
218
+
219
+ # @return [String] the store key of the index entry of the base entry
220
+ # at `base_key` (#index)
221
+ def index_key(base_key)
222
+ "#{base_key}#{KEY_INFIX}#{INDEX_NAME}"
223
+ end
224
+
225
+ # Every key a variant of the base entry at `base_key` can be stored
226
+ # under, whatever its token: one per slot (SLOTS).
227
+ #
228
+ # @param base_key [String]
229
+ # @return [Array<String>]
230
+ def variant_keys(base_key)
231
+ SLOTS.map { |slot| "#{base_key}#{KEY_INFIX}#{slot}" }
232
+ end
233
+
234
+ # The entry a variant is stored as: the hash, and the fingerprint of
235
+ # the base entry and selection it was built for (VARIANT_KEY).
236
+ #
237
+ # @param hash [Hash] in the public format
238
+ # @param variant [Variant]
239
+ # @return [Hash]
240
+ def variant_entry(hash, variant)
241
+ hash.merge(VARIANT_KEY => variant.fingerprint)
242
+ end
243
+
244
+ # The hash a stored variant holds — when it was built for `variant`:
245
+ # this base entry (token) and this selection. nil for anything else in
246
+ # the slot: the variant of a base entry that is gone, another
247
+ # selection, an entry without a fingerprint.
248
+ #
249
+ # @param entry [Hash, nil] as read from the store
250
+ # @param variant [Variant]
251
+ # @return [Hash, nil] the hash in the public format
252
+ def variant_hash(entry, variant)
253
+ entry.except(VARIANT_KEY) if entry.is_a?(Hash) && entry[VARIANT_KEY] == variant.fingerprint
254
+ end
255
+
256
+ # The digest of a token and sorted ids that names a variant.
257
+ #
258
+ # @return [String]
259
+ def fingerprint(token, ids)
260
+ Digest::SHA256.hexdigest("#{token}:#{ids.join(',')}")[0, 32]
261
+ end
262
+
263
+ # Whether `context` says nothing a condition could be checked against:
264
+ # the resolver returned nothing, or neither an IP nor session flags.
265
+ # The time alone is not a request context.
266
+ #
267
+ # @param context [Hash, nil] what config.request_context_resolver returned
268
+ # @return [Boolean]
269
+ def no_context?(context)
270
+ context.blank? || (context[:ip].blank? && Array(context[:session_flags]).empty?)
271
+ end
272
+
273
+ # The context with the time its conditions are checked at, fixed once:
274
+ # the hour the evaluation sees is the hour the signature names. A
275
+ # context that says nothing is left as it is (nothing is evaluated).
276
+ #
277
+ # @param context [Hash, #to_h, nil]
278
+ # @return [Hash]
279
+ def clocked(context)
280
+ context = context.to_h unless context.is_a?(Hash)
281
+ return context if context.blank? || context[:time]
282
+
283
+ context.merge(time: Time.current)
284
+ end
285
+
286
+ # What of a context the conditions look at: the hour, not the second;
287
+ # a copy of the flags (a resolver may hand out the array, or a flag, it
288
+ # mutates); and for a context that selects without evaluating — none
289
+ # at all, or none in strict mode — only that.
290
+ #
291
+ # @param context [Hash, nil] what config.request_context_resolver returned
292
+ # @return [Array, Symbol]
293
+ def signature(context)
294
+ return :strict if strict? && no_context?(context)
295
+ return :blank if context.blank?
296
+
297
+ flags = context[:session_flags]
298
+ [context[:ip], flags.nil? ? nil : Array(flags).map { |flag| -flag.to_s }, (context[:time] || Time.current).hour]
299
+ end
300
+
301
+ def strict?
302
+ PermissionForge.configuration.conditional_sets_strict_mode == true
303
+ end
304
+ end
305
+
306
+ # @param sets [Array<Member>]
307
+ # @param token [String] minted for the base entry that carries this
308
+ def initialize(sets:, token: SecureRandom.hex(8))
309
+ super(sets: sets.freeze, token: token)
310
+ end
311
+
312
+ # @return [Hash] plain data for the store (see .load)
313
+ def dump
314
+ { token: token, sets: sets.map(&:to_h) }
315
+ end
316
+
317
+ # Whether the variants are kept in the shared cache (MAX_CACHED_SETS).
318
+ #
319
+ # @return [Boolean]
320
+ def cacheable?
321
+ sets.size <= MAX_CACHED_SETS
322
+ end
323
+
324
+ # @param context [Hash, nil] what config.request_context_resolver returned
325
+ # @return [Served]
326
+ def serve(context)
327
+ context = self.class.clocked(context)
328
+ Served.new(conditional: self, variant: variant(context), signature: self.class.signature(context))
329
+ end
330
+
331
+ # @return [Variant, nil] nil when the context selects no conditional set
332
+ def variant(context)
333
+ ids = passing(context).map(&:id).sort
334
+ return if ids.empty?
335
+
336
+ Variant.new(ids: ids, fingerprint: self.class.fingerprint(token, ids), cached: cacheable?)
337
+ end
338
+
339
+ # The slot of the variant of `ids`: the bitmask of those sets among the
340
+ # user's conditional sets, sorted by id — 1..2^n - 1 for n sets, so
341
+ # within SLOTS for a user whose variants are cached (#cacheable?).
342
+ #
343
+ # @param ids [Array<Integer>] some of the sets' ids (Variant#ids)
344
+ # @return [Integer]
345
+ def slot(ids)
346
+ sorted = sets.map(&:id).sort
347
+ ids.sum { |id| 1 << sorted.index(id) }
348
+ end
349
+
350
+ private
351
+
352
+ def passing(context)
353
+ klass = self.class
354
+ strict = klass.strict?
355
+ return sets.select(&:muting?) if strict && klass.no_context?(context)
356
+ return sets if context.blank?
357
+
358
+ sets.select { |member| member.applies?(context, strict) }
359
+ end
360
+ end
361
+ end