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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +1138 -0
- data/LICENSE.txt +21 -0
- data/README.md +356 -0
- data/app/controllers/concerns/permission_forge/acting_admin.rb +17 -0
- data/app/controllers/concerns/permission_forge/api/crud_actions.rb +84 -0
- data/app/controllers/concerns/permission_forge/api/nested_resource_lookup.rb +18 -0
- data/app/controllers/concerns/permission_forge/api/pagination.rb +50 -0
- data/app/controllers/concerns/permission_forge/api/response_renderer.rb +65 -0
- data/app/controllers/concerns/permission_forge/api/serializer_resolver.rb +30 -0
- data/app/controllers/concerns/permission_forge/api_authentication.rb +40 -0
- data/app/controllers/concerns/permission_forge/assignment_authorization.rb +97 -0
- data/app/controllers/concerns/permission_forge/audit_log_gate.rb +55 -0
- data/app/controllers/concerns/permission_forge/basic_authentication.rb +77 -0
- data/app/controllers/concerns/permission_forge/cache_confirmation.rb +32 -0
- data/app/controllers/concerns/permission_forge/forgery_protection.rb +29 -0
- data/app/controllers/concerns/permission_forge/html_format.rb +40 -0
- data/app/controllers/concerns/permission_forge/param_shapes.rb +87 -0
- data/app/controllers/concerns/permission_forge/tenant_required.rb +42 -0
- data/app/controllers/permission_forge/api/audit_events_controller.rb +33 -0
- data/app/controllers/permission_forge/api/base_controller.rb +220 -0
- data/app/controllers/permission_forge/api/dashboard_controller.rb +30 -0
- data/app/controllers/permission_forge/api/permission_set_group_users_controller.rb +51 -0
- data/app/controllers/permission_forge/api/permission_set_groups_controller.rb +71 -0
- data/app/controllers/permission_forge/api/permission_set_rules_controller.rb +37 -0
- data/app/controllers/permission_forge/api/permission_set_users_controller.rb +105 -0
- data/app/controllers/permission_forge/api/permission_sets_controller.rb +87 -0
- data/app/controllers/permission_forge/api/resource_actions_controller.rb +58 -0
- data/app/controllers/permission_forge/api/resource_fields_controller.rb +30 -0
- data/app/controllers/permission_forge/api/resources_controller.rb +56 -0
- data/app/controllers/permission_forge/application_controller.rb +198 -0
- data/app/controllers/permission_forge/assets_controller.rb +97 -0
- data/app/controllers/permission_forge/assignments_controller.rb +266 -0
- data/app/controllers/permission_forge/audit_events_controller.rb +94 -0
- data/app/controllers/permission_forge/bulk_assignments_controller.rb +108 -0
- data/app/controllers/permission_forge/field_lookups_controller.rb +38 -0
- data/app/controllers/permission_forge/home_controller.rb +24 -0
- data/app/controllers/permission_forge/maintenance_controller.rb +13 -0
- data/app/controllers/permission_forge/people_controller.rb +190 -0
- data/app/controllers/permission_forge/permission_set_editor.rb +71 -0
- data/app/controllers/permission_forge/permission_set_group_users_controller.rb +57 -0
- data/app/controllers/permission_forge/permission_set_groups_controller.rb +111 -0
- data/app/controllers/permission_forge/permission_set_rules_controller.rb +97 -0
- data/app/controllers/permission_forge/permission_sets_controller.rb +227 -0
- data/app/controllers/permission_forge/preferences_controller.rb +26 -0
- data/app/controllers/permission_forge/resource_actions_controller.rb +106 -0
- data/app/controllers/permission_forge/resource_fields_controller.rb +129 -0
- data/app/controllers/permission_forge/resources_controller.rb +189 -0
- data/app/controllers/permission_forge/settings_controller.rb +33 -0
- data/app/frontend/permission_forge/brand/apple-touch-icon.png +0 -0
- data/app/frontend/permission_forge/brand/chevron.svg +1 -0
- data/app/frontend/permission_forge/brand/favicon.ico +0 -0
- data/app/frontend/permission_forge/brand/favicon.svg +1 -0
- data/app/frontend/permission_forge/brand/logo-horizontal-dark.svg +1 -0
- data/app/frontend/permission_forge/brand/logo-horizontal-light.svg +1 -0
- data/app/frontend/permission_forge/brand/mark-small.svg +1 -0
- data/app/frontend/permission_forge/brand/mark.svg +1 -0
- data/app/frontend/permission_forge/pf_admin.css +766 -0
- data/app/frontend/permission_forge/pf_admin.js +482 -0
- data/app/helpers/permission_forge/application_helper.rb +167 -0
- data/app/helpers/permission_forge/audit_helper.rb +230 -0
- data/app/helpers/permission_forge/audit_sentences_helper.rb +84 -0
- data/app/helpers/permission_forge/badges_helper.rb +134 -0
- data/app/helpers/permission_forge/changes_helper.rb +117 -0
- data/app/helpers/permission_forge/people_helper.rb +118 -0
- data/app/helpers/permission_forge/sentences_helper.rb +175 -0
- data/app/helpers/permission_forge/settings_helper.rb +152 -0
- data/app/helpers/permission_forge/ui_helper.rb +184 -0
- data/app/models/permission_forge/audit_event.rb +159 -0
- data/app/models/permission_forge/permission_set.rb +100 -0
- data/app/models/permission_forge/permission_set_group.rb +83 -0
- data/app/models/permission_forge/permission_set_group_user.rb +43 -0
- data/app/models/permission_forge/permission_set_rule.rb +99 -0
- data/app/models/permission_forge/permission_set_user.rb +158 -0
- data/app/models/permission_forge/resource.rb +100 -0
- data/app/models/permission_forge/resource_action.rb +58 -0
- data/app/models/permission_forge/resource_field.rb +69 -0
- data/app/views/layouts/permission_forge/application.html.erb +30 -0
- data/app/views/layouts/permission_forge/fragment.html.erb +2 -0
- data/app/views/permission_forge/assignments/_person_options.html.erb +26 -0
- data/app/views/permission_forge/assignments/confirm_destroy.html.erb +24 -0
- data/app/views/permission_forge/assignments/new.html.erb +99 -0
- data/app/views/permission_forge/audit_events/index.html.erb +161 -0
- data/app/views/permission_forge/audit_events/show.html.erb +146 -0
- data/app/views/permission_forge/bulk_assignments/confirm_revoke.html.erb +21 -0
- data/app/views/permission_forge/bulk_assignments/new.html.erb +69 -0
- data/app/views/permission_forge/field_lookups/show.html.erb +32 -0
- data/app/views/permission_forge/home/_audit_card.html.erb +19 -0
- data/app/views/permission_forge/home/_ending_card.html.erb +32 -0
- data/app/views/permission_forge/home/_expired_card.html.erb +34 -0
- data/app/views/permission_forge/home/_privacy_card.html.erb +67 -0
- data/app/views/permission_forge/home/_rules_card.html.erb +23 -0
- data/app/views/permission_forge/home/_stale_card.html.erb +39 -0
- data/app/views/permission_forge/home/_tidy_card.html.erb +18 -0
- data/app/views/permission_forge/home/index.html.erb +69 -0
- data/app/views/permission_forge/people/_access_tab.html.erb +58 -0
- data/app/views/permission_forge/people/_audit_tab.html.erb +93 -0
- data/app/views/permission_forge/people/_effective_field_source.html.erb +11 -0
- data/app/views/permission_forge/people/_effective_tab.html.erb +118 -0
- data/app/views/permission_forge/people/_effective_why.html.erb +48 -0
- data/app/views/permission_forge/people/_why_item.html.erb +19 -0
- data/app/views/permission_forge/people/confirm_remove_all.html.erb +27 -0
- data/app/views/permission_forge/people/consent_preview.html.erb +39 -0
- data/app/views/permission_forge/people/erasure_preview.html.erb +28 -0
- data/app/views/permission_forge/people/index.html.erb +78 -0
- data/app/views/permission_forge/people/show.html.erb +54 -0
- data/app/views/permission_forge/permission_set_groups/confirm_destroy.html.erb +12 -0
- data/app/views/permission_forge/permission_set_groups/index.html.erb +55 -0
- data/app/views/permission_forge/permission_set_groups/show.html.erb +100 -0
- data/app/views/permission_forge/permission_set_rules/_builder.html.erb +145 -0
- data/app/views/permission_forge/permission_set_rules/_value_input.html.erb +42 -0
- data/app/views/permission_forge/permission_set_rules/confirm_destroy.html.erb +13 -0
- data/app/views/permission_forge/permission_set_rules/edit.html.erb +3 -0
- data/app/views/permission_forge/permission_set_rules/new.html.erb +3 -0
- data/app/views/permission_forge/permission_sets/_assignments_tab.html.erb +74 -0
- data/app/views/permission_forge/permission_sets/_audit_tab.html.erb +80 -0
- data/app/views/permission_forge/permission_sets/_conditions_tab.html.erb +83 -0
- data/app/views/permission_forge/permission_sets/_header.html.erb +36 -0
- data/app/views/permission_forge/permission_sets/_records_summary.html.erb +16 -0
- data/app/views/permission_forge/permission_sets/_resource_summary.html.erb +11 -0
- data/app/views/permission_forge/permission_sets/_resources_tab.html.erb +49 -0
- data/app/views/permission_forge/permission_sets/confirm_destroy.html.erb +23 -0
- data/app/views/permission_forge/permission_sets/confirm_switch_mode.html.erb +15 -0
- data/app/views/permission_forge/permission_sets/index.html.erb +83 -0
- data/app/views/permission_forge/permission_sets/new.html.erb +63 -0
- data/app/views/permission_forge/permission_sets/review.html.erb +26 -0
- data/app/views/permission_forge/permission_sets/show.html.erb +50 -0
- data/app/views/permission_forge/resource_actions/_across_sets.html.erb +24 -0
- data/app/views/permission_forge/resource_actions/_add_select.html.erb +12 -0
- data/app/views/permission_forge/resource_actions/_effect.html.erb +25 -0
- data/app/views/permission_forge/resource_actions/_endpoints.html.erb +16 -0
- data/app/views/permission_forge/resource_actions/_form.html.erb +44 -0
- data/app/views/permission_forge/resource_actions/_managed_note.html.erb +11 -0
- data/app/views/permission_forge/resource_actions/confirm_destroy.html.erb +13 -0
- data/app/views/permission_forge/resource_actions/new.html.erb +31 -0
- data/app/views/permission_forge/resource_actions/show.html.erb +39 -0
- data/app/views/permission_forge/resource_fields/_explain.html.erb +24 -0
- data/app/views/permission_forge/resource_fields/show.html.erb +186 -0
- data/app/views/permission_forge/resources/_actions_tab.html.erb +88 -0
- data/app/views/permission_forge/resources/_fields_tab.html.erb +89 -0
- data/app/views/permission_forge/resources/_rules_tab.html.erb +55 -0
- data/app/views/permission_forge/resources/_sync.html.erb +57 -0
- data/app/views/permission_forge/resources/confirm_destroy.html.erb +17 -0
- data/app/views/permission_forge/resources/new.html.erb +57 -0
- data/app/views/permission_forge/resources/show.html.erb +117 -0
- data/app/views/permission_forge/settings/_ladder.html.erb +15 -0
- data/app/views/permission_forge/settings/_row.html.erb +38 -0
- data/app/views/permission_forge/settings/show.html.erb +67 -0
- data/app/views/permission_forge/shared/_across_sets.html.erb +31 -0
- data/app/views/permission_forge/shared/_audit_events.html.erb +36 -0
- data/app/views/permission_forge/shared/_audit_footer.html.erb +16 -0
- data/app/views/permission_forge/shared/_audit_off_note.html.erb +15 -0
- data/app/views/permission_forge/shared/_audit_pagination.html.erb +19 -0
- data/app/views/permission_forge/shared/_audit_refused_note.html.erb +7 -0
- data/app/views/permission_forge/shared/_audit_timeline.html.erb +29 -0
- data/app/views/permission_forge/shared/_crud_cell.html.erb +18 -0
- data/app/views/permission_forge/shared/_dialog_frame.html.erb +11 -0
- data/app/views/permission_forge/shared/_expiry_picker.html.erb +27 -0
- data/app/views/permission_forge/shared/_head_extra.html.erb +3 -0
- data/app/views/permission_forge/shared/_help_banner.html.erb +10 -0
- data/app/views/permission_forge/shared/_level_control.html.erb +18 -0
- data/app/views/permission_forge/shared/_mode_banners.html.erb +22 -0
- data/app/views/permission_forge/shared/_nav.html.erb +12 -0
- data/app/views/permission_forge/shared/_preferences.html.erb +24 -0
- data/app/views/permission_forge/shared/_reason_field.html.erb +20 -0
- data/app/views/permission_forge/shared/_savebar.html.erb +13 -0
- data/app/views/permission_forge/shared/_sheet_frame.html.erb +22 -0
- data/app/views/permission_forge/shared/_sidebar.html.erb +18 -0
- data/app/views/permission_forge/shared/_tech_details.html.erb +20 -0
- data/app/views/permission_forge/shared/_toasts.html.erb +27 -0
- data/app/views/permission_forge/shared/_topbar.html.erb +15 -0
- data/app/views/permission_forge/shared/_user_select.html.erb +16 -0
- data/app/views/permission_forge/shared/audit_refused.html.erb +7 -0
- data/config/locales/permission_forge.en.yml +2115 -0
- data/config/locales/permission_forge.mk.yml +2166 -0
- data/config/locales/permission_forge.sq.yml +2164 -0
- data/config/routes.rb +143 -0
- data/lib/generators/permission_forge/install/install_generator.rb +105 -0
- data/lib/generators/permission_forge/install/templates/initializer.rb.tt +148 -0
- data/lib/generators/permission_forge/install/templates/migration.rb.tt +198 -0
- data/lib/generators/permission_forge/rls/rls_generator.rb +57 -0
- data/lib/generators/permission_forge/rls/templates/enable_permission_forge_rls.rb.tt +15 -0
- data/lib/generators/permission_forge/upgrade/templates/add_admin_ui_columns.rb.tt +8 -0
- data/lib/generators/permission_forge/upgrade/templates/add_audit_events.rb.tt +98 -0
- data/lib/generators/permission_forge/upgrade/templates/add_resource_fields_unique_index.rb.tt +69 -0
- data/lib/generators/permission_forge/upgrade/upgrade_generator.rb +66 -0
- data/lib/permission_forge/admin/action_across_sets.rb +76 -0
- data/lib/permission_forge/admin/action_rows.rb +156 -0
- data/lib/permission_forge/admin/attention_items.rb +207 -0
- data/lib/permission_forge/admin/audit_event_detail.rb +75 -0
- data/lib/permission_forge/admin/audit_log.rb +193 -0
- data/lib/permission_forge/admin/audit_log_days.rb +41 -0
- data/lib/permission_forge/admin/audit_lookups.rb +105 -0
- data/lib/permission_forge/admin/audit_name_history.rb +85 -0
- data/lib/permission_forge/admin/audit_trail.rb +169 -0
- data/lib/permission_forge/admin/bulk_people_matcher.rb +109 -0
- data/lib/permission_forge/admin/change_set.rb +123 -0
- data/lib/permission_forge/admin/change_set_parts/actions.rb +27 -0
- data/lib/permission_forge/admin/change_set_parts/base.rb +58 -0
- data/lib/permission_forge/admin/change_set_parts/context.rb +50 -0
- data/lib/permission_forge/admin/change_set_parts/field_edit.rb +67 -0
- data/lib/permission_forge/admin/change_set_parts/field_levels.rb +71 -0
- data/lib/permission_forge/admin/change_set_parts/new_actions.rb +91 -0
- data/lib/permission_forge/admin/change_set_parts/new_fields.rb +63 -0
- data/lib/permission_forge/admin/change_set_parts/resource_edit.rb +55 -0
- data/lib/permission_forge/admin/change_set_parts/resource_flags.rb +19 -0
- data/lib/permission_forge/admin/change_set_parts/set_attrs.rb +45 -0
- data/lib/permission_forge/admin/change_set_parts/values.rb +20 -0
- data/lib/permission_forge/admin/column_fields.rb +48 -0
- data/lib/permission_forge/admin/conditions_form.rb +78 -0
- data/lib/permission_forge/admin/controller_catalog.rb +166 -0
- data/lib/permission_forge/admin/csv.rb +41 -0
- data/lib/permission_forge/admin/editable_lock.rb +157 -0
- data/lib/permission_forge/admin/effective_access.rb +158 -0
- data/lib/permission_forge/admin/effective_fields.rb +176 -0
- data/lib/permission_forge/admin/expiry_presets.rb +51 -0
- data/lib/permission_forge/admin/exports/document.rb +89 -0
- data/lib/permission_forge/admin/exports/events.rb +51 -0
- data/lib/permission_forge/admin/exports/holdings.rb +118 -0
- data/lib/permission_forge/admin/exports.rb +37 -0
- data/lib/permission_forge/admin/field_across_sets.rb +51 -0
- data/lib/permission_forge/admin/field_detail.rb +112 -0
- data/lib/permission_forge/admin/field_level.rb +74 -0
- data/lib/permission_forge/admin/field_rows.rb +137 -0
- data/lib/permission_forge/admin/last_changes.rb +94 -0
- data/lib/permission_forge/admin/model_catalog.rb +216 -0
- data/lib/permission_forge/admin/people_candidates.rb +108 -0
- data/lib/permission_forge/admin/people_directory.rb +158 -0
- data/lib/permission_forge/admin/permission_set_factory.rb +48 -0
- data/lib/permission_forge/admin/person_label.rb +95 -0
- data/lib/permission_forge/admin/record_id.rb +19 -0
- data/lib/permission_forge/admin/rule_builder.rb +58 -0
- data/lib/permission_forge/admin/rule_form.rb +175 -0
- data/lib/permission_forge/admin/rule_form_input.rb +74 -0
- data/lib/permission_forge/admin/sensitive_columns.rb +68 -0
- data/lib/permission_forge/admin/settings_catalog.rb +261 -0
- data/lib/permission_forge/admin/settings_values.rb +130 -0
- data/lib/permission_forge/api/serializers/audit_event_serializer.rb +27 -0
- data/lib/permission_forge/api/serializers/base_serializer.rb +36 -0
- data/lib/permission_forge/api/serializers/permission_set_group_serializer.rb +12 -0
- data/lib/permission_forge/api/serializers/permission_set_group_user_serializer.rb +11 -0
- data/lib/permission_forge/api/serializers/permission_set_rule_serializer.rb +25 -0
- data/lib/permission_forge/api/serializers/permission_set_serializer.rb +13 -0
- data/lib/permission_forge/api/serializers/permission_set_user_serializer.rb +11 -0
- data/lib/permission_forge/api/serializers/resource_action_serializer.rb +13 -0
- data/lib/permission_forge/api/serializers/resource_field_serializer.rb +14 -0
- data/lib/permission_forge/api/serializers/resource_serializer.rb +15 -0
- data/lib/permission_forge/audit/actor.rb +144 -0
- data/lib/permission_forge/audit/actor_resolver.rb +103 -0
- data/lib/permission_forge/audit/audited_views.rb +208 -0
- data/lib/permission_forge/audit/boot_warnings.rb +58 -0
- data/lib/permission_forge/audit/column_values.rb +56 -0
- data/lib/permission_forge/audit/context.rb +98 -0
- data/lib/permission_forge/audit/detached.rb +62 -0
- data/lib/permission_forge/audit/event_catalog.rb +110 -0
- data/lib/permission_forge/audit/event_query.rb +192 -0
- data/lib/permission_forge/audit/failure.rb +47 -0
- data/lib/permission_forge/audit/field_access.rb +186 -0
- data/lib/permission_forge/audit/gate.rb +167 -0
- data/lib/permission_forge/audit/job_context.rb +32 -0
- data/lib/permission_forge/audit/log_access.rb +36 -0
- data/lib/permission_forge/audit/recorder.rb +168 -0
- data/lib/permission_forge/audit/refusals.rb +61 -0
- data/lib/permission_forge/audit/request_context.rb +152 -0
- data/lib/permission_forge/audit/runtime.rb +178 -0
- data/lib/permission_forge/audit/runtime_emission.rb +135 -0
- data/lib/permission_forge/audit/settings_observer.rb +70 -0
- data/lib/permission_forge/audit/source.rb +54 -0
- data/lib/permission_forge/audit/subscribers/base.rb +69 -0
- data/lib/permission_forge/audit/subscribers/legacy_hooks.rb +97 -0
- data/lib/permission_forge/audit/subscribers/log.rb +45 -0
- data/lib/permission_forge/audit/subscribers.rb +58 -0
- data/lib/permission_forge/audit.rb +302 -0
- data/lib/permission_forge/boot_checks.rb +184 -0
- data/lib/permission_forge/cache/backlog.rb +142 -0
- data/lib/permission_forge/cache/base.rb +200 -0
- data/lib/permission_forge/cache/bulk_invalidation.rb +131 -0
- data/lib/permission_forge/cache/failure.rb +171 -0
- data/lib/permission_forge/cache/handled_errors.rb +82 -0
- data/lib/permission_forge/cache/holder_invalidation.rb +85 -0
- data/lib/permission_forge/cache/invalidation.rb +180 -0
- data/lib/permission_forge/cache/memory_cache.rb +77 -0
- data/lib/permission_forge/cache/null_cache.rb +40 -0
- data/lib/permission_forge/cache/rails_cache.rb +231 -0
- data/lib/permission_forge/cache/rebuild.rb +149 -0
- data/lib/permission_forge/cache/settlement.rb +63 -0
- data/lib/permission_forge/cache/user_keys.rb +206 -0
- data/lib/permission_forge/cache/variant_removal.rb +55 -0
- data/lib/permission_forge/cache/withheld_notice.rb +60 -0
- data/lib/permission_forge/concerns/audited_collection.rb +34 -0
- data/lib/permission_forge/concerns/auto_permission_scoping.rb +158 -0
- data/lib/permission_forge/concerns/controller_action_guard.rb +106 -0
- data/lib/permission_forge/concerns/field_mask_checker.rb +45 -0
- data/lib/permission_forge/concerns/has_active_scope.rb +12 -0
- data/lib/permission_forge/concerns/has_audit_trail.rb +59 -0
- data/lib/permission_forge/concerns/has_permission_set_users.rb +115 -0
- data/lib/permission_forge/concerns/has_tenant_scoping.rb +86 -0
- data/lib/permission_forge/concerns/has_uuid.rb +24 -0
- data/lib/permission_forge/concerns/invalidates_in_batch.rb +207 -0
- data/lib/permission_forge/concerns/invalidates_managed_actions.rb +80 -0
- data/lib/permission_forge/concerns/masked_attributes/alias_access.rb +280 -0
- data/lib/permission_forge/concerns/masked_attributes/store_access.rb +100 -0
- data/lib/permission_forge/concerns/masked_attributes/validation_reads.rb +153 -0
- data/lib/permission_forge/concerns/masked_attributes/virtual_readers.rb +242 -0
- data/lib/permission_forge/concerns/masked_attributes.rb +576 -0
- data/lib/permission_forge/concerns/permission_cache_accessor.rb +416 -0
- data/lib/permission_forge/concerns/permission_scoped_findable.rb +337 -0
- data/lib/permission_forge/concerns/permissioned_user.rb +78 -0
- data/lib/permission_forge/concerns/permissions_memo.rb +160 -0
- data/lib/permission_forge/concerns/permissions_per_context.rb +288 -0
- data/lib/permission_forge/concerns/permissions_regenerator.rb +43 -0
- data/lib/permission_forge/concerns/records_audit_events.rb +286 -0
- data/lib/permission_forge/concerns/resource_permission_checker.rb +116 -0
- data/lib/permission_forge/concerns/validates_column_lengths.rb +69 -0
- data/lib/permission_forge/concerns/validates_dependency.rb +54 -0
- data/lib/permission_forge/conditional_sets.rb +361 -0
- data/lib/permission_forge/configuration/admin_ui_settings.rb +108 -0
- data/lib/permission_forge/configuration/audit_settings.rb +184 -0
- data/lib/permission_forge/configuration/cache_settings.rb +76 -0
- data/lib/permission_forge/configuration/controller_action_settings.rb +112 -0
- data/lib/permission_forge/configuration/gdpr_settings.rb +70 -0
- data/lib/permission_forge/configuration/masked_attributes_settings.rb +83 -0
- data/lib/permission_forge/configuration/permits_attributes_settings.rb +26 -0
- data/lib/permission_forge/configuration/presets.rb +139 -0
- data/lib/permission_forge/configuration/query_scoping_settings.rb +107 -0
- data/lib/permission_forge/configuration/strategy_values.rb +57 -0
- data/lib/permission_forge/configuration/virtual_attributes_settings.rb +65 -0
- data/lib/permission_forge/configuration.rb +624 -0
- data/lib/permission_forge/controller_actions/authorizer.rb +171 -0
- data/lib/permission_forge/controller_actions/managed_index.rb +213 -0
- data/lib/permission_forge/controller_actions/mapping.rb +167 -0
- data/lib/permission_forge/controller_actions.rb +122 -0
- data/lib/permission_forge/deprecation.rb +86 -0
- data/lib/permission_forge/engine.rb +113 -0
- data/lib/permission_forge/execution_state.rb +318 -0
- data/lib/permission_forge/invalidation_batch.rb +206 -0
- data/lib/permission_forge/masked_models.rb +226 -0
- data/lib/permission_forge/masking_sync.rb +271 -0
- data/lib/permission_forge/model_bindings.rb +90 -0
- data/lib/permission_forge/open_transaction.rb +303 -0
- data/lib/permission_forge/operators/constants.rb +41 -0
- data/lib/permission_forge/operators/registry.rb +300 -0
- data/lib/permission_forge/operators/rule_column.rb +120 -0
- data/lib/permission_forge/operators/validations.rb +111 -0
- data/lib/permission_forge/operators/value_casters/number.rb +68 -0
- data/lib/permission_forge/operators/value_casters/temporal.rb +69 -0
- data/lib/permission_forge/operators/value_casters.rb +80 -0
- data/lib/permission_forge/operators/value_typing.rb +115 -0
- data/lib/permission_forge/query_scoping_warnings.rb +68 -0
- data/lib/permission_forge/rake_audit.rb +65 -0
- data/lib/permission_forge/resource_chain.rb +190 -0
- data/lib/permission_forge/rls.rb +209 -0
- data/lib/permission_forge/services/attribute_access_guard.rb +138 -0
- data/lib/permission_forge/services/audit_event_purger.rb +150 -0
- data/lib/permission_forge/services/bulk_permission_set_assignment.rb +108 -0
- data/lib/permission_forge/services/concerns/across_tenants.rb +69 -0
- data/lib/permission_forge/services/concerns/audited_operation.rb +43 -0
- data/lib/permission_forge/services/concerns/clonable.rb +29 -0
- data/lib/permission_forge/services/consent_withdrawal_handler.rb +135 -0
- data/lib/permission_forge/services/dashboard_stats.rb +43 -0
- data/lib/permission_forge/services/data_subject_erasure.rb +306 -0
- data/lib/permission_forge/services/expired_assignment_cleaner.rb +152 -0
- data/lib/permission_forge/services/extra_field_defaults.rb +71 -0
- data/lib/permission_forge/services/field_permission_merger.rb +83 -0
- data/lib/permission_forge/services/filters_response_attributes.rb +144 -0
- data/lib/permission_forge/services/gdpr_field_auditor/text_report.rb +63 -0
- data/lib/permission_forge/services/gdpr_field_auditor.rb +223 -0
- data/lib/permission_forge/services/host_key_guard.rb +110 -0
- data/lib/permission_forge/services/mask_value.rb +93 -0
- data/lib/permission_forge/services/permission_cache_clearer.rb +174 -0
- data/lib/permission_forge/services/permission_hash_builder.rb +305 -0
- data/lib/permission_forge/services/permission_rule_applier.rb +192 -0
- data/lib/permission_forge/services/permission_set_cloner.rb +89 -0
- data/lib/permission_forge/services/permission_set_condition_evaluator.rb +102 -0
- data/lib/permission_forge/services/permission_set_group_cloner.rb +52 -0
- data/lib/permission_forge/services/permits_attributes/nested_records.rb +106 -0
- data/lib/permission_forge/services/permits_attributes.rb +270 -0
- data/lib/permission_forge/services/resource_field_generator.rb +93 -0
- data/lib/permission_forge/services/resource_with_fields_builder.rb +47 -0
- data/lib/permission_forge/services/rls_operator_sql.rb +131 -0
- data/lib/permission_forge/services/rls_policy_generator.rb +365 -0
- data/lib/permission_forge/services/security_audit/action_check.rb +157 -0
- data/lib/permission_forge/services/security_audit/assignment_check.rb +120 -0
- data/lib/permission_forge/services/security_audit/audit_trail_check.rb +166 -0
- data/lib/permission_forge/services/security_audit/base_check.rb +57 -0
- data/lib/permission_forge/services/security_audit/config_check.rb +212 -0
- data/lib/permission_forge/services/security_audit/integrity_check.rb +105 -0
- data/lib/permission_forge/services/security_audit/personal_data_check.rb +79 -0
- data/lib/permission_forge/services/security_audit/privilege_check.rb +67 -0
- data/lib/permission_forge/services/security_audit/rule_check.rb +141 -0
- data/lib/permission_forge/services/security_audit/text_report.rb +46 -0
- data/lib/permission_forge/services/security_audit/user_model_check.rb +67 -0
- data/lib/permission_forge/services/security_audit.rb +75 -0
- data/lib/permission_forge/services/stale_assignment_auditor.rb +68 -0
- data/lib/permission_forge/services/unscoped_query_detector.rb +132 -0
- data/lib/permission_forge/version.rb +3 -0
- data/lib/permission_forge/virtual_fields/discovery.rb +65 -0
- data/lib/permission_forge/virtual_fields.rb +102 -0
- data/lib/permission_forge.rb +464 -0
- data/lib/tasks/permission_forge.rake +31 -0
- data/lib/tasks/permission_forge_audit_events.rake +28 -0
- data/lib/tasks/permission_forge_cache.rake +26 -0
- data/lib/tasks/permission_forge_expiration.rake +30 -0
- data/lib/tasks/permission_forge_gdpr.rake +69 -0
- data/lib/tasks/permission_forge_rls.rake +38 -0
- metadata +515 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Cache
|
|
5
|
+
# One invalidation of a user's cached permission hashes — or of the
|
|
6
|
+
# managed-actions index (ControllerActions::ManagedIndex): leaves a fresh
|
|
7
|
+
# stamp, then removes the keys — and checks what the store answers.
|
|
8
|
+
#
|
|
9
|
+
# The stamp is written BEFORE the keys are removed. A rebuild that read its
|
|
10
|
+
# rows before the commit is only dangerous when it stores its hash after
|
|
11
|
+
# the removal; it re-reads the stamp after storing, so with this order the
|
|
12
|
+
# new stamp is already there for it to see. Removing first would leave a
|
|
13
|
+
# gap: remove, the stale hash is stored, the rebuild sees the old stamp,
|
|
14
|
+
# and only then is the new stamp written — nothing would notice. (A rebuild
|
|
15
|
+
# that stores before the removal loses its entry to it; either way no
|
|
16
|
+
# pre-commit hash survives.) The keys are removed even when the stamp could
|
|
17
|
+
# not be written.
|
|
18
|
+
#
|
|
19
|
+
# A store that is unreachable does not raise: it answers false or nil. So every
|
|
20
|
+
# answer is checked, and an invalidation the store did not confirm is tried
|
|
21
|
+
# again, ATTEMPTS times in all. When it still fails it is reported
|
|
22
|
+
# (Cache::Failure) and not raised: the entry may then outlive the change
|
|
23
|
+
# until cache_ttl, and somebody has to know.
|
|
24
|
+
#
|
|
25
|
+
# A store that is down fails every call, and each one may wait for a
|
|
26
|
+
# timeout. After a failed invalidation the next ones therefore make a
|
|
27
|
+
# single attempt for RETRY_PAUSE seconds, which keeps a commit that
|
|
28
|
+
# invalidates many users from waiting three times as long for nothing.
|
|
29
|
+
#
|
|
30
|
+
# An invalidation that failed for good is still owed: it is put on the
|
|
31
|
+
# process's Backlog and made when the store answers again.
|
|
32
|
+
#
|
|
33
|
+
# @api private
|
|
34
|
+
class Invalidation
|
|
35
|
+
ATTEMPTS = 3
|
|
36
|
+
RETRY_PAUSE = 5
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
def call(...) = new(...).call
|
|
40
|
+
|
|
41
|
+
# @return [Boolean] whether a recent invalidation failed for good
|
|
42
|
+
def store_failing?
|
|
43
|
+
until_time = @failing_until
|
|
44
|
+
!until_time.nil? && now < until_time
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def store_failed!
|
|
48
|
+
@failing_until = now + RETRY_PAUSE
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def store_answered!
|
|
52
|
+
@failing_until = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Backlog] the invalidations this process still owes the store
|
|
56
|
+
def backlog
|
|
57
|
+
@backlog ||= Backlog.new
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Notes an invalidation the store did not confirm, to be made later.
|
|
61
|
+
#
|
|
62
|
+
# @return [Boolean] false when exactly this was owed already
|
|
63
|
+
def owe(user_id:, stamp_key:, keys:)
|
|
64
|
+
backlog.add(user_id: user_id, stamp_key: stamp_key, keys: keys)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Whether an invalidation is still owed, after one more try to make
|
|
68
|
+
# the owed ones. Costs nothing while nothing is owed.
|
|
69
|
+
#
|
|
70
|
+
# @param stamp_key [String] the stamp key of the invalidation (one per
|
|
71
|
+
# user, one for the managed-actions index)
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
def owed?(stamp_key)
|
|
74
|
+
owed = backlog
|
|
75
|
+
return false if owed.empty?
|
|
76
|
+
|
|
77
|
+
retry_owed
|
|
78
|
+
owed.include?(stamp_key)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Tries the owed invalidations once each. Not while the store has just
|
|
82
|
+
# failed one: an outage must not add a failing call to every request.
|
|
83
|
+
# And not from inside an open database transaction: a store that
|
|
84
|
+
# shares the connection (Solid Cache in the application's database)
|
|
85
|
+
# would take the removals back if the transaction rolled back.
|
|
86
|
+
#
|
|
87
|
+
# @return [void]
|
|
88
|
+
def retry_owed
|
|
89
|
+
owed = backlog
|
|
90
|
+
return if owed.empty? || store_failing? || PermissionForge::OpenTransaction.current
|
|
91
|
+
|
|
92
|
+
owed.try_again { |entry| new(**entry).try_again }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# For tests, and for a cache store that has been replaced: what was
|
|
96
|
+
# owed to the old one is not owed to the new one.
|
|
97
|
+
def reset!
|
|
98
|
+
store_answered!
|
|
99
|
+
backlog.clear
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def now
|
|
105
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @param user_id [Object, nil] whose hashes are invalidated (for the
|
|
110
|
+
# report; nil for an entry that belongs to no user)
|
|
111
|
+
# @param stamp_key [String] where the user's invalidation stamp lives
|
|
112
|
+
# @param keys [Array<String>] the cache keys to remove
|
|
113
|
+
def initialize(user_id:, stamp_key:, keys:, cache: PermissionForge.cache)
|
|
114
|
+
@user_id = user_id
|
|
115
|
+
@stamp_key = stamp_key
|
|
116
|
+
@keys = keys
|
|
117
|
+
@cache = cache
|
|
118
|
+
@error = nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @return [Boolean] whether the store confirmed the stamp and every removal
|
|
122
|
+
def call
|
|
123
|
+
attempts = self.class.store_failing? ? 1 : ATTEMPTS
|
|
124
|
+
confirmed = attempts.times.any? { attempt }
|
|
125
|
+
confirmed ? succeeded : failed(attempts)
|
|
126
|
+
confirmed
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# One more try at an invalidation that failed for good. Without the
|
|
130
|
+
# stamp nothing is removed this time: every key was tried when it failed.
|
|
131
|
+
#
|
|
132
|
+
# @return [Boolean] whether the store confirmed the stamp and every removal
|
|
133
|
+
def try_again
|
|
134
|
+
stamp && @keys.all? { |key| answered? { @cache.expunge(key) } }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def attempt
|
|
140
|
+
stamped = stamp
|
|
141
|
+
# every key is tried, whatever happened to the stamp and to the keys before it
|
|
142
|
+
removed = @keys.map { |key| answered? { remove(key, confirm: stamped) } }
|
|
143
|
+
stamped && removed.all?
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def stamp
|
|
147
|
+
answered? { @cache.write(@stamp_key, SecureRandom.hex(8), ttl: PermissionForge.configuration.cache_ttl) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Without the stamp the attempt has failed already: the key is deleted
|
|
151
|
+
# all the same, but not paid for a second time to confirm it.
|
|
152
|
+
def remove(key, confirm:)
|
|
153
|
+
return @cache.expunge(key) if confirm
|
|
154
|
+
|
|
155
|
+
@cache.delete(key)
|
|
156
|
+
false
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def answered?
|
|
160
|
+
yield
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
@error = e
|
|
163
|
+
false
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The store answers: what is owed from before can be made now.
|
|
167
|
+
def succeeded
|
|
168
|
+
self.class.store_answered!
|
|
169
|
+
self.class.retry_owed
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def failed(attempts)
|
|
173
|
+
invalidation = self.class
|
|
174
|
+
invalidation.store_failed!
|
|
175
|
+
invalidation.owe(user_id: @user_id, stamp_key: @stamp_key, keys: @keys)
|
|
176
|
+
Failure.report(operation: :invalidate, user_id: @user_id, keys: @keys, error: @error, attempts: attempts)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module PermissionForge
|
|
2
|
+
module Cache
|
|
3
|
+
# In-process hash-backed cache. Intended for development, tests, and
|
|
4
|
+
# single-process deployments. Thread-safe via a Mutex so concurrent Puma
|
|
5
|
+
# threads can't corrupt the store, but still PROCESS-LOCAL: in a multi-worker
|
|
6
|
+
# deployment each worker has its own copy, so prefer :rails_cache (Redis,
|
|
7
|
+
# Memcached, SolidCache) in production. The engine logs a warning at boot if
|
|
8
|
+
# this backend is used in Rails.env.production?.
|
|
9
|
+
#
|
|
10
|
+
# @api public
|
|
11
|
+
class MemoryCache < Base
|
|
12
|
+
def initialize(prefix: 'pf')
|
|
13
|
+
super
|
|
14
|
+
@store = {}
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def process_local?
|
|
19
|
+
true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The entry under `key` (a deep copy, keys symbolized); nil when there
|
|
23
|
+
# is none or it has expired.
|
|
24
|
+
#
|
|
25
|
+
# @return [Object, nil]
|
|
26
|
+
def read(key)
|
|
27
|
+
entry = @mutex.synchronize { @store[prefixed_key(key)] }
|
|
28
|
+
return nil if entry.nil?
|
|
29
|
+
|
|
30
|
+
if entry.is_a?(Hash) && entry[:__expires_at__]
|
|
31
|
+
if Time.now >= entry[:__expires_at__]
|
|
32
|
+
@mutex.synchronize { @store.delete(prefixed_key(key)) }
|
|
33
|
+
return nil
|
|
34
|
+
end
|
|
35
|
+
value = entry[:__value__]
|
|
36
|
+
else
|
|
37
|
+
value = entry
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# deep_dup for deep copy — callers can't mutate the cached object.
|
|
41
|
+
deep_symbolize(value.deep_dup)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Stores a deep copy of `value`, expiring after `ttl` seconds (never
|
|
45
|
+
# without one).
|
|
46
|
+
#
|
|
47
|
+
# @return [Object] the value
|
|
48
|
+
def write(key, value, ttl: nil)
|
|
49
|
+
# dup on write too, so a caller mutating the object after write can't
|
|
50
|
+
# corrupt the stored copy.
|
|
51
|
+
stored = value.deep_dup
|
|
52
|
+
entry = ttl ? { __value__: stored, __expires_at__: Time.now + ttl } : stored
|
|
53
|
+
@mutex.synchronize { @store[prefixed_key(key)] = entry }
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Object, nil] the entry removed, nil when there was none
|
|
58
|
+
def delete(key)
|
|
59
|
+
@mutex.synchronize { @store.delete(prefixed_key(key)) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Removes every entry of this process's store.
|
|
63
|
+
#
|
|
64
|
+
# @return [void]
|
|
65
|
+
def clear
|
|
66
|
+
@mutex.synchronize { @store.clear }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# rubocop:disable-next Naming/PredicateMethod -- removes the entries; the answer is whether the store could
|
|
70
|
+
def purge_entries(namespace)
|
|
71
|
+
regexp, = UserKeys.entry_patterns(namespace, prefix: prefix)
|
|
72
|
+
@mutex.synchronize { @store.delete_if { |key, _entry| key.match?(regexp) } }
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module PermissionForge
|
|
2
|
+
module Cache
|
|
3
|
+
# A backend that stores nothing (config.cache_backend = :null): every read misses, so every
|
|
4
|
+
# permission check rebuilds its hash. For tests and debugging.
|
|
5
|
+
#
|
|
6
|
+
# @api public
|
|
7
|
+
class NullCache < Base
|
|
8
|
+
def initialize(prefix: 'pf')
|
|
9
|
+
super
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @return [nil] always a miss
|
|
13
|
+
def read(_key)
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Nothing is kept, on purpose: that is not a failed write (false or nil
|
|
18
|
+
# would say so — see Base#write).
|
|
19
|
+
def write(_key, value, **)
|
|
20
|
+
value
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [nil] there is nothing to remove
|
|
24
|
+
def delete(_key)
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [nil] there is nothing to remove
|
|
29
|
+
def clear
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Nothing is kept: nothing is left either.
|
|
34
|
+
# rubocop:disable-next Naming/PredicateMethod -- removes the entries; the answer is whether the store could
|
|
35
|
+
def clear_entries(_namespace)
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
module PermissionForge
|
|
2
|
+
module Cache
|
|
3
|
+
# Wraps any ActiveSupport::Cache::Store backend.
|
|
4
|
+
# Works with every cache store Rails supports out of the box:
|
|
5
|
+
# - :memory_store
|
|
6
|
+
# - :file_store
|
|
7
|
+
# - :mem_cache_store (Memcached)
|
|
8
|
+
# - :redis_cache_store (Redis)
|
|
9
|
+
# - :solid_cache_store (SolidCache / Rails 8)
|
|
10
|
+
# - :null_store
|
|
11
|
+
# - Any custom ActiveSupport::Cache::Store subclass
|
|
12
|
+
#
|
|
13
|
+
# By default, uses Rails.cache. You can also pass a specific store instance.
|
|
14
|
+
#
|
|
15
|
+
# @api public
|
|
16
|
+
class RailsCache < Base
|
|
17
|
+
# Seconds the empty entry written by #expunge lives.
|
|
18
|
+
EMPTY_ENTRY_TTL = 1
|
|
19
|
+
|
|
20
|
+
# @return [Hash{String => Object}] the entries found, by prefixed key, symbolized as #read does
|
|
21
|
+
def read_multi(keys)
|
|
22
|
+
cache_store.read_multi(*keys.map { |key| prefixed_key(key) }).transform_values { |value| deep_symbolize(value) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Whether the store writes many entries in one call of its own, rather
|
|
26
|
+
# than ActiveSupport's default loop (which ignores each write's answer).
|
|
27
|
+
# Anything that is not an ActiveSupport store (a wrapper) is written key
|
|
28
|
+
# by key through its #write.
|
|
29
|
+
def self.multi_write?(store)
|
|
30
|
+
store.is_a?(ActiveSupport::Cache::Store) &&
|
|
31
|
+
store.class.instance_method(:write_multi_entries).owner != ActiveSupport::Cache::Store
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(prefix: 'pf', store: nil)
|
|
35
|
+
super(prefix: prefix)
|
|
36
|
+
@store = store
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The entry under `key` in the Rails store, keys symbolized; nil for a
|
|
40
|
+
# miss (also what a store that failed the read answers).
|
|
41
|
+
#
|
|
42
|
+
# @return [Object, nil]
|
|
43
|
+
def read(key)
|
|
44
|
+
value = cache_store.read(prefixed_key(key))
|
|
45
|
+
return nil if value.nil?
|
|
46
|
+
|
|
47
|
+
deep_symbolize(value)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Redis, Memcached and Solid Cache stores (and others built on
|
|
51
|
+
# ActiveSupport's LocalCache strategy) remember every read of a request,
|
|
52
|
+
# misses included. A nested local cache starts empty, so the read inside
|
|
53
|
+
# it reaches the store; what the request remembers is left as it is.
|
|
54
|
+
def read_fresh(key)
|
|
55
|
+
store = cache_store
|
|
56
|
+
return read(key) unless store.respond_to?(:with_local_cache)
|
|
57
|
+
|
|
58
|
+
store.with_local_cache { read(key) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# One round trip for all the keys (read_multi), past the request's
|
|
62
|
+
# local copies as #read_fresh reads: a nested local cache starts empty.
|
|
63
|
+
def read_many(keys)
|
|
64
|
+
return {} if keys.empty?
|
|
65
|
+
|
|
66
|
+
store = cache_store
|
|
67
|
+
found = store.respond_to?(:with_local_cache) ? store.with_local_cache { read_multi(keys) } : read_multi(keys)
|
|
68
|
+
keys.to_h { |key| [key, found[prefixed_key(key)]] }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Writes `value` under `key`, expiring after `ttl` seconds (the store's
|
|
72
|
+
# default expiry without one).
|
|
73
|
+
#
|
|
74
|
+
# @return [Boolean] the store's answer: false when it did not take it
|
|
75
|
+
def write(key, value, ttl: nil)
|
|
76
|
+
options = {}
|
|
77
|
+
options[:expires_in] = ttl if ttl
|
|
78
|
+
cache_store.write(prefixed_key(key), value, **options)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Rails' :memory_store lives in each process.
|
|
82
|
+
def process_local?
|
|
83
|
+
cache_store.is_a?(ActiveSupport::Cache::MemoryStore)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# An explicit `expires_in: nil` overrides the store's default expiry.
|
|
87
|
+
def write_permanent(key, value)
|
|
88
|
+
cache_store.write(prefixed_key(key), value, expires_in: nil)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [Boolean] the store's answer: false when there was nothing
|
|
92
|
+
# to remove, or the delete failed (see #expunge)
|
|
93
|
+
def delete(key)
|
|
94
|
+
cache_store.delete(prefixed_key(key))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A delete that reports true removed the entry. One that reports false
|
|
98
|
+
# either found nothing or failed (Redis, Memcached and Solid Cache stores
|
|
99
|
+
# rescue their errors), so the key is overwritten with an empty entry
|
|
100
|
+
# that expires at once: a write says plainly whether it succeeded, and a
|
|
101
|
+
# read of that entry is a miss like any other.
|
|
102
|
+
# rubocop:disable-next Naming/PredicateMethod -- removes the key; the answer is whether the store confirmed it
|
|
103
|
+
def expunge(key)
|
|
104
|
+
name = prefixed_key(key)
|
|
105
|
+
store = cache_store
|
|
106
|
+
return true if store.delete(name)
|
|
107
|
+
|
|
108
|
+
store.write(name, nil, expires_in: EMPTY_ENTRY_TTL) ? true : false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# One round trip for all the entries when the store sends them together
|
|
112
|
+
# (Redis pipelines them, Solid Cache writes them in one statement): its
|
|
113
|
+
# answer is then the answer for every key. Any other store (memory,
|
|
114
|
+
# file, Memcached, null) is written one key at a time through #write,
|
|
115
|
+
# as Base does.
|
|
116
|
+
def write_many(entries, ttl: nil, &)
|
|
117
|
+
return super unless multi_write?
|
|
118
|
+
return {} if entries.empty?
|
|
119
|
+
|
|
120
|
+
written = write_multi(entries, ttl, &)
|
|
121
|
+
entries.transform_values { written }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Overwrites the keys with empty entries that expire at once (as
|
|
125
|
+
# #expunge does when a delete reports nothing), in one #write_many: a
|
|
126
|
+
# write says plainly whether it succeeded, where a multi-delete only
|
|
127
|
+
# counts what it removed. It also replaces the request's local copy of
|
|
128
|
+
# each entry, which ActiveSupport's LocalCache leaves in place for a
|
|
129
|
+
# multi-delete (it only hooks single deletes): a later read in the same
|
|
130
|
+
# request would still find the entry. A read of an empty entry is a
|
|
131
|
+
# miss like any other.
|
|
132
|
+
def expunge_many(keys, &)
|
|
133
|
+
return super unless multi_write?
|
|
134
|
+
|
|
135
|
+
write_many(keys.to_h { |key| [key, nil] }, ttl: EMPTY_ENTRY_TTL, &)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Where the store writes many entries in one call, the keys are
|
|
139
|
+
# overwritten in that one call (#expunge_many — whose answer is not
|
|
140
|
+
# needed here); else one delete per key, as Base does.
|
|
141
|
+
def delete_many(keys, &)
|
|
142
|
+
return super unless multi_write?
|
|
143
|
+
|
|
144
|
+
expunge_many(keys, &)
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Removes every entry under the prefix — where the store can match
|
|
149
|
+
# keys: Redis, memory and file stores. Solid Cache and Memcached stores
|
|
150
|
+
# have no delete_matched, so there this removes nothing and says so in
|
|
151
|
+
# the log (it answers false). On those, invalidate the users instead
|
|
152
|
+
# (User#invalidate_permissions), or change config.cache_key_prefix,
|
|
153
|
+
# which leaves the old entries to expire.
|
|
154
|
+
def clear
|
|
155
|
+
cache_store.delete_matched(prefix_matcher)
|
|
156
|
+
rescue NotImplementedError, ArgumentError
|
|
157
|
+
# Log loudly rather than silently no-op, so a "revoke everyone now"
|
|
158
|
+
# call isn't mistaken for success.
|
|
159
|
+
Rails.logger&.warn(
|
|
160
|
+
"[PermissionForge] cache clear removed nothing: #{cache_store.class} does not support " \
|
|
161
|
+
'delete_matched (Solid Cache and Memcached stores do not). Invalidate the users instead ' \
|
|
162
|
+
'(User#invalidate_permissions), or change config.cache_key_prefix and let the old entries expire.'
|
|
163
|
+
)
|
|
164
|
+
false
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Through the store's delete_matched: a Redis store takes a glob (its
|
|
168
|
+
# only form; the stamps go with the bare keys), the memory and file
|
|
169
|
+
# stores a Regexp (exact; the store's own namespace is put in front by
|
|
170
|
+
# ActiveSupport). A store that has no delete_matched (Memcached, Solid
|
|
171
|
+
# Cache) or refuses the matcher answers false. Whether the removal
|
|
172
|
+
# reached the keys is checked by Base#clear_entries.
|
|
173
|
+
def purge_entries(namespace)
|
|
174
|
+
entry_matchers(namespace).each { |matcher| cache_store.delete_matched(matcher) }
|
|
175
|
+
true
|
|
176
|
+
rescue NotImplementedError, ArgumentError
|
|
177
|
+
false
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
# A Redis store takes globs (its only form); the memory and file stores a Regexp (exact).
|
|
183
|
+
def entry_matchers(namespace)
|
|
184
|
+
regexp, globs = UserKeys.entry_patterns(namespace, prefix: prefix)
|
|
185
|
+
redis_store? ? globs : [regexp]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Every key under the prefix: a glob for Redis; for the memory and file
|
|
189
|
+
# stores a Regexp anchored with `^` (the anchor ActiveSupport keeps when
|
|
190
|
+
# it puts the store's own namespace in front). A String there would be
|
|
191
|
+
# read as an unanchored Regexp ("pf:*" matches any key containing "pf")
|
|
192
|
+
# and raises on a namespaced store.
|
|
193
|
+
def prefix_matcher
|
|
194
|
+
redis_store? ? "#{prefix}:*" : /^#{Regexp.escape(prefix)}:/
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def redis_store?
|
|
198
|
+
cache_store.class.name.to_s.include?('Redis')
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def cache_store
|
|
202
|
+
@store || rails_cache
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def multi_write?
|
|
206
|
+
self.class.multi_write?(cache_store)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Whether the store took all the entries (HandledErrors.written); the
|
|
210
|
+
# error it handed its error handler, or raised, is yielded.
|
|
211
|
+
def write_multi(entries, ttl)
|
|
212
|
+
options = ttl ? { expires_in: ttl } : {}
|
|
213
|
+
store = cache_store
|
|
214
|
+
written, error = HandledErrors.written(store) do
|
|
215
|
+
store.write_multi(entries.transform_keys { |key| prefixed_key(key) }, options)
|
|
216
|
+
end
|
|
217
|
+
yield error if error && block_given?
|
|
218
|
+
written
|
|
219
|
+
rescue StandardError => e
|
|
220
|
+
yield e if block_given?
|
|
221
|
+
false
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def rails_cache
|
|
225
|
+
raise 'Rails.cache is not available. Pass a store: option or ensure Rails is loaded.' unless defined?(Rails) && Rails.respond_to?(:cache)
|
|
226
|
+
|
|
227
|
+
Rails.cache
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Cache
|
|
5
|
+
# Stores a freshly built permission hash — or managed-actions index
|
|
6
|
+
# (ControllerActions::ManagedIndex) — unless it may have been built across
|
|
7
|
+
# an invalidation, in which case it is not left in the shared cache.
|
|
8
|
+
#
|
|
9
|
+
# The race: a rebuild reads the permission rows, a commit invalidates the
|
|
10
|
+
# user, and only then does the rebuild store what it read. Every
|
|
11
|
+
# invalidation leaves a per-user stamp (Cache::Invalidation); a rebuild
|
|
12
|
+
# reads the stamp before it builds and again after it stored. Only when
|
|
13
|
+
# both reads return the same stamp is the entry kept:
|
|
14
|
+
#
|
|
15
|
+
# - the stamp changed: the entry is removed at once (the repeat may take
|
|
16
|
+
# long, or raise) and the hash is built once more, ATTEMPTS times in all;
|
|
17
|
+
# - there is no stamp: the rebuild writes one first and reads it back. A
|
|
18
|
+
# missing stamp and a stamp that could not be read look the same, so a
|
|
19
|
+
# rebuild never concludes anything from two empty reads;
|
|
20
|
+
# - the store cannot keep a stamp (it is down, or it stores nothing): the
|
|
21
|
+
# hash is built once and not stored;
|
|
22
|
+
# - a store call raises (a store without a failsafe): taken as the
|
|
23
|
+
# answer an ActiveSupport store gives instead — nothing read, nothing
|
|
24
|
+
# written — so the request still gets the hash it built;
|
|
25
|
+
# - anything raises along the way: the entry is removed.
|
|
26
|
+
#
|
|
27
|
+
# The caller gets the hash it built in every case. Whether the shared cache
|
|
28
|
+
# kept it is the second value.
|
|
29
|
+
#
|
|
30
|
+
# A hash that must not be shared at all — it was built inside a database
|
|
31
|
+
# transaction that reads from a snapshot (OpenTransaction.withholding) —
|
|
32
|
+
# is built without a single call to the store (#withhold).
|
|
33
|
+
#
|
|
34
|
+
# @api private
|
|
35
|
+
class Rebuild
|
|
36
|
+
# A rebuild that raced an invalidation is repeated once. Racing a second
|
|
37
|
+
# one is rare (two commits within two rebuilds), and repeating without a
|
|
38
|
+
# limit could keep a request busy for as long as records are being saved.
|
|
39
|
+
ATTEMPTS = 2
|
|
40
|
+
|
|
41
|
+
def self.call(...) = new(...).call
|
|
42
|
+
|
|
43
|
+
# @param key [String] the cache key of the hash
|
|
44
|
+
# @param stamp_key [String] where the user's invalidation stamp lives
|
|
45
|
+
# @param user_id [Object, nil] for the failure report
|
|
46
|
+
# @param build [#call] returns [hash, ttl]; reads the rows — past
|
|
47
|
+
# ActiveRecord's query cache, or a repeat reads what the first build read
|
|
48
|
+
def initialize(key:, stamp_key:, user_id:, build:, cache: PermissionForge.cache)
|
|
49
|
+
@key = key
|
|
50
|
+
@stamp_key = stamp_key
|
|
51
|
+
@user_id = user_id
|
|
52
|
+
@build = build
|
|
53
|
+
@cache = cache
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @yieldreturn [Boolean] when a block is given, called after the stamp
|
|
57
|
+
# was read and before the entry is written: false keeps the entry out
|
|
58
|
+
# of the store (a variant whose index entry was not written)
|
|
59
|
+
# @return [Array(Hash, Boolean)] the hash as built, and whether the shared
|
|
60
|
+
# cache kept it
|
|
61
|
+
def call(&before_write)
|
|
62
|
+
@before_write = before_write
|
|
63
|
+
hash = nil
|
|
64
|
+
ATTEMPTS.times do
|
|
65
|
+
hash, outcome = attempt(current_stamp)
|
|
66
|
+
return [hash, true] if outcome == :kept
|
|
67
|
+
break unless outcome == :overtaken
|
|
68
|
+
end
|
|
69
|
+
[hash, false]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Builds and leaves the store alone: no read, no write, no removal. An
|
|
73
|
+
# entry that is there stays there — it holds committed rows, and
|
|
74
|
+
# whoever changes them invalidates it after the commit.
|
|
75
|
+
#
|
|
76
|
+
# @return [Hash] the hash as built
|
|
77
|
+
def withhold
|
|
78
|
+
@build.call.first
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# The stamp to check against: the one in the store, or a new one when
|
|
84
|
+
# there is none — nil when the store does not give it back.
|
|
85
|
+
def current_stamp
|
|
86
|
+
stamp = answered { @cache.read_fresh(@stamp_key) }
|
|
87
|
+
return stamp if stamp
|
|
88
|
+
|
|
89
|
+
stamp = SecureRandom.hex(8)
|
|
90
|
+
written = answered { @cache.write(@stamp_key, stamp, ttl: PermissionForge.configuration.cache_ttl) }
|
|
91
|
+
stamp if written && answered { @cache.read_fresh(@stamp_key) } == stamp
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @return [Array(Hash, Symbol)] the hash and what became of it:
|
|
95
|
+
# :kept, :overtaken (an invalidation happened meanwhile — worth another
|
|
96
|
+
# attempt) or :not_stored (the store did not take it — repeating the
|
|
97
|
+
# build would change nothing)
|
|
98
|
+
def attempt(stamp)
|
|
99
|
+
hash, ttl = @build.call
|
|
100
|
+
stored = stamp && ready_to_write? && answered { @cache.write(@key, hash, ttl: ttl) }
|
|
101
|
+
outcome = stored ? verify(stamp) : :not_stored
|
|
102
|
+
[hash, outcome]
|
|
103
|
+
ensure
|
|
104
|
+
# Without a stamp nothing was written. With one, a write was sent:
|
|
105
|
+
# also one the store said it did not take may have been applied.
|
|
106
|
+
discard if stamp && outcome != :kept
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# What must happen before the entry is written, after the stamp was
|
|
110
|
+
# read (the block given to #call): an invalidation from then on either
|
|
111
|
+
# finds what it wrote or makes the verification fail.
|
|
112
|
+
def ready_to_write?
|
|
113
|
+
@before_write.nil? || answered { @before_write.call } ? true : false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# A stamp that cannot be read back is not verified, and not worth a
|
|
117
|
+
# second build either.
|
|
118
|
+
def verify(stamp)
|
|
119
|
+
current = answered { @cache.read_fresh(@stamp_key) }
|
|
120
|
+
return :not_stored if current.nil?
|
|
121
|
+
|
|
122
|
+
current == stamp ? :kept : :overtaken
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The store's answer; nil when the call raised.
|
|
126
|
+
def answered
|
|
127
|
+
yield
|
|
128
|
+
rescue StandardError
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# An entry this rebuild wrote and could not verify must not stay. When
|
|
133
|
+
# the store does not confirm its removal either, the removal is owed
|
|
134
|
+
# like an invalidation that failed (Backlog), and somebody has to know —
|
|
135
|
+
# once: while it is owed, the rebuilds that follow fail the same way.
|
|
136
|
+
def discard
|
|
137
|
+
removed = begin
|
|
138
|
+
@cache.expunge(@key)
|
|
139
|
+
rescue StandardError => e
|
|
140
|
+
error = e
|
|
141
|
+
false
|
|
142
|
+
end
|
|
143
|
+
return if removed || !Invalidation.owe(user_id: @user_id, stamp_key: @stamp_key, keys: [@key])
|
|
144
|
+
|
|
145
|
+
Failure.report(operation: :discard, user_id: @user_id, keys: [@key], error: error)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|