permission_forge 0.14.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2372 -0
- data/README.md +76 -25
- data/app/controllers/concerns/permission_forge/api/crud_actions.rb +3 -2
- data/app/controllers/concerns/permission_forge/api/input_shapes.rb +200 -0
- data/app/controllers/concerns/permission_forge/api/pagination.rb +12 -5
- data/app/controllers/concerns/permission_forge/api/response_renderer.rb +37 -1
- data/app/controllers/concerns/permission_forge/api_authentication.rb +13 -2
- data/app/controllers/concerns/permission_forge/cache_confirmation.rb +42 -11
- data/app/controllers/concerns/permission_forge/change_review.rb +42 -0
- data/app/controllers/concerns/permission_forge/copy_refusal.rb +39 -0
- data/app/controllers/concerns/permission_forge/param_shapes.rb +207 -7
- data/app/controllers/concerns/permission_forge/save_notice.rb +69 -0
- data/app/controllers/permission_forge/api/audit_events_controller.rb +1 -1
- data/app/controllers/permission_forge/api/base_controller.rb +163 -12
- data/app/controllers/permission_forge/api/dashboard_controller.rb +29 -4
- data/app/controllers/permission_forge/api/permission_set_group_users_controller.rb +5 -2
- data/app/controllers/permission_forge/api/permission_set_groups_controller.rb +5 -4
- data/app/controllers/permission_forge/api/permission_set_rules_controller.rb +7 -5
- data/app/controllers/permission_forge/api/permission_set_users_controller.rb +80 -19
- data/app/controllers/permission_forge/api/permission_sets_controller.rb +12 -6
- data/app/controllers/permission_forge/api/resource_actions_controller.rb +1 -1
- data/app/controllers/permission_forge/api/resource_fields_controller.rb +3 -4
- data/app/controllers/permission_forge/api/resources_controller.rb +26 -12
- data/app/controllers/permission_forge/api/unmatched_routes_controller.rb +20 -0
- data/app/controllers/permission_forge/application_controller.rb +39 -5
- data/app/controllers/permission_forge/assignments_controller.rb +36 -34
- data/app/controllers/permission_forge/bulk_assignments_controller.rb +8 -4
- data/app/controllers/permission_forge/field_lookups_controller.rb +1 -1
- data/app/controllers/permission_forge/maintenance_controller.rb +6 -2
- data/app/controllers/permission_forge/people_controller.rb +14 -14
- data/app/controllers/permission_forge/permission_set_editor.rb +20 -6
- data/app/controllers/permission_forge/permission_set_group_users_controller.rb +3 -1
- data/app/controllers/permission_forge/permission_set_groups_controller.rb +23 -9
- data/app/controllers/permission_forge/permission_set_rules_controller.rb +20 -10
- data/app/controllers/permission_forge/permission_sets_controller.rb +23 -24
- data/app/controllers/permission_forge/resource_actions_controller.rb +11 -6
- data/app/controllers/permission_forge/resource_fields_controller.rb +37 -19
- data/app/controllers/permission_forge/resources_controller.rb +12 -11
- data/app/frontend/permission_forge/pf_admin.css +99 -17
- data/app/frontend/permission_forge/pf_admin.js +36 -0
- data/app/helpers/permission_forge/application_helper.rb +22 -72
- data/app/helpers/permission_forge/audit_attribution_helper.rb +77 -0
- data/app/helpers/permission_forge/audit_helper.rb +57 -119
- data/app/helpers/permission_forge/audit_records_helper.rb +103 -0
- data/app/helpers/permission_forge/audit_sentences_helper.rb +56 -2
- data/app/helpers/permission_forge/badges_helper.rb +74 -17
- data/app/helpers/permission_forge/changes_helper.rb +24 -6
- data/app/helpers/permission_forge/conditions_helper.rb +119 -0
- data/app/helpers/permission_forge/labels_helper.rb +134 -0
- data/app/helpers/permission_forge/people_helper.rb +11 -19
- data/app/helpers/permission_forge/rule_problems_helper.rb +73 -0
- data/app/helpers/permission_forge/sentences_helper.rb +119 -98
- data/app/helpers/permission_forge/settings_helper.rb +2 -2
- data/app/helpers/permission_forge/ui_helper.rb +65 -4
- data/app/models/permission_forge/audit_event.rb +5 -1
- data/app/models/permission_forge/permission_set.rb +208 -18
- data/app/models/permission_forge/permission_set_group.rb +10 -4
- data/app/models/permission_forge/permission_set_group_user.rb +3 -1
- data/app/models/permission_forge/permission_set_rule.rb +254 -17
- data/app/models/permission_forge/permission_set_user.rb +38 -3
- data/app/models/permission_forge/resource.rb +18 -8
- data/app/models/permission_forge/resource_action.rb +15 -6
- data/app/models/permission_forge/resource_field.rb +97 -11
- data/app/views/permission_forge/assignments/confirm_destroy.html.erb +4 -4
- data/app/views/permission_forge/assignments/new.html.erb +10 -6
- data/app/views/permission_forge/audit_events/index.html.erb +1 -1
- data/app/views/permission_forge/audit_events/show.html.erb +16 -9
- data/app/views/permission_forge/bulk_assignments/confirm_revoke.html.erb +3 -3
- data/app/views/permission_forge/bulk_assignments/new.html.erb +3 -3
- data/app/views/permission_forge/field_lookups/show.html.erb +2 -2
- data/app/views/permission_forge/home/_broken_rules_card.html.erb +35 -0
- data/app/views/permission_forge/home/_conditions_card.html.erb +28 -0
- data/app/views/permission_forge/home/_ending_card.html.erb +11 -5
- data/app/views/permission_forge/home/_expired_card.html.erb +1 -1
- data/app/views/permission_forge/home/_mask_strategies_card.html.erb +27 -0
- data/app/views/permission_forge/home/_privacy_card.html.erb +21 -7
- data/app/views/permission_forge/home/_rules_card.html.erb +1 -1
- data/app/views/permission_forge/home/_secret_rules_card.html.erb +33 -0
- data/app/views/permission_forge/home/_stale_card.html.erb +7 -5
- data/app/views/permission_forge/home/_tidy_card.html.erb +1 -1
- data/app/views/permission_forge/home/index.html.erb +5 -6
- data/app/views/permission_forge/people/_access_tab.html.erb +26 -19
- data/app/views/permission_forge/people/_audit_tab.html.erb +8 -8
- data/app/views/permission_forge/people/_effective_field_source.html.erb +13 -7
- data/app/views/permission_forge/people/_effective_tab.html.erb +16 -15
- data/app/views/permission_forge/people/_effective_why.html.erb +20 -16
- data/app/views/permission_forge/people/_why_item.html.erb +1 -1
- data/app/views/permission_forge/people/confirm_remove_all.html.erb +3 -3
- data/app/views/permission_forge/people/erasure_preview.html.erb +1 -1
- data/app/views/permission_forge/permission_set_groups/confirm_destroy.html.erb +3 -3
- data/app/views/permission_forge/permission_set_groups/index.html.erb +3 -3
- data/app/views/permission_forge/permission_set_groups/show.html.erb +28 -19
- data/app/views/permission_forge/permission_set_rules/_builder.html.erb +35 -15
- data/app/views/permission_forge/permission_set_rules/_preview.html.erb +17 -0
- data/app/views/permission_forge/permission_set_rules/confirm_destroy.html.erb +3 -3
- data/app/views/permission_forge/permission_set_rules/preview.html.erb +12 -0
- data/app/views/permission_forge/permission_sets/_assignments_tab.html.erb +37 -22
- data/app/views/permission_forge/permission_sets/_audit_tab.html.erb +4 -4
- data/app/views/permission_forge/permission_sets/_condition_problems.html.erb +15 -0
- data/app/views/permission_forge/permission_sets/_conditions_tab.html.erb +14 -8
- data/app/views/permission_forge/permission_sets/_header.html.erb +6 -5
- data/app/views/permission_forge/permission_sets/_records_summary.html.erb +2 -2
- data/app/views/permission_forge/permission_sets/_resources_tab.html.erb +4 -4
- data/app/views/permission_forge/permission_sets/confirm_destroy.html.erb +4 -4
- data/app/views/permission_forge/permission_sets/confirm_switch_mode.html.erb +3 -3
- data/app/views/permission_forge/permission_sets/index.html.erb +6 -6
- data/app/views/permission_forge/permission_sets/new.html.erb +2 -2
- data/app/views/permission_forge/permission_sets/review.html.erb +13 -3
- data/app/views/permission_forge/permission_sets/show.html.erb +7 -5
- data/app/views/permission_forge/resource_actions/_across_sets.html.erb +7 -10
- data/app/views/permission_forge/resource_actions/_effect.html.erb +5 -5
- data/app/views/permission_forge/resource_actions/_form.html.erb +1 -1
- data/app/views/permission_forge/resource_actions/_managed_note.html.erb +5 -3
- data/app/views/permission_forge/resource_actions/confirm_destroy.html.erb +1 -1
- data/app/views/permission_forge/resource_actions/new.html.erb +1 -1
- data/app/views/permission_forge/resource_actions/show.html.erb +2 -2
- data/app/views/permission_forge/resource_fields/_explain.html.erb +19 -15
- data/app/views/permission_forge/resource_fields/show.html.erb +21 -12
- data/app/views/permission_forge/resources/_actions_tab.html.erb +4 -4
- data/app/views/permission_forge/resources/_fields_tab.html.erb +17 -9
- data/app/views/permission_forge/resources/_rules_tab.html.erb +9 -6
- data/app/views/permission_forge/resources/_sync.html.erb +8 -5
- data/app/views/permission_forge/resources/confirm_destroy.html.erb +3 -3
- data/app/views/permission_forge/resources/new.html.erb +3 -2
- data/app/views/permission_forge/resources/show.html.erb +6 -6
- data/app/views/permission_forge/settings/_row.html.erb +2 -2
- data/app/views/permission_forge/settings/show.html.erb +10 -1
- data/app/views/permission_forge/shared/_across_sets.html.erb +8 -9
- data/app/views/permission_forge/shared/_audit_events.html.erb +2 -2
- data/app/views/permission_forge/shared/_audit_footer.html.erb +13 -2
- data/app/views/permission_forge/shared/_crud_cell.html.erb +6 -6
- data/app/views/permission_forge/shared/_mode_banners.html.erb +1 -1
- data/app/views/permission_forge/shared/_savebar.html.erb +4 -2
- data/config/locales/permission_forge.en.yml +480 -69
- data/config/locales/permission_forge.mk.yml +499 -79
- data/config/locales/permission_forge.sq.yml +509 -80
- data/config/routes.rb +38 -23
- data/lib/generators/permission_forge/install/install_generator.rb +21 -0
- data/lib/generators/permission_forge/install/templates/initializer.rb.tt +35 -34
- data/lib/generators/permission_forge/install/templates/migration.rb.tt +5 -5
- data/lib/permission_forge/activation_conditions.rb +200 -0
- data/lib/permission_forge/admin/action_label.rb +33 -0
- data/lib/permission_forge/admin/action_rows.rb +3 -1
- data/lib/permission_forge/admin/actor_label.rb +52 -0
- data/lib/permission_forge/admin/attention_items.rb +152 -31
- data/lib/permission_forge/admin/audit_log.rb +4 -7
- data/lib/permission_forge/admin/audit_trail.rb +33 -17
- data/lib/permission_forge/admin/bulk_people_matcher.rb +32 -14
- data/lib/permission_forge/admin/chain_entries.rb +42 -0
- data/lib/permission_forge/admin/change_set.rb +12 -1
- data/lib/permission_forge/admin/change_set_parts/base.rb +5 -4
- data/lib/permission_forge/admin/change_set_parts/context.rb +3 -1
- data/lib/permission_forge/admin/change_set_parts/field_edit.rb +4 -1
- data/lib/permission_forge/admin/change_set_parts/field_levels.rb +1 -1
- data/lib/permission_forge/admin/change_set_parts/new_actions.rb +1 -1
- data/lib/permission_forge/admin/change_set_parts/new_fields.rb +38 -11
- data/lib/permission_forge/admin/change_set_parts/resource_edit.rb +17 -1
- data/lib/permission_forge/admin/change_set_parts/resource_flags.rb +1 -1
- data/lib/permission_forge/admin/change_set_parts/set_attrs.rb +4 -0
- data/lib/permission_forge/admin/change_set_parts/values.rb +10 -4
- data/lib/permission_forge/admin/column_fields.rb +22 -21
- data/lib/permission_forge/admin/conditions_form.rb +47 -15
- data/lib/permission_forge/admin/controller_catalog.rb +42 -14
- data/lib/permission_forge/admin/editable_lock.rb +83 -3
- data/lib/permission_forge/admin/effective_access.rb +147 -48
- data/lib/permission_forge/admin/effective_fields.rb +75 -67
- data/lib/permission_forge/admin/error_sentence.rb +47 -0
- data/lib/permission_forge/admin/expiry_presets.rb +17 -0
- data/lib/permission_forge/admin/exports/holdings.rb +6 -3
- data/lib/permission_forge/admin/field_across_sets.rb +7 -4
- data/lib/permission_forge/admin/field_chain.rb +109 -0
- data/lib/permission_forge/admin/field_detail.rb +24 -11
- data/lib/permission_forge/admin/field_label.rb +109 -0
- data/lib/permission_forge/admin/field_level.rb +4 -68
- data/lib/permission_forge/admin/field_provenance.rb +132 -0
- data/lib/permission_forge/admin/field_rows.rb +26 -11
- data/lib/permission_forge/admin/last_changes.rb +14 -0
- data/lib/permission_forge/admin/list_sentence.rb +32 -0
- data/lib/permission_forge/admin/mask_strategy_choices.rb +57 -0
- data/lib/permission_forge/admin/model_catalog.rb +83 -39
- data/lib/permission_forge/admin/model_label.rb +66 -0
- data/lib/permission_forge/admin/people_candidates.rb +21 -9
- data/lib/permission_forge/admin/people_directory.rb +23 -5
- data/lib/permission_forge/admin/permission_set_factory.rb +6 -15
- data/lib/permission_forge/admin/person_label.rb +105 -16
- data/lib/permission_forge/admin/prefill.rb +92 -0
- data/lib/permission_forge/admin/record_name.rb +45 -0
- data/lib/permission_forge/admin/rule_builder.rb +77 -3
- data/lib/permission_forge/admin/rule_form.rb +44 -10
- data/lib/permission_forge/admin/rule_form_input.rb +27 -11
- data/lib/permission_forge/admin/rule_notes.rb +43 -0
- data/lib/permission_forge/admin/rule_preview.rb +96 -0
- data/lib/permission_forge/admin/sensitive_columns.rb +10 -62
- data/lib/permission_forge/admin/settings_catalog.rb +64 -12
- data/lib/permission_forge/admin/state_label.rb +97 -0
- data/lib/permission_forge/admin_method_override.rb +44 -0
- data/lib/permission_forge/api/schema.rb +322 -0
- data/lib/permission_forge/api/serializers/base_serializer.rb +87 -1
- data/lib/permission_forge/api/serializers/permission_set_group_user_serializer.rb +1 -1
- data/lib/permission_forge/api/serializers/permission_set_rule_serializer.rb +1 -1
- data/lib/permission_forge/api/serializers/permission_set_serializer.rb +5 -2
- data/lib/permission_forge/api/serializers/permission_set_user_serializer.rb +1 -1
- data/lib/permission_forge/api/serializers/resource_action_serializer.rb +1 -1
- data/lib/permission_forge/api/serializers/resource_field_serializer.rb +1 -1
- data/lib/permission_forge/api/serializers/resource_serializer.rb +1 -1
- data/lib/permission_forge/api_authorizations.rb +59 -0
- data/lib/permission_forge/api_only_admin.rb +81 -0
- data/lib/permission_forge/audit/actor_codes.rb +32 -0
- data/lib/permission_forge/audit/actor_resolver.rb +1 -2
- data/lib/permission_forge/audit/boot_warnings.rb +29 -9
- data/lib/permission_forge/audit/event_catalog.rb +16 -6
- data/lib/permission_forge/audit/event_query.rb +3 -3
- data/lib/permission_forge/audit/reason_codes.rb +25 -0
- data/lib/permission_forge/audit/runtime.rb +9 -2
- data/lib/permission_forge/audit/settings_observer.rb +2 -2
- data/lib/permission_forge/audit/unmasked_without_user.rb +169 -0
- data/lib/permission_forge/audit.rb +24 -10
- data/lib/permission_forge/boot_checks.rb +180 -63
- data/lib/permission_forge/cache/base.rb +10 -0
- data/lib/permission_forge/cache/null_cache.rb +5 -0
- data/lib/permission_forge/cache/outcome.rb +119 -0
- data/lib/permission_forge/cache/rails_cache.rb +6 -0
- data/lib/permission_forge/cache/report.rb +150 -0
- data/lib/permission_forge/cache/user_keys.rb +8 -4
- data/lib/permission_forge/cache_warnings.rb +140 -0
- data/lib/permission_forge/concerns/auto_permission_scoping.rb +19 -2
- data/lib/permission_forge/concerns/has_tenant_scoping.rb +1 -1
- data/lib/permission_forge/concerns/has_uuid.rb +1 -1
- data/lib/permission_forge/concerns/keeps_copied_values.rb +48 -0
- data/lib/permission_forge/concerns/masked_attributes/alias_access.rb +12 -2
- data/lib/permission_forge/concerns/masked_attributes/virtual_readers.rb +6 -4
- data/lib/permission_forge/concerns/masked_attributes.rb +150 -34
- data/lib/permission_forge/concerns/normalizes_label.rb +78 -0
- data/lib/permission_forge/concerns/permission_cache_accessor.rb +3 -3
- data/lib/permission_forge/concerns/permission_scoped_findable.rb +121 -29
- data/lib/permission_forge/concerns/permissioned_user.rb +1 -1
- data/lib/permission_forge/concerns/records_audit_events.rb +13 -6
- data/lib/permission_forge/concerns/requires_name.rb +37 -0
- data/lib/permission_forge/concerns/validates_column_lengths.rb +24 -0
- data/lib/permission_forge/concerns/validates_dependency.rb +5 -2
- data/lib/permission_forge/concerns/validates_required_columns.rb +74 -0
- data/lib/permission_forge/conditional_sets.rb +13 -6
- data/lib/permission_forge/configuration/admin_ui_settings.rb +18 -4
- data/lib/permission_forge/configuration/audit_settings.rb +2 -1
- data/lib/permission_forge/configuration/controller_action_settings.rb +5 -4
- data/lib/permission_forge/configuration/masked_attributes_settings.rb +73 -19
- data/lib/permission_forge/configuration/presets.rb +20 -11
- data/lib/permission_forge/configuration/query_scoping_settings.rb +55 -0
- data/lib/permission_forge/configuration/strategy_values.rb +2 -1
- data/lib/permission_forge/configuration.rb +41 -18
- data/lib/permission_forge/controller_actions/managed_index.rb +8 -7
- data/lib/permission_forge/database_reach.rb +93 -0
- data/lib/permission_forge/deprecation.rb +1 -1
- data/lib/permission_forge/docs/api_samples.rb +250 -0
- data/lib/permission_forge/docs/configuration_reference.rb +172 -0
- data/lib/permission_forge/encrypted_attributes.rb +150 -0
- data/lib/permission_forge/engine.rb +46 -14
- data/lib/permission_forge/execution_state/restore.rb +74 -0
- data/lib/permission_forge/execution_state.rb +8 -1
- data/lib/permission_forge/field_level.rb +123 -0
- data/lib/permission_forge/field_names.rb +145 -0
- data/lib/permission_forge/field_variants.rb +96 -0
- data/lib/permission_forge/generated_text.rb +95 -0
- data/lib/permission_forge/inspection_filters.rb +83 -0
- data/lib/permission_forge/managed_fields.rb +66 -0
- data/lib/permission_forge/masked_models.rb +25 -19
- data/lib/permission_forge/masking_sync.rb +100 -25
- data/lib/permission_forge/model_bindings.rb +30 -19
- data/lib/permission_forge/model_lookup.rb +42 -0
- data/lib/permission_forge/operators/encrypted_alias.rb +67 -0
- data/lib/permission_forge/operators/registry.rb +6 -0
- data/lib/permission_forge/operators/rule_column.rb +242 -27
- data/lib/permission_forge/operators/rule_path.rb +179 -0
- data/lib/permission_forge/operators/rule_problem.rb +309 -0
- data/lib/permission_forge/operators/rule_sources.rb +96 -0
- data/lib/permission_forge/operators/value_casters/postgresql_types.rb +237 -0
- data/lib/permission_forge/operators/value_casters/temporal.rb +9 -20
- data/lib/permission_forge/operators/value_casters.rb +61 -13
- data/lib/permission_forge/operators/value_typing.rb +83 -7
- data/lib/permission_forge/query_scoping_warnings.rb +22 -17
- data/lib/permission_forge/rake_audit.rb +5 -4
- data/lib/permission_forge/rake_dry_run.rb +32 -0
- data/lib/permission_forge/rake_output.rb +189 -0
- data/lib/permission_forge/record_read_access.rb +51 -0
- data/lib/permission_forge/resource_chain.rb +14 -2
- data/lib/permission_forge/schema_read/ordered_types.rb +72 -0
- data/lib/permission_forge/schema_read.rb +335 -0
- data/lib/permission_forge/secret_columns.rb +49 -0
- data/lib/permission_forge/sensitive_columns.rb +114 -0
- data/lib/permission_forge/services/attribute_access_guard.rb +22 -14
- data/lib/permission_forge/services/bulk_permission_set_assignment.rb +16 -8
- data/lib/permission_forge/services/concerns/clonable.rb +9 -0
- data/lib/permission_forge/services/concerns/settles_cache.rb +58 -0
- data/lib/permission_forge/services/consent_withdrawal_handler.rb +37 -19
- data/lib/permission_forge/services/data_subject_erasure.rb +83 -45
- data/lib/permission_forge/services/expired_assignment_cleaner.rb +62 -25
- data/lib/permission_forge/services/extra_field_defaults.rb +1 -1
- data/lib/permission_forge/services/field_permission_merger.rb +44 -17
- data/lib/permission_forge/services/filters_response_attributes.rb +20 -5
- data/lib/permission_forge/services/gdpr_field_auditor/text_report.rb +33 -5
- data/lib/permission_forge/services/gdpr_field_auditor.rb +68 -48
- data/lib/permission_forge/services/host_key_guard.rb +8 -5
- data/lib/permission_forge/services/mask_value.rb +72 -11
- data/lib/permission_forge/services/permission_cache_clearer.rb +36 -15
- data/lib/permission_forge/services/permission_hash_builder.rb +48 -19
- data/lib/permission_forge/services/permission_rule_applier.rb +131 -68
- data/lib/permission_forge/services/permission_set_cloner.rb +32 -8
- data/lib/permission_forge/services/permission_set_condition_evaluator.rb +38 -36
- data/lib/permission_forge/services/permission_set_group_cloner.rb +2 -1
- data/lib/permission_forge/services/permits_attributes/result.rb +31 -0
- data/lib/permission_forge/services/permits_attributes.rb +48 -27
- data/lib/permission_forge/services/resource_field_generator/row_builder.rb +96 -0
- data/lib/permission_forge/services/resource_field_generator.rb +71 -42
- data/lib/permission_forge/services/resource_with_fields_builder.rb +2 -1
- data/lib/permission_forge/services/rls_operator_sql.rb +83 -24
- data/lib/permission_forge/services/rls_policy_generator.rb +83 -39
- data/lib/permission_forge/services/rls_read_access_sql.rb +131 -0
- data/lib/permission_forge/services/security_audit/action_check.rb +49 -5
- data/lib/permission_forge/services/security_audit/assignment_check.rb +2 -1
- data/lib/permission_forge/services/security_audit/audit_trail_check.rb +1 -3
- data/lib/permission_forge/services/security_audit/conditions_check.rb +92 -0
- data/lib/permission_forge/services/security_audit/config_check.rb +41 -18
- data/lib/permission_forge/services/security_audit/integrity_check.rb +71 -4
- data/lib/permission_forge/services/security_audit/masking_without_user.rb +66 -0
- data/lib/permission_forge/services/security_audit/personal_data_check.rb +7 -11
- data/lib/permission_forge/services/security_audit/rule_check.rb +180 -50
- data/lib/permission_forge/services/security_audit/text_report.rb +14 -2
- data/lib/permission_forge/services/security_audit/unmanaged_actions.rb +145 -0
- data/lib/permission_forge/services/security_audit/user_model_check.rb +4 -3
- data/lib/permission_forge/services/security_audit.rb +16 -4
- data/lib/permission_forge/services/stale_assignment_auditor.rb +8 -5
- data/lib/permission_forge/timestamp_fields.rb +60 -0
- data/lib/permission_forge/unique_conflict.rb +117 -0
- data/lib/permission_forge/version.rb +1 -1
- data/lib/permission_forge/virtual_fields/discovery.rb +2 -1
- data/lib/permission_forge/virtual_fields.rb +15 -5
- data/lib/permission_forge/warnings.rb +263 -0
- data/lib/permission_forge/web_request.rb +248 -0
- data/lib/permission_forge.rb +54 -0
- data/lib/tasks/permission_forge.rake +26 -6
- data/lib/tasks/permission_forge_audit_events.rake +8 -4
- data/lib/tasks/permission_forge_cache.rake +12 -12
- data/lib/tasks/permission_forge_docs.rake +48 -0
- data/lib/tasks/permission_forge_expiration.rake +11 -5
- data/lib/tasks/permission_forge_gdpr.rake +36 -20
- metadata +84 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# Whether a signed-in user's record reads of a model are refused whole,
|
|
5
|
+
# before any row-level rule: the one definition behind
|
|
6
|
+
# `config.no_read_access_strategy`, asked by find_by / find_by! /
|
|
7
|
+
# permission_scoped (PermissionScopedFindable) and the default scope
|
|
8
|
+
# (AutoPermissionScoping) before they hand the rules to
|
|
9
|
+
# Services::PermissionRuleApplier.
|
|
10
|
+
#
|
|
11
|
+
# Under :rules_only (the default) never: the reads apply the rules of the
|
|
12
|
+
# user's entries, and a user with no entry, or one that does not grant
|
|
13
|
+
# read_access, has no rules and gets every row (can_read? is the check).
|
|
14
|
+
# Under :deny (the strict preset) whenever the user's entries along the
|
|
15
|
+
# class's Resource chain do not grant read_access
|
|
16
|
+
# (ResourceChain.readable?): no entry at all, or one of them without read
|
|
17
|
+
# — whatever took it away (no Permission Set grants it, a Muting
|
|
18
|
+
# Permission Set, a Resource renamed off its model, a granting set whose
|
|
19
|
+
# activation conditions do not pass or cannot be judged). View All and
|
|
20
|
+
# Modify All skip the rules of a readable entry only.
|
|
21
|
+
#
|
|
22
|
+
# With nobody signed in nil_user_strategy decides; the rules are lifted
|
|
23
|
+
# where they always were (the host's without_permission_scope, the
|
|
24
|
+
# gem's own suspension of the default scope), and this with them.
|
|
25
|
+
#
|
|
26
|
+
# @api private
|
|
27
|
+
module RecordReadAccess
|
|
28
|
+
# The reason access.denied carries.
|
|
29
|
+
REASON = :no_read_access
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# @param user [#permissions_hash] the signed-in user
|
|
34
|
+
# @param klass [Class] the model class read
|
|
35
|
+
# @param configuration [PermissionForge::Configuration]
|
|
36
|
+
# @return [Boolean]
|
|
37
|
+
def denied?(user, klass, configuration: PermissionForge.configuration)
|
|
38
|
+
configuration.deny_record_reads_without_read_access? &&
|
|
39
|
+
!ResourceChain.readable?(user, ResourceChain.names(klass))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# What NotAuthorizedError#failure_messages says for such a denial, in
|
|
43
|
+
# the current locale.
|
|
44
|
+
#
|
|
45
|
+
# @param klass [Class]
|
|
46
|
+
# @return [String]
|
|
47
|
+
def failure_message(klass)
|
|
48
|
+
I18n.t('permission_forge.denials.no_read_access', resource: ResourceChain.names(klass).first || klass.name)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -42,8 +42,8 @@ module PermissionForge
|
|
|
42
42
|
# @param resource_name [String, Symbol]
|
|
43
43
|
# @return [Array<Symbol>]
|
|
44
44
|
def names_for(resource_name)
|
|
45
|
-
klass = resource_name
|
|
46
|
-
klass
|
|
45
|
+
klass = ModelLookup.model_for(resource_name)
|
|
46
|
+
klass ? names(klass) : [resource_name.to_sym]
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
# The user's resource entries along the chain, nearest first.
|
|
@@ -92,6 +92,18 @@ module PermissionForge
|
|
|
92
92
|
entries(user, names).first&.dig(:permissions, flag) == true
|
|
93
93
|
end
|
|
94
94
|
|
|
95
|
+
# Whether the user may read the class's records at all: an entry along
|
|
96
|
+
# the chain, and every one of them grants read_access — a class cannot
|
|
97
|
+
# be more readable than an entry it answers to, as for its fields
|
|
98
|
+
# (#unreadable?). A subclass with no entry of its own reads as its base.
|
|
99
|
+
# Asked through RecordReadAccess.denied?.
|
|
100
|
+
#
|
|
101
|
+
# @return [Boolean]
|
|
102
|
+
def readable?(user, names)
|
|
103
|
+
resource_entries = entries(user, names)
|
|
104
|
+
resource_entries.any? && !unreadable?(resource_entries)
|
|
105
|
+
end
|
|
106
|
+
|
|
95
107
|
# @return [Symbol, nil] :view_all or :modify_all when one of them lets
|
|
96
108
|
# the user past the rules of an entry
|
|
97
109
|
def bypass_reason(permissions)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent/map'
|
|
4
|
+
|
|
5
|
+
module PermissionForge
|
|
6
|
+
module SchemaRead
|
|
7
|
+
# Whether PostgreSQL can compare values of a type as a rule does (`=`,
|
|
8
|
+
# `<>`, `<`, `>`, `IN`, `BETWEEN`): the type — a domain's base type, a
|
|
9
|
+
# range through `anyrange` — has a default btree operator class whose
|
|
10
|
+
# family holds `=` and `<` for it. `xml`, `point`, `polygon` and `path`
|
|
11
|
+
# have no `=`, `hstore` no `<` (its btree operators are `#<#`...). Asked
|
|
12
|
+
# once per process, database and type (one catalog query, in a savepoint);
|
|
13
|
+
# a type the database does not know (`to_regtype` answers NULL) has none.
|
|
14
|
+
# A failed query answers no for that call only and is asked again next
|
|
15
|
+
# time.
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
module OrderedTypes
|
|
19
|
+
@answers = Concurrent::Map.new
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# @param model [Class] a model on the connection
|
|
23
|
+
# @param sql_type [String] the column's SQL type, from the schema
|
|
24
|
+
# @return [Boolean]
|
|
25
|
+
def ordered?(model, sql_type)
|
|
26
|
+
key = [model.connection_db_config.name, sql_type.to_s]
|
|
27
|
+
known = @answers[key]
|
|
28
|
+
return known unless known.nil?
|
|
29
|
+
|
|
30
|
+
answer = ask(model, sql_type)
|
|
31
|
+
answer.nil? ? false : (@answers[key] = answer)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Forgets the answers (tests).
|
|
35
|
+
#
|
|
36
|
+
# @return [void]
|
|
37
|
+
def reset!
|
|
38
|
+
@answers.clear
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# true / false, or nil when the catalog could not be asked (not kept).
|
|
44
|
+
def ask(model, sql_type)
|
|
45
|
+
model.connection_pool.with_connection do |connection|
|
|
46
|
+
connection.transaction(requires_new: true) do
|
|
47
|
+
connection.select_value(sql(connection, sql_type), 'SCHEMA') == true
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
rescue ActiveRecord::StatementInvalid
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def sql(connection, sql_type)
|
|
55
|
+
<<~SQL.squish
|
|
56
|
+
WITH given AS (SELECT COALESCE(NULLIF(t.typbasetype, 0), t.oid) AS oid FROM pg_catalog.pg_type t
|
|
57
|
+
WHERE t.oid = to_regtype(#{connection.quote(sql_type.to_s)})),
|
|
58
|
+
input AS (SELECT CASE b.typtype WHEN 'r' THEN to_regtype('anyrange')::oid
|
|
59
|
+
WHEN 'm' THEN to_regtype('anymultirange')::oid ELSE b.oid END AS oid
|
|
60
|
+
FROM given JOIN pg_catalog.pg_type b ON b.oid = given.oid)
|
|
61
|
+
SELECT COUNT(DISTINCT o.oprname) = 2 FROM input
|
|
62
|
+
JOIN pg_catalog.pg_opclass c ON c.opcintype = input.oid AND c.opcdefault
|
|
63
|
+
JOIN pg_catalog.pg_am a ON a.oid = c.opcmethod AND a.amname = 'btree'
|
|
64
|
+
JOIN pg_catalog.pg_amop ao ON ao.amopfamily = c.opcfamily AND ao.amoplefttype = c.opcintype
|
|
65
|
+
AND ao.amoprighttype = c.opcintype
|
|
66
|
+
JOIN pg_catalog.pg_operator o ON o.oid = ao.amopopr AND o.oprname IN ('=', '<')
|
|
67
|
+
SQL
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent/map'
|
|
4
|
+
require 'concurrent/atomic/atomic_fixnum'
|
|
5
|
+
|
|
6
|
+
module PermissionForge
|
|
7
|
+
# The one place the gem reads a model's columns, and whether a table is
|
|
8
|
+
# there. Rails keeps both in its schema cache, read once per process (or
|
|
9
|
+
# loaded from a schema cache dump), so a column a migration added while
|
|
10
|
+
# the process was up — a running development server, an old process in a
|
|
11
|
+
# rolling deploy — is not in it: a rule naming it would be refused, Sync
|
|
12
|
+
# fields would not offer it, its timestamps would not be read-only.
|
|
13
|
+
#
|
|
14
|
+
# So a read that answers "no" — the name is not a column of the table, the
|
|
15
|
+
# table is not there — reads the schema again first: the table's column
|
|
16
|
+
# names are asked of the database (one metadata query) and, only when they
|
|
17
|
+
# differ from what the cache holds, the model's column information is reset
|
|
18
|
+
# (reset_column_information) and the answer asked again. A process whose
|
|
19
|
+
# schema did not change never resets a model on the request path (a reset
|
|
20
|
+
# drops the model's generated attribute methods while other threads use
|
|
21
|
+
# it); the question the builder asks for every `_id` field's `_uuid`
|
|
22
|
+
# variant costs one query per model and name per process. A name is read
|
|
23
|
+
# again at most once per process while the table's columns stay as they
|
|
24
|
+
# were read: a name found missing by a re-read is
|
|
25
|
+
# *confirmed* missing and costs nothing afterwards, until something else
|
|
26
|
+
# reads the table again (a reload in development, a migration in the
|
|
27
|
+
# process, another re-read). A column that is there costs nothing extra:
|
|
28
|
+
# no query, no lock.
|
|
29
|
+
#
|
|
30
|
+
# A list of columns cannot tell what it is missing. The reads that build
|
|
31
|
+
# rows or reports from one — the field generator, Sync fields, the
|
|
32
|
+
# admin's model catalog, the GDPR audit, the rule builder's columns — pass
|
|
33
|
+
# `verify: true`: the column names are asked of the database (one
|
|
34
|
+
# metadata query, no reset) and the schema is read again when they differ.
|
|
35
|
+
# That is also how a removed column leaves a process: until such a read, a
|
|
36
|
+
# column the cache still lists is answered as there (asking the database
|
|
37
|
+
# on every "yes" would cost a query on the request path); after it, every
|
|
38
|
+
# reader agrees it is gone, without reading again.
|
|
39
|
+
#
|
|
40
|
+
# Columns are the table's, as the database has them (`columns(model)`,
|
|
41
|
+
# `ignored_columns` included, as a rule's column is resolved) or the
|
|
42
|
+
# model's (`column_names`, `ignored_columns` excluded), as each caller says.
|
|
43
|
+
#
|
|
44
|
+
# @example
|
|
45
|
+
# PermissionForge::SchemaRead.column?(Customer, 'region') # read again once if the cache says no
|
|
46
|
+
# PermissionForge::SchemaRead.columns(Customer, verify: true).keys
|
|
47
|
+
# PermissionForge::SchemaRead.table?(Customer, 'suppliers')
|
|
48
|
+
#
|
|
49
|
+
# @api private
|
|
50
|
+
module SchemaRead
|
|
51
|
+
# What the last re-read of a table found: `names` confirmed missing in
|
|
52
|
+
# the columns identified by `token` (the cached columns hash's
|
|
53
|
+
# object_id), and the `columns` it read.
|
|
54
|
+
Confirmed = Data.define(:token, :names, :columns)
|
|
55
|
+
|
|
56
|
+
# Seconds a table found missing stays missing before table? asks the
|
|
57
|
+
# database again.
|
|
58
|
+
TABLE_RECHECK = 30
|
|
59
|
+
|
|
60
|
+
@confirmed = Concurrent::Map.new
|
|
61
|
+
@claims = Concurrent::Map.new
|
|
62
|
+
@tables = Concurrent::Map.new
|
|
63
|
+
@enum_labels = Concurrent::Map.new
|
|
64
|
+
@enum_claims = Concurrent::Map.new
|
|
65
|
+
@rereads = Concurrent::AtomicFixnum.new
|
|
66
|
+
|
|
67
|
+
class << self
|
|
68
|
+
# The table's columns by name (`ignored_columns` included).
|
|
69
|
+
#
|
|
70
|
+
# @param model [Class] an Active Record model with a table
|
|
71
|
+
# @param verify [Boolean] compare with the database first (see the module comment)
|
|
72
|
+
# @return [Hash{String => ActiveRecord::ConnectionAdapters::Column}]
|
|
73
|
+
def columns(model, verify: false)
|
|
74
|
+
verify!(model) if verify
|
|
75
|
+
table_columns(model)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The model's column names (`ignored_columns` excluded).
|
|
79
|
+
#
|
|
80
|
+
# @param model [Class]
|
|
81
|
+
# @param verify [Boolean] compare with the database first
|
|
82
|
+
# @return [Array<String>]
|
|
83
|
+
def column_names(model, verify: false)
|
|
84
|
+
verify!(model) if verify
|
|
85
|
+
model.column_names
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The table's column of that name, read again once when there is none.
|
|
89
|
+
# A block picks the column out of the table's columns instead of the
|
|
90
|
+
# exact name (a lookup that ignores case where the database does).
|
|
91
|
+
#
|
|
92
|
+
# @param model [Class]
|
|
93
|
+
# @param name [String, Symbol]
|
|
94
|
+
# @yieldparam columns [Hash{String => ActiveRecord::ConnectionAdapters::Column}]
|
|
95
|
+
# @yieldreturn [ActiveRecord::ConnectionAdapters::Column, nil]
|
|
96
|
+
# @return [ActiveRecord::ConnectionAdapters::Column, nil]
|
|
97
|
+
def column(model, name, &pick)
|
|
98
|
+
name = name.to_s
|
|
99
|
+
pick ||= ->(columns) { columns[name] }
|
|
100
|
+
pick.call(table_columns(model)) || (pick.call(table_columns(model)) if reread_missing?(model, [name]))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @param model [Class]
|
|
104
|
+
# @param name [String, Symbol]
|
|
105
|
+
# @param ignored [Boolean] whether a column the model ignores (`ignored_columns`) counts
|
|
106
|
+
# @return [Boolean] whether the table has the column (read again once before "no")
|
|
107
|
+
def column?(model, name, ignored: true)
|
|
108
|
+
name = name.to_s
|
|
109
|
+
has?(model, name) { ignored ? table_columns(model).key?(name) : model.column_names.include?(name) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The names among `names` that are columns of the model
|
|
113
|
+
# (`ignored_columns` excluded), the table read again once when some are
|
|
114
|
+
# not.
|
|
115
|
+
#
|
|
116
|
+
# @param model [Class]
|
|
117
|
+
# @param names [Array<String>]
|
|
118
|
+
# @return [Array<String>] in the order of `names`
|
|
119
|
+
def columns_among(model, names)
|
|
120
|
+
names = names.map(&:to_s)
|
|
121
|
+
found = names & model.column_names
|
|
122
|
+
return found if found.size == names.size || !reread_missing?(model, names - found)
|
|
123
|
+
|
|
124
|
+
names & model.column_names
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The block's answer about `name`; when it is "no" and the table has no
|
|
128
|
+
# such column, the table is read again once and the block asked again.
|
|
129
|
+
# For a predicate that also counts what is not a column (an attribute,
|
|
130
|
+
# an alias, a method, a virtual field).
|
|
131
|
+
#
|
|
132
|
+
# @param model [Class]
|
|
133
|
+
# @param name [String, Symbol]
|
|
134
|
+
# @yieldreturn [Boolean]
|
|
135
|
+
# @return [Boolean]
|
|
136
|
+
def has?(model, name)
|
|
137
|
+
return true if yield
|
|
138
|
+
|
|
139
|
+
reread_missing?(model, [name.to_s]) && yield ? true : false
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Whether the connection has a data source (a table or a view) named
|
|
143
|
+
# `table`; a "no" is asked of the database again, at most once every
|
|
144
|
+
# TABLE_RECHECK seconds per model and name (a table a migration
|
|
145
|
+
# creates later is found within that interval, and a name that stays
|
|
146
|
+
# missing — a rule qualified with an alias — costs one query per
|
|
147
|
+
# interval, never one per call).
|
|
148
|
+
#
|
|
149
|
+
# @param model [Class] a model on the connection
|
|
150
|
+
# @param table [String] the name as written
|
|
151
|
+
# @return [Boolean]
|
|
152
|
+
def table?(model, table)
|
|
153
|
+
table = table.to_s
|
|
154
|
+
cache = schema_cache(model)
|
|
155
|
+
return true if cache.data_source_exists?(table)
|
|
156
|
+
|
|
157
|
+
claim = [slot(model), table]
|
|
158
|
+
return false unless table_recheck_due?(claim)
|
|
159
|
+
|
|
160
|
+
reread_table?(cache, table, claim)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Whether `label` is one of the labels of the native enum type `type`
|
|
164
|
+
# (PostgreSQL: `CREATE TYPE ... AS ENUM`), as the database has them.
|
|
165
|
+
# The labels are read once per process and type (one catalog query);
|
|
166
|
+
# a label they lack is asked of the database again once, while the
|
|
167
|
+
# labels stay as they were read: one a migration added while the
|
|
168
|
+
# process runs (`ALTER TYPE ... ADD VALUE`). A type the database does
|
|
169
|
+
# not know has no labels.
|
|
170
|
+
#
|
|
171
|
+
# @param model [Class] a model on the connection whose table has the column
|
|
172
|
+
# @param type [String] the column's SQL type: the enum type's name
|
|
173
|
+
# @param label [String]
|
|
174
|
+
# @return [Boolean]
|
|
175
|
+
def enum_label?(model, type, label)
|
|
176
|
+
key = [model.connection_db_config.name, type.to_s]
|
|
177
|
+
labels = @enum_labels.compute_if_absent(key) { enum_labels_of(model, type) }
|
|
178
|
+
return true if labels.include?(label)
|
|
179
|
+
|
|
180
|
+
claim = [key, labels.object_id, label]
|
|
181
|
+
return false unless @enum_claims.put_if_absent(claim, true).nil?
|
|
182
|
+
|
|
183
|
+
@rereads.increment
|
|
184
|
+
(@enum_labels[key] = enum_labels_of(model, type)).include?(label)
|
|
185
|
+
rescue ActiveRecord::StatementInvalid
|
|
186
|
+
# The labels could not be read (a timeout, a lost connection): no
|
|
187
|
+
# for this call only — nothing kept, asked again next time.
|
|
188
|
+
@enum_claims.delete(claim) if claim
|
|
189
|
+
false
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Whether PostgreSQL can compare values of the type (SchemaRead::OrderedTypes).
|
|
193
|
+
#
|
|
194
|
+
# @param model [Class] a model on the connection
|
|
195
|
+
# @param sql_type [String] the column's SQL type, from the schema
|
|
196
|
+
# @return [Boolean]
|
|
197
|
+
def ordered_type?(model, sql_type) = OrderedTypes.ordered?(model, sql_type)
|
|
198
|
+
|
|
199
|
+
# @return [Integer] how many times this process read a table's schema again
|
|
200
|
+
def rereads
|
|
201
|
+
@rereads.value
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Forgets what was confirmed missing (tests).
|
|
205
|
+
#
|
|
206
|
+
# @return [void]
|
|
207
|
+
def reset!
|
|
208
|
+
[@confirmed, @claims, @tables, @enum_labels, @enum_claims].each(&:clear)
|
|
209
|
+
OrderedTypes.reset!
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
# The type's labels in their order; none for a type the database does
|
|
215
|
+
# not know (to_regtype answers NULL where a cast would raise and fail
|
|
216
|
+
# the statement, and with it the caller's transaction).
|
|
217
|
+
def enum_labels_of(model, type)
|
|
218
|
+
model.connection_pool.with_connection do |connection|
|
|
219
|
+
connection.transaction(requires_new: true) do
|
|
220
|
+
connection.select_values(<<~SQL.squish, 'SCHEMA').freeze
|
|
221
|
+
SELECT enumlabel::text FROM pg_catalog.pg_enum
|
|
222
|
+
WHERE enumtypid = to_regtype(#{connection.quote(type.to_s)}) ORDER BY enumsortorder
|
|
223
|
+
SQL
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Claims the re-read of a missing table when none ran in the last
|
|
229
|
+
# TABLE_RECHECK seconds (one thread wins; the others answer "no").
|
|
230
|
+
def table_recheck_due?(key)
|
|
231
|
+
now = Time.now.to_f
|
|
232
|
+
last = @tables[key]
|
|
233
|
+
return @tables.put_if_absent(key, now).nil? if last.nil?
|
|
234
|
+
return false if now - last < TABLE_RECHECK
|
|
235
|
+
|
|
236
|
+
@tables.replace_pair(key, last, now)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# The table asked of the database again, once per claim. A read that
|
|
240
|
+
# fails (a timeout, a lost connection) answers no for this call only:
|
|
241
|
+
# the claim is let go, so the next call asks again.
|
|
242
|
+
def reread_table?(cache, table, claim)
|
|
243
|
+
cache.clear_data_source_cache!(table)
|
|
244
|
+
@rereads.increment
|
|
245
|
+
cache.data_source_exists?(table)
|
|
246
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
|
|
247
|
+
@tables.delete(claim)
|
|
248
|
+
false
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def table_columns(model)
|
|
252
|
+
schema_cache(model).columns_hash(model.table_name)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# The pool's schema cache, never `model.connection`'s: that reader
|
|
256
|
+
# leases a connection to the thread for good and, from Rails 7.2,
|
|
257
|
+
# raises under ActiveRecord.permanent_connection_checkout =
|
|
258
|
+
# :disallowed. The pool's cache takes a connection only for the read
|
|
259
|
+
# it makes, and is the one the connection's own cache is. Rails 7.1
|
|
260
|
+
# has no pool cache (nor :disallowed): the connection's.
|
|
261
|
+
def schema_cache(model)
|
|
262
|
+
pool = model.connection_pool
|
|
263
|
+
pool.respond_to?(:schema_cache) ? pool.schema_cache : model.connection.schema_cache
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Reads the table again when one of `names` is no column of it and was
|
|
267
|
+
# not confirmed missing by the read the cache holds. true when it did.
|
|
268
|
+
# Two threads asking at once: one reads, the other answers from what
|
|
269
|
+
# the cache holds (no lock is held while Rails resets the model).
|
|
270
|
+
def reread_missing?(model, names)
|
|
271
|
+
columns = table_columns(model)
|
|
272
|
+
missing = names - columns.keys
|
|
273
|
+
return false if missing.empty? || confirmed?(model, columns, missing)
|
|
274
|
+
|
|
275
|
+
claim = [slot(model), columns.object_id, missing.sort]
|
|
276
|
+
return false unless @claims.put_if_absent(claim, true).nil?
|
|
277
|
+
|
|
278
|
+
begin
|
|
279
|
+
reread!(model, missing)
|
|
280
|
+
ensure
|
|
281
|
+
@claims.delete(claim)
|
|
282
|
+
end
|
|
283
|
+
true
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def confirmed?(model, columns, missing)
|
|
287
|
+
entry = @confirmed[slot(model)]
|
|
288
|
+
!entry.nil? && entry.token == columns.object_id && (missing - entry.names).empty?
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Confirmed missing after the read: the names asked for now, the ones
|
|
292
|
+
# confirmed before, and every column an earlier read had (the cache
|
|
293
|
+
# being dropped, the last re-read) — a removed column among them — that
|
|
294
|
+
# the new read lacks.
|
|
295
|
+
def reread!(model, missing, live: live_names(model))
|
|
296
|
+
known = missing | known_names(model)
|
|
297
|
+
@rereads.increment
|
|
298
|
+
model.reset_column_information unless current?(model, live)
|
|
299
|
+
fresh = table_columns(model)
|
|
300
|
+
@confirmed[slot(model)] = Confirmed.new(token: fresh.object_id, names: (known - fresh.keys).freeze, columns: fresh.keys.freeze)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# The columns the cache and the model hold and, from the last re-read,
|
|
304
|
+
# the columns it read and the names it confirmed missing.
|
|
305
|
+
def known_names(model)
|
|
306
|
+
before = @confirmed[slot(model)]
|
|
307
|
+
table_columns(model).keys | model.columns_hash.keys | (before ? before.names + before.columns : [])
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# What the memo is keyed by: the class's object_id, so that a class
|
|
311
|
+
# reloaded in development (another class) is read again, and the memo
|
|
312
|
+
# does not keep the old one alive.
|
|
313
|
+
def slot(model) = model.object_id
|
|
314
|
+
|
|
315
|
+
def verify!(model)
|
|
316
|
+
live = live_names(model)
|
|
317
|
+
reread!(model, [], live: live) unless current?(model, live)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Whether both reads Rails keeps — the schema cache's columns and the
|
|
321
|
+
# model's own (`columns_hash`, which DDL run in the process does not
|
|
322
|
+
# clear) — are the database's `live` names.
|
|
323
|
+
def current?(model, live)
|
|
324
|
+
live.sort == table_columns(model).keys.sort &&
|
|
325
|
+
model.columns_hash.keys.sort == (live - model.ignored_columns.map(&:to_s)).sort
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# The table's column names as the database has them now (a metadata
|
|
329
|
+
# query).
|
|
330
|
+
def live_names(model)
|
|
331
|
+
model.connection_pool.with_connection { |connection| connection.columns(model.table_name).map(&:name) }
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# The one definition of "a column that holds a secret", which a record rule
|
|
5
|
+
# never compares: config.rule_secret_column_patterns (default
|
|
6
|
+
# Configuration::QueryScopingSettings::DEFAULT_RULE_SECRET_COLUMN_PATTERNS —
|
|
7
|
+
# `*_digest`, `*token*`, `encrypted_*`, `*_secret*`, `*passw*`, `secret*`,
|
|
8
|
+
# `api_key` / `private_key` / `secret_key` / `access_key`, `crypted_*`,
|
|
9
|
+
# `*salt`, `otp_*`; `[]` opts out). A rule on such a column turns the rule into an oracle on
|
|
10
|
+
# the secret (who may read which records tells its value apart), and the
|
|
11
|
+
# rule's value (stored, shown in the admin and the API) would be a guess at it.
|
|
12
|
+
#
|
|
13
|
+
# Unlike SensitiveColumns (personal data, which a rule may well compare:
|
|
14
|
+
# an owner's email) it judges the column a rule names: the name after the
|
|
15
|
+
# last dot, in any case. Readers: PermissionSetRule's validation (a new
|
|
16
|
+
# rule, a change of its column or path, and on a rule stored before a
|
|
17
|
+
# change of its value or operator), the admin's rule builder (not
|
|
18
|
+
# offered), the security audit's secret_rule_columns finding and Home's
|
|
19
|
+
# card for the rules stored before (they keep evaluating as before).
|
|
20
|
+
#
|
|
21
|
+
# @api private
|
|
22
|
+
class SecretColumns
|
|
23
|
+
# @param configuration [Configuration]
|
|
24
|
+
def initialize(configuration: PermissionForge.configuration)
|
|
25
|
+
@configuration = configuration
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param name [String, Symbol, nil] a column name, qualified or not (`table.column`)
|
|
29
|
+
# @return [Boolean]
|
|
30
|
+
def secret?(name)
|
|
31
|
+
!pattern_for(name).nil?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param name [String, Symbol, nil]
|
|
35
|
+
# @return [Regexp, nil] the first pattern the column matches
|
|
36
|
+
def pattern_for(name)
|
|
37
|
+
column = name.to_s.split('.').last.to_s.downcase
|
|
38
|
+
return if column.empty?
|
|
39
|
+
|
|
40
|
+
Array(@configuration.rule_secret_column_patterns).find { |pattern| column.match?(pattern) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Whether the column a rule compares holds a secret.
|
|
44
|
+
#
|
|
45
|
+
# @param rule [PermissionSetRule]
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def rule?(rule) = secret?(rule.field_name)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# The one definition of "this column looks like personal data, and this is
|
|
5
|
+
# the mask for it". Two sources, in this order: config.gdpr_field_patterns
|
|
6
|
+
# (pattern => strategy; the alternative that matched names the match) and,
|
|
7
|
+
# while filter_parameters_integration is on, the host application's
|
|
8
|
+
# Rails filter_parameters (recommended strategy 'full': what the host hides
|
|
9
|
+
# from its logs it does not show in the clear either).
|
|
10
|
+
#
|
|
11
|
+
# Every reader asks here: the admin's presets, "Sync fields", the Fields
|
|
12
|
+
# tab and Home's privacy card; the field generator (ResourceFieldGenerator
|
|
13
|
+
# and so the API's generate_fields and grant_full_access!) under
|
|
14
|
+
# gdpr_defaults; GdprFieldAuditor (rake permission_forge:audit_masks) and
|
|
15
|
+
# the security audit's personal_data check; the boot warning about
|
|
16
|
+
# unmasked personal data. A column one of them calls personal data, they
|
|
17
|
+
# all do (release_readiness/decision_parity_test.rb).
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
20
|
+
class SensitiveColumns
|
|
21
|
+
# A match: `pattern` names what matched (the alternative of the GDPR
|
|
22
|
+
# pattern, or 'filter_parameters'), `strategy` the recommended mask,
|
|
23
|
+
# `source` :gdpr_pattern or :filter_parameters.
|
|
24
|
+
Match = Struct.new(:pattern, :strategy, :source, keyword_init: true) do
|
|
25
|
+
def filter_parameter? = source == :filter_parameters
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
FILTER_PARAMETERS = 'filter_parameters'
|
|
29
|
+
|
|
30
|
+
# @param configuration [Configuration]
|
|
31
|
+
# @param filter_parameters [Array, nil] the host's filter_parameters
|
|
32
|
+
# (default: Rails.application.config.filter_parameters while the
|
|
33
|
+
# integration is on)
|
|
34
|
+
def initialize(configuration: PermissionForge.configuration, filter_parameters: nil)
|
|
35
|
+
@configuration = configuration
|
|
36
|
+
@filter_parameters = filter_parameters
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param column_name [String, Symbol]
|
|
40
|
+
# @return [Match, nil]
|
|
41
|
+
def match(column_name)
|
|
42
|
+
name = column_name.to_s
|
|
43
|
+
regex, strategy = @configuration.gdpr_field_patterns.find { |pattern, _| name.match?(pattern) }
|
|
44
|
+
return Match.new(pattern: pattern_label(regex, name), strategy: strategy, source: :gdpr_pattern) if regex
|
|
45
|
+
return Match.new(pattern: FILTER_PARAMETERS, strategy: 'full', source: :filter_parameters) if filtered_parameter?(name)
|
|
46
|
+
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param column_name [String, Symbol]
|
|
51
|
+
# @return [Boolean]
|
|
52
|
+
def sensitive?(column_name)
|
|
53
|
+
!match(column_name).nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Human-ish pattern name for "Matches privacy pattern "email"": the
|
|
59
|
+
# alternative of the regex that actually matched, stripped of anchors.
|
|
60
|
+
def pattern_label(regex, name)
|
|
61
|
+
source = regex.source
|
|
62
|
+
hit = matching_alternative(source, name) || source.split('|').first
|
|
63
|
+
hit.to_s.gsub(/\\[Azb]|\.\*|\[[^\]]*\]/, '').delete('^a-z0-9_').delete_prefix('_').delete_suffix('_').presence || source
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def matching_alternative(source, name)
|
|
67
|
+
source.split('|').find { |alt| safe_match?(alt, name) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def safe_match?(source, name)
|
|
71
|
+
name.match?(Regexp.new(source))
|
|
72
|
+
rescue RegexpError
|
|
73
|
+
false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A column name is not a request parameter: ActiveSupport::
|
|
77
|
+
# ParameterFilter matches a String / Symbol entry anywhere in the key,
|
|
78
|
+
# so Rails' generated `:otp`, `:ssn`, `:salt` would call `footprint`,
|
|
79
|
+
# `classname`, `persalted` personal data. Here such an entry names a
|
|
80
|
+
# column when one of the name's segments (split on `_`) starts with it;
|
|
81
|
+
# a leading or trailing underscore ties it to a segment boundary
|
|
82
|
+
# (`_key`: a segment after the first starts with `key` — `api_key`, and
|
|
83
|
+
# also `sort_key`). A Regexp entry matches as it is written; a Proc
|
|
84
|
+
# entry (it rewrites values) names no column.
|
|
85
|
+
def filtered_parameter?(name)
|
|
86
|
+
@filter_patterns ||= host_filter_parameters.filter_map { |entry| filter_pattern(entry) }
|
|
87
|
+
@filter_patterns.any? { |pattern| name.match?(pattern) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def filter_pattern(entry)
|
|
91
|
+
case entry
|
|
92
|
+
when Regexp then entry
|
|
93
|
+
when String, Symbol then segment_pattern(entry.to_s)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def segment_pattern(entry)
|
|
98
|
+
core = entry.delete_prefix('_').delete_suffix('_')
|
|
99
|
+
return if core.empty?
|
|
100
|
+
|
|
101
|
+
start = entry.start_with?('_') ? '(?<=_)' : '(?:\\A|(?<=_))'
|
|
102
|
+
finish = entry.end_with?('_') ? '(?=_)' : ''
|
|
103
|
+
Regexp.new("#{start}#{Regexp.escape(core)}#{finish}", Regexp::IGNORECASE)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def host_filter_parameters
|
|
107
|
+
return [] unless @configuration.filter_parameters_integration
|
|
108
|
+
return Array(@filter_parameters) if @filter_parameters
|
|
109
|
+
return [] unless defined?(Rails) && Rails.application
|
|
110
|
+
|
|
111
|
+
Array(Rails.application.config.filter_parameters)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|