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,200 @@
|
|
|
1
|
+
module PermissionForge
|
|
2
|
+
# The permission cache backends (Base and its subclasses, config.cache_backend) and the
|
|
3
|
+
# machinery that invalidates and rebuilds cached permission hashes. The backends are public;
|
|
4
|
+
# each class says which it is (a public namespace does not make its children public).
|
|
5
|
+
#
|
|
6
|
+
# @api public
|
|
7
|
+
module Cache
|
|
8
|
+
# The interface a cache backend implements (config.cache_backend accepts a subclass):
|
|
9
|
+
# read/write/delete/clear, plus what the gem builds on them — #read_fresh, #expunge, the
|
|
10
|
+
# multi-key calls and the removal of permission entries by pattern (#clear_entries).
|
|
11
|
+
#
|
|
12
|
+
# @api public
|
|
13
|
+
class Base
|
|
14
|
+
# @return [String] put in front of every key, with a colon (config.cache_key_prefix)
|
|
15
|
+
attr_reader :prefix
|
|
16
|
+
|
|
17
|
+
def initialize(prefix: 'pf')
|
|
18
|
+
@prefix = prefix
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def read(key)
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Reads `key` from the store itself. A store that keeps a copy of what a
|
|
26
|
+
# request has read (ActiveSupport's local cache) answers a repeated read
|
|
27
|
+
# from that copy — also a miss; a value another process may have written
|
|
28
|
+
# in the meantime has to be read past it. Backends without such a copy
|
|
29
|
+
# read as usual.
|
|
30
|
+
def read_fresh(key)
|
|
31
|
+
read(key)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Object] something truthy when the value was written; false or
|
|
35
|
+
# nil when the store reports that it was not (ActiveSupport stores
|
|
36
|
+
# answer that instead of raising when they are unreachable)
|
|
37
|
+
def write(key, value, ttl: nil)
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Whether every process has a store of its own (an in-memory store):
|
|
42
|
+
# clearing or writing it here reaches no other process.
|
|
43
|
+
#
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def process_local?
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Writes an entry that never expires — not even under a default expiry
|
|
50
|
+
# the store applies to every entry (a Rails cache configured with
|
|
51
|
+
# expires_in). Here a write without a TTL, which is that already.
|
|
52
|
+
#
|
|
53
|
+
# @return [Object] as #write
|
|
54
|
+
def write_permanent(key, value)
|
|
55
|
+
write(key, value)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def delete(key)
|
|
59
|
+
raise NotImplementedError
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Removes `key` and says whether it is certainly gone. `delete` alone
|
|
63
|
+
# cannot say that for a store that swallows its errors: it answers false
|
|
64
|
+
# both for "there was nothing to delete" and for "the delete failed"
|
|
65
|
+
# (RailsCache#expunge deals with that). A backend that can only fail by
|
|
66
|
+
# raising is taken at its word unless its delete says false.
|
|
67
|
+
#
|
|
68
|
+
# @return [Boolean] false when the store could not confirm the removal
|
|
69
|
+
# rubocop:disable-next Naming/PredicateMethod -- removes the key; the answer is whether the store confirmed it
|
|
70
|
+
def expunge(key)
|
|
71
|
+
delete(key) != false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def clear
|
|
75
|
+
raise NotImplementedError
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Removes every permission entry of one key namespace — the base
|
|
79
|
+
# entries of every user and tenant, and their variants
|
|
80
|
+
# (UserKeys.entry_patterns) — where the store can match keys
|
|
81
|
+
# (#purge_entries), and says whether it did. Verified, not taken on
|
|
82
|
+
# trust: an entry of each key shape (UserKeys.canary_keys) is written
|
|
83
|
+
# before and read back after; a pattern that did not reach the store's
|
|
84
|
+
# keys (a store namespace the pattern does not know) leaves them, and
|
|
85
|
+
# the answer is false. False also when the backend cannot match keys,
|
|
86
|
+
# or the canaries could not be written: the caller enumerates the
|
|
87
|
+
# users instead (Services::PermissionCacheClearer).
|
|
88
|
+
#
|
|
89
|
+
# @param namespace [String] one of UserKeys.namespaces
|
|
90
|
+
# @return [Boolean]
|
|
91
|
+
def clear_entries(namespace)
|
|
92
|
+
canaries = UserKeys.canary_keys(namespace)
|
|
93
|
+
return false unless canaries.all? { |key| write(key, CANARY_ENTRY, ttl: CANARY_TTL) }
|
|
94
|
+
return false unless purge_entries(namespace)
|
|
95
|
+
|
|
96
|
+
canaries.none? { |key| read_fresh(key) }
|
|
97
|
+
ensure
|
|
98
|
+
canaries&.each { |key| delete(key) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# What a canary entry holds, and how long it may live if left behind.
|
|
102
|
+
CANARY_ENTRY = { canary: true }.freeze
|
|
103
|
+
CANARY_TTL = 60
|
|
104
|
+
|
|
105
|
+
# Removes the entries matching UserKeys.entry_patterns in one call per
|
|
106
|
+
# pattern, where the backend can. Here it cannot.
|
|
107
|
+
#
|
|
108
|
+
# @return [Boolean] whether the backend could
|
|
109
|
+
# rubocop:disable-next Naming/PredicateMethod -- removes the entries; the answer is whether the store could
|
|
110
|
+
def purge_entries(_namespace)
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Writes several entries and says, per key, whether the store took it
|
|
115
|
+
# (#write). Here one call per key; a backend that can send them in one
|
|
116
|
+
# round trip overrides it (RailsCache). Every key is tried: one whose
|
|
117
|
+
# write raises answers false, and the error is given to the block.
|
|
118
|
+
#
|
|
119
|
+
# @param entries [Hash{String => Object}]
|
|
120
|
+
# @param ttl [Integer, nil] seconds, for every entry
|
|
121
|
+
# @yieldparam error [StandardError] what a write raised
|
|
122
|
+
# @return [Hash{String => Boolean}]
|
|
123
|
+
def write_many(entries, ttl: nil, &on_error)
|
|
124
|
+
entries.to_h { |key, value| [key, answer(on_error) { write(key, value, ttl: ttl) }] }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Removes several keys and says, per key, whether it is certainly gone
|
|
128
|
+
# (#expunge). Here one call per key; a backend that can send them in
|
|
129
|
+
# one round trip overrides it (RailsCache). Every key is tried: one
|
|
130
|
+
# whose removal raises answers false, and the error is given to the
|
|
131
|
+
# block.
|
|
132
|
+
#
|
|
133
|
+
# @param keys [Array<String>]
|
|
134
|
+
# @yieldparam error [StandardError] what a removal raised
|
|
135
|
+
# @return [Hash{String => Boolean}]
|
|
136
|
+
def expunge_many(keys, &on_error)
|
|
137
|
+
keys.to_h { |key| [key, answer(on_error) { expunge(key) }] }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Reads several keys from the store itself (#read_fresh) and answers,
|
|
141
|
+
# per key, the entry or nil. Here one read per key; a backend that can
|
|
142
|
+
# fetch them in one round trip overrides it (RailsCache). A key whose
|
|
143
|
+
# read raises answers nil.
|
|
144
|
+
#
|
|
145
|
+
# @param keys [Array<String>]
|
|
146
|
+
# @return [Hash{String => Object, nil}]
|
|
147
|
+
def read_many(keys)
|
|
148
|
+
keys.to_h { |key| [key, fresh_or_nil(key)] }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Removes several keys without asking whether they are gone (#delete):
|
|
152
|
+
# for an invalidation that has failed already (its stamp was refused),
|
|
153
|
+
# where a confirmation would be paid for twice. Here one call per key;
|
|
154
|
+
# RailsCache sends them in one round trip where it can. Every key is
|
|
155
|
+
# tried; an error is given to the block.
|
|
156
|
+
#
|
|
157
|
+
# @param keys [Array<String>]
|
|
158
|
+
# @yieldparam error [StandardError] what a removal raised
|
|
159
|
+
# @return [void]
|
|
160
|
+
def delete_many(keys, &on_error)
|
|
161
|
+
keys.each { |key| answer(on_error) { delete(key) } }
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def fresh_or_nil(key)
|
|
168
|
+
read_fresh(key)
|
|
169
|
+
rescue StandardError
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The store's answer as true or false; false, after the error is
|
|
174
|
+
# handed on, when the call raised.
|
|
175
|
+
def answer(on_error)
|
|
176
|
+
yield ? true : false
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
on_error&.call(e)
|
|
179
|
+
false
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def prefixed_key(key)
|
|
183
|
+
"#{prefix}:#{key}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Stores hand back string keys (JSON) or the hash as written; the
|
|
187
|
+
# permission hash is read with symbol keys.
|
|
188
|
+
def deep_symbolize(obj)
|
|
189
|
+
case obj
|
|
190
|
+
when Hash
|
|
191
|
+
obj.to_h { |k, v| [k.to_sym, deep_symbolize(v)] }
|
|
192
|
+
when Array
|
|
193
|
+
obj.map { |v| deep_symbolize(v) }
|
|
194
|
+
else
|
|
195
|
+
obj
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Cache
|
|
5
|
+
# Many invalidations at once: what Invalidation does for one — a fresh
|
|
6
|
+
# stamp, then the keys, every answer checked, ATTEMPTS tries, what still
|
|
7
|
+
# fails reported (Failure) and owed (Backlog) — with one multi-key call
|
|
8
|
+
# per step (Base#write_many, Base#expunge_many) instead of one call per
|
|
9
|
+
# key. On a store that sends them together (Redis, Solid Cache) a slice
|
|
10
|
+
# of users costs two round trips instead of three to five per user.
|
|
11
|
+
#
|
|
12
|
+
# The order holds for every user: all the stamps of the call are written
|
|
13
|
+
# before any key is removed, so each user's stamp is there before that
|
|
14
|
+
# user's keys go (see Invalidation). The keys are removed even when the
|
|
15
|
+
# stamps could not be written — then plainly deleted, not confirmed
|
|
16
|
+
# (Invalidation#remove): that user has failed already, and on a store
|
|
17
|
+
# that is down a confirmation is one more failing call per key. An
|
|
18
|
+
# attempt only repeats the users the store did not confirm.
|
|
19
|
+
#
|
|
20
|
+
# Callers keep the size of one call bounded (HolderInvalidation::SLICE).
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
class BulkInvalidation
|
|
24
|
+
def self.call(...) = new(...).call
|
|
25
|
+
|
|
26
|
+
# The ids of the users whose invalidation the store did not confirm.
|
|
27
|
+
# A call that raises as a whole is owed and reported user by user.
|
|
28
|
+
#
|
|
29
|
+
# @param targets [Array<Hash>] as for #initialize
|
|
30
|
+
# @return [Array]
|
|
31
|
+
def self.unconfirmed(targets)
|
|
32
|
+
call(targets).map { |target| target[:user_id] }
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
targets.each do |target|
|
|
35
|
+
Invalidation.owe(**target)
|
|
36
|
+
Failure.report(operation: :invalidate, **target.slice(:user_id, :keys), error: e)
|
|
37
|
+
end
|
|
38
|
+
targets.map { |target| target[:user_id] }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param targets [Array<Hash>] one per user, the arguments of an
|
|
42
|
+
# Invalidation: user_id:, stamp_key:, keys:
|
|
43
|
+
def initialize(targets, cache: PermissionForge.cache)
|
|
44
|
+
@targets = targets
|
|
45
|
+
@cache = cache
|
|
46
|
+
@error = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Array<Hash>] the targets the store did not confirm (empty
|
|
50
|
+
# when it confirmed every one)
|
|
51
|
+
def call
|
|
52
|
+
attempts = Invalidation.store_failing? ? 1 : Invalidation::ATTEMPTS
|
|
53
|
+
pending = @targets
|
|
54
|
+
attempts.times do
|
|
55
|
+
break if pending.empty?
|
|
56
|
+
|
|
57
|
+
pending = attempt(pending)
|
|
58
|
+
end
|
|
59
|
+
failed(pending, attempts) if pending.any?
|
|
60
|
+
# as Invalidation does user by user: once one is confirmed, the store
|
|
61
|
+
# answers, and what is owed (the failures just now included) is tried
|
|
62
|
+
succeeded if pending.size < @targets.size
|
|
63
|
+
pending
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# @return [Array<Hash>] the targets still unconfirmed after this attempt
|
|
69
|
+
def attempt(targets)
|
|
70
|
+
stamped = answers { @cache.write_many(fresh_stamps(targets), ttl: PermissionForge.configuration.cache_ttl, &method(:keep_error)) }
|
|
71
|
+
confirming, failed = targets.partition { |target| stamped[target[:stamp_key]] }
|
|
72
|
+
# the keys are removed whatever became of the stamps
|
|
73
|
+
delete_keys(failed)
|
|
74
|
+
removed = expunge_keys(confirming)
|
|
75
|
+
targets.reject { |target| confirmed?(target, stamped, removed) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Users whose stamp was refused: deleted, no confirmation asked.
|
|
79
|
+
def delete_keys(targets)
|
|
80
|
+
answers { @cache.delete_many(keys_of(targets), &method(:keep_error)) } if targets.any?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Hash{String => Boolean}] whether each key is certainly gone
|
|
84
|
+
def expunge_keys(targets)
|
|
85
|
+
return {} if targets.empty?
|
|
86
|
+
|
|
87
|
+
answers { @cache.expunge_many(keys_of(targets), &method(:keep_error)) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def keys_of(targets)
|
|
91
|
+
targets.flat_map { |target| target[:keys] }.uniq
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A backend whose multi-key call raises as a whole confirmed nothing.
|
|
95
|
+
def answers
|
|
96
|
+
yield
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
keep_error(e)
|
|
99
|
+
{}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The user's stamp was written and every one of its keys removed.
|
|
103
|
+
def confirmed?(target, stamped, removed)
|
|
104
|
+
stamped[target[:stamp_key]] && target[:keys].all? { |key| removed[key] }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def fresh_stamps(targets)
|
|
108
|
+
targets.to_h { |target| [target[:stamp_key], SecureRandom.hex(8)] }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The last error a store call raised, for the failure report.
|
|
112
|
+
def keep_error(error)
|
|
113
|
+
@error = error
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The store answers: what is owed from before can be made now.
|
|
117
|
+
def succeeded
|
|
118
|
+
Invalidation.store_answered!
|
|
119
|
+
Invalidation.retry_owed
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def failed(targets, attempts)
|
|
123
|
+
Invalidation.store_failed!
|
|
124
|
+
targets.each do |target|
|
|
125
|
+
Invalidation.owe(**target)
|
|
126
|
+
Failure.report(operation: :invalidate, **target.slice(:user_id, :keys), error: @error, attempts: attempts)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Cache
|
|
5
|
+
# Makes a cache failure visible without raising: an invalidation the store
|
|
6
|
+
# did not confirm, an entry that could not be removed, or an error inside
|
|
7
|
+
# an after_commit invalidation. Raising there would make Rails skip the
|
|
8
|
+
# commit callbacks of the remaining records, so the failure is
|
|
9
|
+
#
|
|
10
|
+
# - logged at error level,
|
|
11
|
+
# - published as the `cache_failure.permission_forge` notification, and
|
|
12
|
+
# - handed to `config.on_cache_failure`.
|
|
13
|
+
#
|
|
14
|
+
# A subscriber or a hook that raises is logged and does not stop the other.
|
|
15
|
+
#
|
|
16
|
+
# What a failure means: a cached permission hash that should be gone may
|
|
17
|
+
# still be in the store, and stays there until `cache_ttl`.
|
|
18
|
+
#
|
|
19
|
+
# Code that has to say whether an invalidation went through asks here
|
|
20
|
+
# (.none?), not the method it called: a host app overrides
|
|
21
|
+
# User#invalidate_permissions, and what such an override returns is its
|
|
22
|
+
# own business.
|
|
23
|
+
#
|
|
24
|
+
# @api private
|
|
25
|
+
module Failure
|
|
26
|
+
# The failures reported inside a Failure.collect block.
|
|
27
|
+
#
|
|
28
|
+
# @api private
|
|
29
|
+
class Collected
|
|
30
|
+
# Payloads kept per collect. A collect abandoned in an Enumerator
|
|
31
|
+
# stays open (the end of a run keeps the open collects), so what it
|
|
32
|
+
# keeps is bounded; past the bound it confirms nobody (fails closed).
|
|
33
|
+
MAX_PAYLOADS = 1_000
|
|
34
|
+
|
|
35
|
+
def initialize
|
|
36
|
+
@payloads = []
|
|
37
|
+
@overflowed = false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def <<(payload)
|
|
41
|
+
@payloads.size < MAX_PAYLOADS ? @payloads << payload : @overflowed = true
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Array<Hash>] the payloads, in order
|
|
46
|
+
def to_a
|
|
47
|
+
@payloads.dup
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Those of `user_ids` that no failure was about — none when one was
|
|
51
|
+
# about nobody in particular (it may be about any of them).
|
|
52
|
+
#
|
|
53
|
+
# @param user_ids [Array]
|
|
54
|
+
# @return [Array]
|
|
55
|
+
def confirmed(user_ids)
|
|
56
|
+
return [] if @overflowed || failed_user_ids.include?(nil)
|
|
57
|
+
|
|
58
|
+
user_ids.reject { |user_id| failed_user_ids.include?(user_id.to_s) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# As Strings (a key may come back as either); nil for a failure
|
|
64
|
+
# about nobody in particular.
|
|
65
|
+
def failed_user_ids
|
|
66
|
+
@payloads.map { |payload| payload[:user_id]&.to_s }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
EVENT = 'cache_failure.permission_forge'
|
|
71
|
+
# ExecutionState slots: how many failures were reported on this thread
|
|
72
|
+
# (fiber under Rails' fiber isolation), and the open collect blocks.
|
|
73
|
+
COUNT_KEY = :pf_cache_failures
|
|
74
|
+
COLLECT_KEY = :pf_cache_failure_collectors
|
|
75
|
+
# Open collects kept per thread: more can only be abandoned ones (an
|
|
76
|
+
# Enumerator paused inside a collect), so the oldest go first.
|
|
77
|
+
MAX_OPEN_COLLECTS = 64
|
|
78
|
+
|
|
79
|
+
class << self
|
|
80
|
+
# Runs the block and says whether it got through without a failure
|
|
81
|
+
# being reported on this thread: every invalidation made inside it was
|
|
82
|
+
# confirmed by the store.
|
|
83
|
+
#
|
|
84
|
+
# @return [Boolean]
|
|
85
|
+
def none?
|
|
86
|
+
before = count
|
|
87
|
+
yield
|
|
88
|
+
count == before
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Runs the block and returns what was reported on this thread inside it.
|
|
92
|
+
# The open collects are one list per thread (fiber under fiber
|
|
93
|
+
# isolation): each one takes out its own Collected when it ends, so
|
|
94
|
+
# collects that interleave (one paused in an Enumerator) never take
|
|
95
|
+
# each other's.
|
|
96
|
+
#
|
|
97
|
+
# @return [Collected]
|
|
98
|
+
def collect
|
|
99
|
+
collected = Collected.new
|
|
100
|
+
open = ExecutionState.fetch(COLLECT_KEY) { [] }
|
|
101
|
+
open.shift while open.size >= MAX_OPEN_COLLECTS
|
|
102
|
+
open.push(collected)
|
|
103
|
+
yield
|
|
104
|
+
collected
|
|
105
|
+
ensure
|
|
106
|
+
ExecutionState[COLLECT_KEY]&.delete_if { |open| open.equal?(collected) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @param operation [Symbol] :invalidate, :discard or :after_commit
|
|
110
|
+
# @param details [Hash] user_id: whose cached permissions are concerned,
|
|
111
|
+
# keys: cache keys that may still hold an entry, error: what the store
|
|
112
|
+
# (or the callback) raised — and anything else worth knowing
|
|
113
|
+
# (record:, attempts:)
|
|
114
|
+
# @return [void]
|
|
115
|
+
def report(operation:, **details)
|
|
116
|
+
payload = { operation: operation, user_id: nil, keys: [], error: nil, **details }
|
|
117
|
+
|
|
118
|
+
ExecutionState[COUNT_KEY] = count + 1
|
|
119
|
+
ExecutionState[COLLECT_KEY]&.each { |collected| collected << payload }
|
|
120
|
+
log(payload)
|
|
121
|
+
notify(payload)
|
|
122
|
+
call_hook(payload)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def count
|
|
128
|
+
ExecutionState[COUNT_KEY] || 0
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def log(payload)
|
|
132
|
+
return unless defined?(Rails) && Rails.logger
|
|
133
|
+
|
|
134
|
+
Rails.logger.error("[PermissionForge] #{message(payload)} — cached permissions may be out of date until cache_ttl")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def message(payload)
|
|
138
|
+
cause = payload[:error]
|
|
139
|
+
parts = ["cache #{payload[:operation]} failed"]
|
|
140
|
+
parts << "for user #{payload[:user_id]}" if payload[:user_id]
|
|
141
|
+
parts << "(keys: #{payload[:keys].join(', ')})" if payload[:keys].any?
|
|
142
|
+
parts << "— #{cause.class}: #{cause.message}" if cause
|
|
143
|
+
parts.join(' ')
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A subscriber that raises would abort the commit callbacks this is
|
|
147
|
+
# reported from, like a hook that raises.
|
|
148
|
+
def notify(payload)
|
|
149
|
+
ActiveSupport::Notifications.instrument(EVENT, payload)
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
log_error('subscriber', e)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def call_hook(payload)
|
|
155
|
+
hook = PermissionForge.configuration.on_cache_failure
|
|
156
|
+
return unless hook
|
|
157
|
+
|
|
158
|
+
hook.call(**payload)
|
|
159
|
+
rescue StandardError => e
|
|
160
|
+
log_error('on_cache_failure hook', e)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def log_error(source, error)
|
|
164
|
+
return unless defined?(Rails) && Rails.logger
|
|
165
|
+
|
|
166
|
+
Rails.logger.error("[PermissionForge] #{EVENT} #{source} error: #{error.class}: #{error.message}")
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Cache
|
|
5
|
+
# The errors a store rescued during a call and handed to its error
|
|
6
|
+
# handler. Rails' Redis store rescues a Redis that cannot be reached,
|
|
7
|
+
# hands the error to its handler (the `error_handler:` option) and goes
|
|
8
|
+
# on as if nothing had been asked of it. Its answer to #write_multi is
|
|
9
|
+
# then no proof: Rails 7.1 to 8.1 answer nil, the store Rails 8.2 ships
|
|
10
|
+
# answers the same Hash whether the batch was written or not. The call
|
|
11
|
+
# of the handler is the one sign every version gives, so
|
|
12
|
+
# RailsCache#write_multi asks here whether there was one.
|
|
13
|
+
#
|
|
14
|
+
# The first time a store is watched, the handler it keeps (its
|
|
15
|
+
# @error_handler) is wrapped: the wrapper notes the error for the call
|
|
16
|
+
# being watched in this execution context, then calls the store's own
|
|
17
|
+
# handler (if it has one) with the same arguments, as the store would
|
|
18
|
+
# have. Outside a watched call it only hands the error on. A store
|
|
19
|
+
# without an @error_handler is not watched (nothing is noted).
|
|
20
|
+
#
|
|
21
|
+
# @api private
|
|
22
|
+
module HandledErrors
|
|
23
|
+
# ExecutionState slot: the errors noted for the watched call in progress.
|
|
24
|
+
KEY = :pf_store_errors
|
|
25
|
+
LOCK = Mutex.new
|
|
26
|
+
private_constant :LOCK
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Runs the block (a call of `store`) and returns its value with the
|
|
30
|
+
# errors the store handed its error handler meanwhile, in this
|
|
31
|
+
# execution context. An entry is nil when the handler was given no
|
|
32
|
+
# exception: a call is still noted.
|
|
33
|
+
#
|
|
34
|
+
# @return [Array(Object, Array<Exception, nil>)]
|
|
35
|
+
def during(store, &)
|
|
36
|
+
return [yield, []] unless store.instance_variable_defined?(:@error_handler)
|
|
37
|
+
|
|
38
|
+
wrap(store)
|
|
39
|
+
handled = []
|
|
40
|
+
[ExecutionState.with(KEY, handled, &), handled]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Runs the block (a write to `store`) and says whether it was written:
|
|
44
|
+
# the store answered truthy and handed its error handler no error
|
|
45
|
+
# meanwhile (Rails 8.2's Redis store answers the same either way).
|
|
46
|
+
# Also returns the last error handed on, if any.
|
|
47
|
+
#
|
|
48
|
+
# @return [Array(Boolean, Exception, nil)]
|
|
49
|
+
def written(store, &)
|
|
50
|
+
answer, handled = during(store, &)
|
|
51
|
+
[answer ? handled.empty? : false, handled.compact.last]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Puts a Handler in front of the store's own error handler, once.
|
|
57
|
+
def wrap(store)
|
|
58
|
+
return if store.instance_variable_get(:@error_handler).is_a?(Handler)
|
|
59
|
+
|
|
60
|
+
LOCK.synchronize do
|
|
61
|
+
handler = store.instance_variable_get(:@error_handler)
|
|
62
|
+
store.instance_variable_set(:@error_handler, Handler.new(handler)) unless handler.is_a?(Handler)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Stands in for a store's error handler.
|
|
68
|
+
class Handler
|
|
69
|
+
def initialize(handler)
|
|
70
|
+
@handler = handler
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Notes the error for the watched call (if one is in progress), then
|
|
74
|
+
# hands everything to the store's own handler.
|
|
75
|
+
def call(*, **details, &)
|
|
76
|
+
ExecutionState[KEY]&.push(details[:exception])
|
|
77
|
+
@handler&.call(*, **details, &)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Cache
|
|
5
|
+
# Invalidates the cached hashes of the holders of a changed permission
|
|
6
|
+
# record by user id, in slices of SLICE users: the same stamps and keys
|
|
7
|
+
# as User#invalidate_permissions (Cache::UserKeys), made through
|
|
8
|
+
# BulkInvalidation — one multi-key write for the stamps and one for the
|
|
9
|
+
# keys per slice — without loading a single user. A change to a set with
|
|
10
|
+
# 10,000 holders cost 30,000 store calls and 20,000 loaded records in the
|
|
11
|
+
# saving request before.
|
|
12
|
+
#
|
|
13
|
+
# Only where nothing but those store calls would happen for a user
|
|
14
|
+
# (.by_id?). Otherwise the holders are loaded and invalidated one by one
|
|
15
|
+
# through the public User#invalidate_permissions, as before
|
|
16
|
+
# (Concerns::InvalidatesInBatch):
|
|
17
|
+
# - the user model overrides invalidate_permissions, or the key or
|
|
18
|
+
# stamp it is built from (PermissionCacheAccessor): the host's code
|
|
19
|
+
# has to run;
|
|
20
|
+
# - somebody listens for permissions.invalidated (or stores it): each
|
|
21
|
+
# event carries its user;
|
|
22
|
+
# - the hashes are withheld in an open transaction that reads from a
|
|
23
|
+
# snapshot (OpenTransaction.withholding): each invalidation is then
|
|
24
|
+
# settled after the commit by the user's own instance.
|
|
25
|
+
#
|
|
26
|
+
# The processes' memos are retired as invalidate_permissions retires
|
|
27
|
+
# them: the cache version is bumped once per slice. Nothing is read: the
|
|
28
|
+
# per-context variants of a user with conditional sets are left to their
|
|
29
|
+
# TTL, unreachable once the base entry is gone (Cache::UserKeys).
|
|
30
|
+
#
|
|
31
|
+
# @api private
|
|
32
|
+
class HolderInvalidation
|
|
33
|
+
SLICE = 500
|
|
34
|
+
EVENT = 'permissions.invalidated'
|
|
35
|
+
|
|
36
|
+
# The methods of PermissionCacheAccessor whose work this does in bulk.
|
|
37
|
+
OWN_METHODS = %i[invalidate_permissions permission_cache_key pf_invalidation_stamp_key].freeze
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
def call(...) = new(...).call
|
|
41
|
+
|
|
42
|
+
# Whether the holders can be invalidated by id, here and now.
|
|
43
|
+
#
|
|
44
|
+
# @param model [Class] the host's user model
|
|
45
|
+
# @return [Boolean]
|
|
46
|
+
def by_id?(model)
|
|
47
|
+
own_methods?(model) && !PermissionForge::Audit.instrument?(EVENT) && !PermissionForge::OpenTransaction.withholding
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# The model and its subclasses (an STI `Admin < User` that overrides
|
|
53
|
+
# them would be loaded as an Admin by the per-user path). A subclass
|
|
54
|
+
# that is not loaded yet (development) is not seen.
|
|
55
|
+
def own_methods?(model)
|
|
56
|
+
[model, *model.descendants].all? do |klass|
|
|
57
|
+
OWN_METHODS.all? do |name|
|
|
58
|
+
(klass.method_defined?(name) || klass.private_method_defined?(name)) &&
|
|
59
|
+
klass.instance_method(name).owner == PermissionForge::Concerns::PermissionCacheAccessor
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param user_ids [Array] the holders' ids (no duplicates)
|
|
66
|
+
# @param tenant_id [Object, nil] the changed record's tenant
|
|
67
|
+
def initialize(user_ids:, tenant_id:)
|
|
68
|
+
@user_ids = user_ids
|
|
69
|
+
@tenant_id = tenant_id
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Array] the ids whose invalidation the store did not confirm
|
|
73
|
+
# (each reported through Cache::Failure, and owed)
|
|
74
|
+
def call
|
|
75
|
+
@user_ids.each_slice(SLICE).flat_map do |slice|
|
|
76
|
+
# a slice whose invalidation raised is owed and reported user by user, and the next slices still get theirs
|
|
77
|
+
BulkInvalidation.unconfirmed(UserKeys.invalidations(slice, tenant_id: @tenant_id))
|
|
78
|
+
ensure
|
|
79
|
+
# after the invalidation, whatever became of it, as invalidate_permissions does
|
|
80
|
+
PermissionForge.bump_permissions_cache_version!
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|