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,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# What the permission records committed by one transaction have already
|
|
5
|
+
# invalidated, so the next record of that commit does not repeat it: a
|
|
6
|
+
# "Review and save" that writes 20 records in a Permission Set assigned to
|
|
7
|
+
# 50 people invalidates 50 hashes, not 1,000.
|
|
8
|
+
#
|
|
9
|
+
# A batch never holds work that only it knows about. Each record still
|
|
10
|
+
# invalidates in its own after_commit (Concerns::InvalidatesInBatch); the
|
|
11
|
+
# batch remembers what was done after the commit — which users (#once) and
|
|
12
|
+
# which affected-user lookups (#fetch) — and keeps a second copy of what
|
|
13
|
+
# records noted for the commit (#note). Mistrusting or losing a batch
|
|
14
|
+
# therefore costs repeated invalidations, never one a record would have
|
|
15
|
+
# made on its own.
|
|
16
|
+
#
|
|
17
|
+
# Lifecycle:
|
|
18
|
+
# 1. A record joins the batch still open on its thread and connection when
|
|
19
|
+
# it is written (.join). An open batch has been used by no commit, so
|
|
20
|
+
# it remembers nothing — joining one cannot bring anything stale along.
|
|
21
|
+
# 2. The first after_commit of a record that joined claims it (#claim):
|
|
22
|
+
# from then on nothing can join, and the batch notes where the claim
|
|
23
|
+
# happened — the transaction depth of its connection and the ambient
|
|
24
|
+
# tenant.
|
|
25
|
+
# 3. Later records of that commit claim it from the same place and reuse
|
|
26
|
+
# what it remembers. A claim from anywhere else is refused, and that
|
|
27
|
+
# record invalidates on its own.
|
|
28
|
+
#
|
|
29
|
+
# Why the depth: a batch must only be reused by records whose transaction had
|
|
30
|
+
# committed before the batch remembered anything. While a record's
|
|
31
|
+
# transaction is open it sits on the connection's transaction stack; its
|
|
32
|
+
# after_commit runs once it has been popped. So everything remembered at the
|
|
33
|
+
# depth a record sees in its own after_commit was remembered after that
|
|
34
|
+
# record's transaction left the stack — after its commit. Without the check,
|
|
35
|
+
# a transaction nested in a non-joinable one (it runs its commit callbacks
|
|
36
|
+
# while the outer transaction is still open) would cover for the outer
|
|
37
|
+
# transaction's records, whose rows are not visible to anyone else yet.
|
|
38
|
+
#
|
|
39
|
+
# Why the tenant: lookups and cache keys follow the ambient tenant, so what
|
|
40
|
+
# was remembered under one tenant says nothing about another.
|
|
41
|
+
#
|
|
42
|
+
# What it remembers is forgotten as soon as another permission record is
|
|
43
|
+
# written (.note_write): the commit's after_commit callbacks are not the only
|
|
44
|
+
# code that runs between two of them. A callback in between may write a
|
|
45
|
+
# record whose own invalidation comes too early to count — in a transaction
|
|
46
|
+
# nested in a non-joinable one it runs at the savepoint release, before the
|
|
47
|
+
# COMMIT anyone else can see. The records still waiting then invalidate
|
|
48
|
+
# again, after that write, as they did before there were batches. The count
|
|
49
|
+
# of writes is kept for the whole process: such a write may be made in
|
|
50
|
+
# another fiber or thread than the one the commit runs on.
|
|
51
|
+
# (Notes — #note — are kept: they say what has to be done, not what was.)
|
|
52
|
+
#
|
|
53
|
+
# @api private
|
|
54
|
+
class InvalidationBatch
|
|
55
|
+
# The ExecutionState slot of the batch still open on this thread (fiber
|
|
56
|
+
# under Rails' fiber isolation).
|
|
57
|
+
THREAD_KEY = :pf_open_invalidation_batch
|
|
58
|
+
|
|
59
|
+
# Guards @writes increments; reads are lock-free.
|
|
60
|
+
WRITES_MUTEX = Mutex.new
|
|
61
|
+
|
|
62
|
+
class << self
|
|
63
|
+
# The batch a record being written on `connection` joins: the one still
|
|
64
|
+
# open on this thread for that connection, else a new one.
|
|
65
|
+
#
|
|
66
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
|
|
67
|
+
# @return [InvalidationBatch]
|
|
68
|
+
def join(connection)
|
|
69
|
+
note_write
|
|
70
|
+
current = ExecutionState[THREAD_KEY]
|
|
71
|
+
return current if current&.open_on?(connection)
|
|
72
|
+
|
|
73
|
+
ExecutionState[THREAD_KEY] = new(connection)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A permission record is being written somewhere in this process: every
|
|
77
|
+
# batch forgets what it remembers before it is reused (see #claim).
|
|
78
|
+
#
|
|
79
|
+
# @return [void]
|
|
80
|
+
def note_write
|
|
81
|
+
WRITES_MUTEX.synchronize { @writes = writes + 1 }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Integer] permission-record writes this process has made
|
|
85
|
+
def writes
|
|
86
|
+
@writes || 0
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Without a connection the batch stands alone: it can be used at once and
|
|
91
|
+
# never joined or claimed (an invalidation outside any commit).
|
|
92
|
+
def initialize(connection = nil)
|
|
93
|
+
@connection = connection
|
|
94
|
+
@claimed_at = nil
|
|
95
|
+
@writes_seen = nil
|
|
96
|
+
@lookups = {}
|
|
97
|
+
@done = Set.new
|
|
98
|
+
@noted = {}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [Boolean] whether a record written on `connection` may still join
|
|
102
|
+
def open_on?(connection)
|
|
103
|
+
!@claimed_at && @connection.equal?(connection)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Called from a record's after_commit, before it uses the batch.
|
|
107
|
+
#
|
|
108
|
+
# @return [InvalidationBatch, nil] the batch when the commit being reported
|
|
109
|
+
# may reuse it; nil when it was claimed from somewhere else
|
|
110
|
+
def claim
|
|
111
|
+
return unless @connection
|
|
112
|
+
|
|
113
|
+
place = [@connection.open_transactions, PermissionForge.ambient_tenant_id]
|
|
114
|
+
unless @claimed_at
|
|
115
|
+
@claimed_at = place
|
|
116
|
+
release_thread
|
|
117
|
+
end
|
|
118
|
+
return unless @claimed_at == place
|
|
119
|
+
|
|
120
|
+
forget_if_written_since
|
|
121
|
+
self
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A lookup shared by the records of the commit.
|
|
125
|
+
#
|
|
126
|
+
# @param key [Object] what is looked up (e.g. [:assignments, permission_set_id])
|
|
127
|
+
# @return [Object] the block's value, from the first call for this key
|
|
128
|
+
def fetch(key)
|
|
129
|
+
return @lookups[key] if @lookups.key?(key)
|
|
130
|
+
|
|
131
|
+
@lookups[key] = yield
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Runs an invalidation unless the commit has already run it. The key is
|
|
135
|
+
# only remembered once the block has returned: an invalidation that raised
|
|
136
|
+
# was not done.
|
|
137
|
+
#
|
|
138
|
+
# @param key [Object] what is invalidated (e.g. [:user, user_id, tenant_id])
|
|
139
|
+
# @return [void]
|
|
140
|
+
def once(key)
|
|
141
|
+
return if @done.include?(key)
|
|
142
|
+
|
|
143
|
+
yield
|
|
144
|
+
@done << key
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Remembers an invalidation the commit has made without #once (many
|
|
148
|
+
# users in one call, Cache::HolderInvalidation) — after it was made.
|
|
149
|
+
#
|
|
150
|
+
# @param key [Object] what was invalidated (e.g. [:user, user_id, tenant_id])
|
|
151
|
+
# @return [void]
|
|
152
|
+
def done!(key)
|
|
153
|
+
@done << key
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @return [Boolean] whether #once (or #done!) has already run for this key
|
|
157
|
+
def done?(key)
|
|
158
|
+
@done.include?(key)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Says that something has to be invalidated when the transaction commits.
|
|
162
|
+
# This never replaces the note a record keeps for itself — a record does
|
|
163
|
+
# its own invalidation in its own after_commit, whatever becomes of the
|
|
164
|
+
# batch. It is for the case that after_commit never runs: of two instances
|
|
165
|
+
# of one row written in a transaction, Rails runs the callbacks of one,
|
|
166
|
+
# and that one can find here what the other noted.
|
|
167
|
+
#
|
|
168
|
+
# @param kind [Symbol] which cache (e.g. :managed_actions)
|
|
169
|
+
# @param value [Object] what of it (e.g. the tenant id)
|
|
170
|
+
# @return [void]
|
|
171
|
+
def note(kind, value)
|
|
172
|
+
(@noted[kind] ||= Set.new) << value
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# @param kind [Symbol]
|
|
176
|
+
# @return [Array] what the records that joined have noted for this cache
|
|
177
|
+
def noted(kind)
|
|
178
|
+
@noted.fetch(kind, []).to_a
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# A record can be marshalled (cached) while it still references the batch
|
|
182
|
+
# it joined. Nothing of a batch is worth keeping, and its connection cannot
|
|
183
|
+
# be dumped: the copy stands alone.
|
|
184
|
+
def marshal_dump = nil
|
|
185
|
+
|
|
186
|
+
def marshal_load(_state)
|
|
187
|
+
initialize
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def forget_if_written_since
|
|
193
|
+
writes = self.class.writes
|
|
194
|
+
return if @writes_seen == writes
|
|
195
|
+
|
|
196
|
+
@writes_seen = writes
|
|
197
|
+
@lookups.clear
|
|
198
|
+
@done.clear
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# So the thread keeps nothing of a claimed batch (it is never joined again).
|
|
202
|
+
def release_thread
|
|
203
|
+
ExecutionState[THREAD_KEY] = nil if ExecutionState[THREAD_KEY].equal?(self)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# Auto-applied masking: mixes MaskedAttributes into the models
|
|
5
|
+
# config.auto_apply_masked_attributes selects and wraps the virtual readers
|
|
6
|
+
# of the models config.mask_virtual_attributes selects. Each setting takes
|
|
7
|
+
# its models from the Resource rows (:auto / true) or from a list. Called
|
|
8
|
+
# by MaskingSync (at boot, after a Resource row names a model, and when
|
|
9
|
+
# another process named one).
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
module MaskedModels
|
|
13
|
+
# Setting values that switch a step off.
|
|
14
|
+
OFF = [false, nil].freeze
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# @return [Boolean] whether either setting is on (MaskingSync does
|
|
18
|
+
# nothing otherwise)
|
|
19
|
+
def enabled?(config = PermissionForge.configuration)
|
|
20
|
+
settings(config).any? { |setting| !OFF.include?(setting) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Whether a setting that is on takes its models from the Resource rows:
|
|
24
|
+
# a row committed in another process can then name a model this process
|
|
25
|
+
# has not masked. False when every setting that is on is a list — the
|
|
26
|
+
# models are fixed by the configuration.
|
|
27
|
+
#
|
|
28
|
+
# @return [Boolean]
|
|
29
|
+
def row_driven?(config = PermissionForge.configuration)
|
|
30
|
+
settings(config).any? { |setting| !OFF.include?(setting) && !setting.is_a?(Array) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Includes the concern on every selected model, then wraps the virtual
|
|
34
|
+
# readers of every selected model that has it (applied here, or
|
|
35
|
+
# included by the model itself). Idempotent. A model that cannot be
|
|
36
|
+
# masked (its table is missing, its database is down) is logged and
|
|
37
|
+
# skipped: the others are still masked.
|
|
38
|
+
#
|
|
39
|
+
# @param only [String, nil] one model, as long as the settings select it
|
|
40
|
+
# @return [Boolean] true when every selected model is masked; false when
|
|
41
|
+
# the Resource rows could not be read (no table yet, no database), a
|
|
42
|
+
# model could not be masked, or a listed name is no model here
|
|
43
|
+
# (MaskingSync then tries again)
|
|
44
|
+
def apply(only: nil)
|
|
45
|
+
config = PermissionForge.configuration
|
|
46
|
+
return true unless enabled?(config)
|
|
47
|
+
return false unless PermissionForge::Resource.table_exists?
|
|
48
|
+
|
|
49
|
+
apply_selected(config, only).all? & unknown_listed_names(config, only).empty?
|
|
50
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
|
|
51
|
+
# Tables may not exist yet during early boot or pre-migration.
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Boolean, nil] false when the model is left unmasked
|
|
56
|
+
def apply_concern(model_name)
|
|
57
|
+
klass = model_class(model_name)
|
|
58
|
+
return if klass.nil? || klass.abstract_class?
|
|
59
|
+
return if masked_class?(klass)
|
|
60
|
+
|
|
61
|
+
# Including the concern runs its `included` block after the module is
|
|
62
|
+
# in the ancestors: a block that failed there (the table could not be
|
|
63
|
+
# read) leaves a class every later include skips. Never start one
|
|
64
|
+
# that cannot finish.
|
|
65
|
+
why = unmaskable_reason(klass)
|
|
66
|
+
if why
|
|
67
|
+
warn_unmaskable(klass, why)
|
|
68
|
+
return false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
klass.include(PermissionForge::Concerns::MaskedAttributes)
|
|
72
|
+
unmaskable_warned.delete(klass.name)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Wraps the virtual readers of a model that has MaskedAttributes. The
|
|
76
|
+
# wrappers re-check config.mask_virtual_attributes on every call. A
|
|
77
|
+
# model whose extra_fields_method raises keeps its declared readers
|
|
78
|
+
# unwrapped, and pf_mask_virtual_readers logs a SECURITY warning.
|
|
79
|
+
#
|
|
80
|
+
# @return [Array<String>, nil] the names wrapped
|
|
81
|
+
def mask_virtual_readers(model_name)
|
|
82
|
+
klass = model_class(model_name)
|
|
83
|
+
return unless klass.respond_to?(:pf_mask_virtual_readers)
|
|
84
|
+
return unless PermissionForge.configuration.mask_virtual_attributes_for?(klass.name)
|
|
85
|
+
|
|
86
|
+
klass.pf_mask_virtual_readers
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Whether the model `model_name` names has the concern in this process
|
|
90
|
+
# (applied, or included by the model or a class it inherits from);
|
|
91
|
+
# false for a name that resolves to no model here.
|
|
92
|
+
#
|
|
93
|
+
# @param model_name [String]
|
|
94
|
+
# @return [Boolean]
|
|
95
|
+
def masked?(model_name)
|
|
96
|
+
klass = model_class(model_name)
|
|
97
|
+
klass ? masked_class?(klass) : false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Warns again about a model that still cannot be masked (MaskingSync.reset!).
|
|
101
|
+
def reset_warnings!
|
|
102
|
+
unmaskable_warned.clear
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def settings(config)
|
|
108
|
+
[config.auto_apply_masked_attributes, config.mask_virtual_attributes]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The concern on every selected model, then the virtual readers (the
|
|
112
|
+
# names of both read first).
|
|
113
|
+
#
|
|
114
|
+
# @return [Array<Boolean>] whether each step left its model masked
|
|
115
|
+
def apply_selected(config, only)
|
|
116
|
+
concern = model_names(config.auto_apply_masked_attributes, only: only)
|
|
117
|
+
virtual = model_names(config.mask_virtual_attributes, only: only)
|
|
118
|
+
concern.map { |name| guarded(name) { apply_concern(name) } } +
|
|
119
|
+
virtual.map { |name| guarded(name) { mask_virtual_readers(name) } }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# One model's failure is logged and the loop goes on; a
|
|
123
|
+
# ConfigurationError (the setup is wrong, not the model) still stops the
|
|
124
|
+
# boot, as everywhere in BootChecks.
|
|
125
|
+
def guarded(name)
|
|
126
|
+
yield != false
|
|
127
|
+
rescue PermissionForge::ConfigurationError
|
|
128
|
+
raise
|
|
129
|
+
rescue StandardError => e
|
|
130
|
+
warn_unmaskable(model_class(name) || name, "#{e.class}: #{e.message}")
|
|
131
|
+
false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The models a setting selects: none when it is off, its list, or the
|
|
135
|
+
# names of the Resource rows — narrowed to `only`.
|
|
136
|
+
def model_names(setting, only: nil)
|
|
137
|
+
return [] if OFF.include?(setting)
|
|
138
|
+
return listed_names(setting, only) if setting.is_a?(Array)
|
|
139
|
+
|
|
140
|
+
only ? Array(only).map(&:to_s) : resource_names
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def listed_names(list, only)
|
|
144
|
+
list = list.map(&:to_s)
|
|
145
|
+
only ? Array(only).map(&:to_s) & list : list
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The names a list setting gives that resolve to no model here (a typo,
|
|
149
|
+
# a class not loaded yet), each logged once. A name a Resource row
|
|
150
|
+
# gives (:auto) may name a controller-only resource: not reported.
|
|
151
|
+
#
|
|
152
|
+
# @return [Array<String>]
|
|
153
|
+
def unknown_listed_names(config, only)
|
|
154
|
+
{ 'auto_apply_masked_attributes' => config.auto_apply_masked_attributes,
|
|
155
|
+
'mask_virtual_attributes' => config.mask_virtual_attributes }.flat_map do |setting, value|
|
|
156
|
+
next [] unless value.is_a?(Array)
|
|
157
|
+
|
|
158
|
+
listed_names(value, only).reject { |name| model_class(name) }.each { |name| warn_unknown(setting, name) }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def warn_unknown(setting, name)
|
|
163
|
+
return if unmaskable_warned.put_if_absent("#{setting}=#{name}", true)
|
|
164
|
+
|
|
165
|
+
Rails.logger&.warn("[PermissionForge] SECURITY warning: '#{name}' is listed in config.#{setting} but is no " \
|
|
166
|
+
'model in this process: nothing is masked for it. It is tried again at the next request or job.')
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Every tenant's rows: a model is masked for the whole process.
|
|
170
|
+
def resource_names
|
|
171
|
+
if PermissionForge.configuration.tenant_enabled? && defined?(ActsAsTenant)
|
|
172
|
+
ActsAsTenant.without_tenant { PermissionForge::Resource.distinct.pluck(:name) }
|
|
173
|
+
else
|
|
174
|
+
PermissionForge::Resource.distinct.pluck(:name)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The class, or a class it inherits from, has the concern applied. A
|
|
179
|
+
# subclass is masked through its parent (the concern is never included
|
|
180
|
+
# into a subclass) and may have a Resource row of its own (STI).
|
|
181
|
+
def masked_class?(klass)
|
|
182
|
+
PermissionForge::Concerns::MaskedAttributes.applied_in_chain?(klass)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Only reached when no class in the chain has the concern applied: the
|
|
186
|
+
# module in the ancestors then means an include that failed halfway.
|
|
187
|
+
#
|
|
188
|
+
# @return [String, nil] why the concern cannot be included now
|
|
189
|
+
def unmaskable_reason(klass)
|
|
190
|
+
return 'its table cannot be read' unless readable_table?(klass)
|
|
191
|
+
|
|
192
|
+
'an earlier attempt failed halfway; restart the process' if klass.include?(PermissionForge::Concerns::MaskedAttributes)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The table exists and can be read. A table found missing is looked up
|
|
196
|
+
# again next time (the cached answer is dropped): a migration may run.
|
|
197
|
+
def readable_table?(klass)
|
|
198
|
+
return true if klass.table_exists?
|
|
199
|
+
|
|
200
|
+
klass.reset_column_information
|
|
201
|
+
false
|
|
202
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
|
|
203
|
+
false
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Logs once per model and process (MaskingSync retries every request).
|
|
207
|
+
def warn_unmaskable(klass, why)
|
|
208
|
+
name = klass.to_s
|
|
209
|
+
return if unmaskable_warned.put_if_absent(name, true)
|
|
210
|
+
|
|
211
|
+
Rails.logger&.warn("[PermissionForge] SECURITY warning: #{name} has a Resource row but #{why}: its columns " \
|
|
212
|
+
'are NOT masked in this process. It is tried again at the next request or job.')
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# The models (and unknown listed names) whose warning this process has logged.
|
|
216
|
+
def unmaskable_warned
|
|
217
|
+
@unmaskable_warned ||= Concurrent::Map.new
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def model_class(model_name)
|
|
221
|
+
klass = model_name.to_s.safe_constantize
|
|
222
|
+
klass if klass.is_a?(Class) && klass < ActiveRecord::Base
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module PermissionForge
|
|
6
|
+
# Carries auto-applied masking (MaskedModels) to every process. The
|
|
7
|
+
# concern is mixed into a model class in the process that loads it: at
|
|
8
|
+
# boot, and in the process that commits the first Resource row for a new
|
|
9
|
+
# model. Every other process would serve that model unmasked until it
|
|
10
|
+
# restarts — unless it hears of it (docs/masked-attributes.md).
|
|
11
|
+
#
|
|
12
|
+
# When a setting takes its models from the Resource rows
|
|
13
|
+
# (MaskedModels.row_driven?):
|
|
14
|
+
# - Writer: the process that commits a row naming a model for the first
|
|
15
|
+
# time (no other row in any tenant has the name, or this process had
|
|
16
|
+
# not masked it: two rows committed together) applies the concern and
|
|
17
|
+
# writes a new random token under KEY in the shared store, no expiry.
|
|
18
|
+
# - Reader: each executor run (request or job) reads KEY once (check!);
|
|
19
|
+
# a token it has not synced with makes it apply to every named model.
|
|
20
|
+
# - A process that reads no token (evicted, flushed) writes one and
|
|
21
|
+
# syncs; every other process then syncs once too. Losing the token
|
|
22
|
+
# costs one `pluck` per process, never a model left unmasked.
|
|
23
|
+
# - A store that does not answer is left alone for RETRY_PAUSE seconds,
|
|
24
|
+
# then one request or job per process probes it; logged once per
|
|
25
|
+
# outage. An announcement the store did not take is owed and written
|
|
26
|
+
# once it answers.
|
|
27
|
+
# When every setting that is on is a list, the models are fixed by the
|
|
28
|
+
# configuration: no token is read or written, and a process that has
|
|
29
|
+
# synced does nothing per request. When both are off, check! returns
|
|
30
|
+
# before reading anything (the hook stays: a setting may be switched on
|
|
31
|
+
# after the boot step).
|
|
32
|
+
#
|
|
33
|
+
# Either way a process that has not synced (its boot could not reach the
|
|
34
|
+
# database, a model could not be masked) applies at its next executor
|
|
35
|
+
# run, and a development code reload applies again (`to_prepare`).
|
|
36
|
+
# The store must be shared (not :memory or :null).
|
|
37
|
+
#
|
|
38
|
+
# @api private
|
|
39
|
+
module MaskingSync
|
|
40
|
+
KEY = 'masked_models_version'
|
|
41
|
+
|
|
42
|
+
# The token of a process that has not synced yet (no token can equal it).
|
|
43
|
+
UNSYNCED = Object.new.freeze
|
|
44
|
+
|
|
45
|
+
# Seconds the store is left alone after it did not answer.
|
|
46
|
+
RETRY_PAUSE = 30
|
|
47
|
+
|
|
48
|
+
# One sync (or apply) at a time; one probe of a failing store at a time.
|
|
49
|
+
SYNC_LOCK = Mutex.new
|
|
50
|
+
PROBE_LOCK = Mutex.new
|
|
51
|
+
|
|
52
|
+
# The token this process synced with (UNSYNCED, or nil for "synced
|
|
53
|
+
# without one"), whether it owes the store an announcement, and until
|
|
54
|
+
# when (monotonic seconds) which store is left alone. A failure while
|
|
55
|
+
# @failing_until is set belongs to the outage already logged.
|
|
56
|
+
@token = UNSYNCED
|
|
57
|
+
@announcement_owed = false
|
|
58
|
+
@failing_until = nil
|
|
59
|
+
@failing_store = nil
|
|
60
|
+
|
|
61
|
+
class << self
|
|
62
|
+
# Executor `to_run`. Row-driven: one store read; applies when another
|
|
63
|
+
# process named a model since this one last synced. The store is not
|
|
64
|
+
# read while it is failing (RETRY_PAUSE), nor at all for lists: the
|
|
65
|
+
# token is then unknown (nil), and only a process that has not synced
|
|
66
|
+
# applies (from the database). Never raises.
|
|
67
|
+
def check!
|
|
68
|
+
return unless MaskedModels.enabled?
|
|
69
|
+
|
|
70
|
+
token = MaskedModels.row_driven? ? answered_token : nil
|
|
71
|
+
sync(token) if changed?(token)
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
Rails.logger&.error("[PermissionForge] auto-applied masking could not be synced — #{e.class}: #{e.message}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# After a Resource row naming `name` has been created or renamed and
|
|
77
|
+
# committed: tells the other processes (a new token) when the name is
|
|
78
|
+
# new to them — no other row has it — or when this process had not
|
|
79
|
+
# masked the model before: rows committed together each see the
|
|
80
|
+
# others, and the first to get here announces. Otherwise, and when
|
|
81
|
+
# the settings are lists, applies the concern here only.
|
|
82
|
+
#
|
|
83
|
+
# @param name [String] the model the row names
|
|
84
|
+
# @yieldreturn [Boolean] whether another Resource row has the name
|
|
85
|
+
# (asked only when it decides something)
|
|
86
|
+
def named!(name)
|
|
87
|
+
return unless MaskedModels.enabled?
|
|
88
|
+
return apply!(name) unless MaskedModels.row_driven?
|
|
89
|
+
|
|
90
|
+
masked_here?(name) && yield ? apply!(name) : announce!(name)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The boot step: applies to every selected model and syncs with the
|
|
94
|
+
# current token (read first, so a name committed meanwhile is seen
|
|
95
|
+
# again by the next check). When a model could not be masked (or the
|
|
96
|
+
# rows could not be read) the process stays unsynced and the next
|
|
97
|
+
# request or job tries again.
|
|
98
|
+
#
|
|
99
|
+
# @return [Boolean] whether every selected model is masked
|
|
100
|
+
def boot!
|
|
101
|
+
done = sync(MaskedModels.row_driven? ? current_token : nil, always: true)
|
|
102
|
+
unless done
|
|
103
|
+
Rails.logger&.warn('[PermissionForge] Auto-applied masking is not complete at boot (see the warnings ' \
|
|
104
|
+
'above, or the Resource table cannot be read): it is tried again at each request or job.')
|
|
105
|
+
end
|
|
106
|
+
done
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# `to_prepare`: after a code reload in development the model classes
|
|
110
|
+
# are new and unmasked. At boot it runs before the boot step: nothing
|
|
111
|
+
# to re-apply yet.
|
|
112
|
+
def reloaded!
|
|
113
|
+
return unless Rails.application&.initialized?
|
|
114
|
+
|
|
115
|
+
reset!
|
|
116
|
+
check!
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Forgets the token (the next check applies whatever the token says)
|
|
120
|
+
# and that the store failed. Also what a boot that could not reach the
|
|
121
|
+
# database leaves (BootChecks).
|
|
122
|
+
def reset!
|
|
123
|
+
SYNC_LOCK.synchronize { @token = UNSYNCED }
|
|
124
|
+
@announcement_owed = false
|
|
125
|
+
@failing_until = nil
|
|
126
|
+
MaskedModels.reset_warnings!
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @return [Boolean] whether this process has synced once
|
|
130
|
+
def synced?
|
|
131
|
+
!@token.equal?(UNSYNCED)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @return [Boolean] whether the store recently did not answer (the
|
|
135
|
+
# store in use: one that replaced it has not failed yet)
|
|
136
|
+
def store_failing?
|
|
137
|
+
until_time = @failing_until
|
|
138
|
+
!until_time.nil? && now < until_time && PermissionForge.cache.equal?(@failing_store)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
# Applies the concern to one model in this process.
|
|
144
|
+
def apply!(name)
|
|
145
|
+
SYNC_LOCK.synchronize { MaskedModels.apply(only: name) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Applies the concern here, then tells the other processes. The token
|
|
149
|
+
# it writes is not taken as this process's own: another process may
|
|
150
|
+
# have written one since this one last checked, so the next check
|
|
151
|
+
# syncs once more.
|
|
152
|
+
#
|
|
153
|
+
# A token the store does not take (it is down, or raises) is owed:
|
|
154
|
+
# this process writes a new one at its first request or job after the
|
|
155
|
+
# store answers again (after RETRY_PAUSE). Never raises for the store.
|
|
156
|
+
def announce!(name)
|
|
157
|
+
apply!(name)
|
|
158
|
+
return if publish_token
|
|
159
|
+
|
|
160
|
+
Rails.logger&.warn('[PermissionForge] The cache store did not take the masking token: other processes ' \
|
|
161
|
+
"mask #{name} once the store answers again and this process has written it.")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The token to check against — nil while the store is failing. A
|
|
165
|
+
# token this process owes the store (an announcement it did not take)
|
|
166
|
+
# is written first, once the store answers.
|
|
167
|
+
#
|
|
168
|
+
# @return [String, nil]
|
|
169
|
+
def answered_token
|
|
170
|
+
return if store_failing?
|
|
171
|
+
return owed_or_current_token unless @failing_until
|
|
172
|
+
|
|
173
|
+
probe_store
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The pause after a failure is over: one thread asks the store again;
|
|
177
|
+
# the others keep the pause (nil) instead of each waiting for a store
|
|
178
|
+
# that may still be gone.
|
|
179
|
+
def probe_store
|
|
180
|
+
return unless PROBE_LOCK.try_lock
|
|
181
|
+
|
|
182
|
+
begin
|
|
183
|
+
owed_or_current_token
|
|
184
|
+
ensure
|
|
185
|
+
PROBE_LOCK.unlock
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def owed_or_current_token
|
|
190
|
+
token = current_token
|
|
191
|
+
token && @announcement_owed ? (publish_token || token) : token
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Writes a new token (no expiry). Remembers it as owed when the store
|
|
195
|
+
# does not take it, and starts the back-off.
|
|
196
|
+
#
|
|
197
|
+
# @return [String, nil] the token written
|
|
198
|
+
def publish_token
|
|
199
|
+
token = SecureRandom.uuid
|
|
200
|
+
written = PermissionForge.cache.write_permanent(KEY, token)
|
|
201
|
+
store_failed! unless written
|
|
202
|
+
@announcement_owed = !written
|
|
203
|
+
token if written
|
|
204
|
+
rescue StandardError => e
|
|
205
|
+
@announcement_owed = true
|
|
206
|
+
store_failed!(e)
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The token in the store — or, when there is none, a new one written
|
|
211
|
+
# (with no expiry) and read back: whatever token is there then, also
|
|
212
|
+
# one another process wrote meanwhile. nil when the store did not
|
|
213
|
+
# answer: it is left alone for RETRY_PAUSE. A read that raised is not
|
|
214
|
+
# followed by a write.
|
|
215
|
+
#
|
|
216
|
+
# @return [String, nil]
|
|
217
|
+
def current_token
|
|
218
|
+
cache = PermissionForge.cache
|
|
219
|
+
token = cache.read_fresh(KEY) || (cache.read_fresh(KEY) if cache.write_permanent(KEY, SecureRandom.uuid))
|
|
220
|
+
token ? store_answered! : store_failed!
|
|
221
|
+
token
|
|
222
|
+
rescue StandardError => e
|
|
223
|
+
store_failed!(e)
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def store_answered!
|
|
228
|
+
@failing_until = nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Logged once per outage: the first failure after the store answered.
|
|
232
|
+
def store_failed!(error = nil)
|
|
233
|
+
first = @failing_until.nil?
|
|
234
|
+
@failing_until = now + RETRY_PAUSE
|
|
235
|
+
@failing_store = PermissionForge.cache
|
|
236
|
+
return unless first
|
|
237
|
+
|
|
238
|
+
why = error ? " (#{error.class}: #{error.message})" : ''
|
|
239
|
+
Rails.logger&.warn("[PermissionForge] The cache store did not give or keep the masking token#{why}: auto-applied " \
|
|
240
|
+
"masking is not synced with other processes for the next #{RETRY_PAUSE} seconds.")
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def now
|
|
244
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Whether `token` is one this process has not synced with. An unknown
|
|
248
|
+
# one (nil: the store did not answer) only for a process that never
|
|
249
|
+
# synced.
|
|
250
|
+
def changed?(token)
|
|
251
|
+
token.nil? ? !synced? : token != @token
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Whether this process has the concern on the model (false for a name
|
|
255
|
+
# that resolves to no model here: not known to be masked).
|
|
256
|
+
def masked_here?(name)
|
|
257
|
+
MaskedModels.masked?(name)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def sync(token, always: false)
|
|
261
|
+
SYNC_LOCK.synchronize do
|
|
262
|
+
return true if !always && synced? && token == @token
|
|
263
|
+
|
|
264
|
+
applied = MaskedModels.apply
|
|
265
|
+
@token = token if applied
|
|
266
|
+
applied
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|