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,309 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Operators
|
|
5
|
+
# The English of RuleProblem#validation_message, by RuleProblem#message_key
|
|
6
|
+
# (translated under permission_forge.validations.permission_set_rule).
|
|
7
|
+
RULE_PROBLEM_MESSAGES = {
|
|
8
|
+
unknown_association: 'names %<association>s, which is not an association of %<model>s',
|
|
9
|
+
empty_association: 'has an empty association name (two dots in a row, or a dot at the start)',
|
|
10
|
+
unjoinable_association: 'names %<association>s, an association of %<model>s that a rule cannot join',
|
|
11
|
+
unjoinable_invalid_definition: 'names %<association>s, an association of %<model>s whose :through, :source ' \
|
|
12
|
+
'or inverse_of names an association that is not there',
|
|
13
|
+
unjoinable_polymorphic: 'names %<association>s, a polymorphic association of %<model>s: a rule cannot join it',
|
|
14
|
+
unjoinable_record_dependent_scope: 'names %<association>s, an association of %<model>s whose scope (or one ' \
|
|
15
|
+
'along its :through) takes the record: a rule cannot join it',
|
|
16
|
+
unjoinable_class_not_loadable: 'names %<association>s, an association of %<model>s whose class cannot be loaded',
|
|
17
|
+
unknown_column: 'names %<name>s, which is not a column of %<model>s',
|
|
18
|
+
unknown_table: 'names %<name>s, which is no table of this database',
|
|
19
|
+
foreign_table: 'names %<name>s, a table that is not %<model>s\'s: a filter rule compares its own table. For a column ' \
|
|
20
|
+
'of another table, use a join rule (a join type and the relationship path)',
|
|
21
|
+
invalid_operator: 'is not a valid operator',
|
|
22
|
+
invalid_join_type: 'is not a valid join type',
|
|
23
|
+
encrypted_column: 'names %<name>s, an encrypted attribute of %<model>s: the database holds its ciphertext, so a ' \
|
|
24
|
+
'rule can only check whether it is empty (is_null, is_not_null)',
|
|
25
|
+
pattern_on_non_text: 'matches text only, and %<model>s.%<name>s is a column of type %<type>s: use a condition ' \
|
|
26
|
+
'that compares values',
|
|
27
|
+
unusable_value: 'is not a value %<model>s.%<name>s can hold'
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# Why a saved rule matches nothing: the one taxonomy of a "broken" rule
|
|
31
|
+
# ({KINDS}), which the model's validations, the applier's `none`
|
|
32
|
+
# (Services::PermissionRuleApplier), the audit (Services::SecurityAudit's
|
|
33
|
+
# rule check), the admin's Home ("Rules that match nothing"), the admin's
|
|
34
|
+
# rule sentences and the RLS policy's `FALSE` all read.
|
|
35
|
+
#
|
|
36
|
+
# - :unknown_join_type — a join type none of PermissionRuleApplier::JOIN_METHODS;
|
|
37
|
+
# `name` is the join type as stored;
|
|
38
|
+
# - :unknown_association / :unjoinable_association — a join rule whose
|
|
39
|
+
# association path cannot be followed ({RulePath#broken?}); `name` is
|
|
40
|
+
# the segment, `model` the model it was looked up in, `cause` why
|
|
41
|
+
# ({RulePath#cause}: :empty_segment, or one of
|
|
42
|
+
# RulePath::UNJOINABLE_CAUSES);
|
|
43
|
+
# - :unknown_operator — an operator the Registry does not have; `name`
|
|
44
|
+
# is the operator as stored;
|
|
45
|
+
# - :missing_column — a field_name that is no column of its table
|
|
46
|
+
# ({RuleColumn#missing?}), or not a name at all ({RuleColumn#malformed?});
|
|
47
|
+
# `name` is the column as written, `model` the model whose table lacks it;
|
|
48
|
+
# - :unknown_table — a field_name qualified with a name that is none of
|
|
49
|
+
# the rule's tables, none its join gives and no table of the database
|
|
50
|
+
# ({RuleColumn#unresolvable?}); `name` is the qualifier;
|
|
51
|
+
# - :foreign_table — a filter rule's field_name qualified with a real
|
|
52
|
+
# table of another model ({RuleColumn#foreign?}): a filter rule compares
|
|
53
|
+
# its model's own table; `name` is the qualifier;
|
|
54
|
+
# - :encrypted_column — a built-in operator that takes a value, on a
|
|
55
|
+
# column that holds ciphertext ({RuleColumn#encrypted?}: an attribute
|
|
56
|
+
# declared with `encrypts`); `name` is the column. Refused for a new
|
|
57
|
+
# rule and when its column, path, join type or model changes or its
|
|
58
|
+
# operator moves off the null checks (on `field_name`);
|
|
59
|
+
# - :pattern_on_non_text — a LIKE operator (contains, starts_with, ...) on
|
|
60
|
+
# a column no pattern can be matched against ({RuleColumn#pattern?}: a
|
|
61
|
+
# json column, and on PostgreSQL every column that is not text);
|
|
62
|
+
# `name` is the column, `column_type` its SQL type. Refused like a
|
|
63
|
+
# value (on `operator`);
|
|
64
|
+
# - :unusable_value — a value the column cannot hold ({ValueTyping::UNUSABLE});
|
|
65
|
+
# `name` is the column, `value` the value as stored. Refused for a new
|
|
66
|
+
# or changed rule (on `field_value`); a stored one is listed and denied.
|
|
67
|
+
#
|
|
68
|
+
# The kinds are checked in the order the applier decides them, so a rule
|
|
69
|
+
# with several problems is named by the one that denies it first. A rule
|
|
70
|
+
# PermissionSetRule refuses on save never gets here; one saved before the
|
|
71
|
+
# check, or written past the model, does. Whatever cannot be told — a
|
|
72
|
+
# Resource named after no model, a model file that raises while it loads
|
|
73
|
+
# — is no column or value problem here (see RuleColumn.for_rule): the
|
|
74
|
+
# admin and the audit describe, they never fail on a model the
|
|
75
|
+
# application cannot load. An operator or join type is judged whatever
|
|
76
|
+
# the model.
|
|
77
|
+
#
|
|
78
|
+
# @example
|
|
79
|
+
# problem = RuleProblem.for(rule)
|
|
80
|
+
# problem&.kind # => :missing_column
|
|
81
|
+
# problem&.name # => "stauts"
|
|
82
|
+
# problem&.key # => :missing_column
|
|
83
|
+
class RuleProblem < Data.define(:kind, :name, :model, :cause, :value, :column_type)
|
|
84
|
+
# Every kind, in the order the applier decides them. A new kind needs a
|
|
85
|
+
# fixture in test/support/rule_problem_fixtures.rb, an entry in
|
|
86
|
+
# RULE_PROBLEM_MESSAGES (or MESSAGE_KEYS), one in REMEDIES, and the
|
|
87
|
+
# message and the `ui.rules.problems` sentence in en, sq and mk.
|
|
88
|
+
#
|
|
89
|
+
# The value kinds come last and in ValueTyping.problem's order: an
|
|
90
|
+
# encrypted column or a LIKE on a column that is not text makes every
|
|
91
|
+
# value UNUSABLE, so they are told apart before :unusable_value.
|
|
92
|
+
KINDS = %i[unknown_join_type unknown_association unjoinable_association unknown_operator missing_column
|
|
93
|
+
unknown_table foreign_table encrypted_column pattern_on_non_text unusable_value].freeze
|
|
94
|
+
# What the admin changes to correct the rule, by kind — the rule
|
|
95
|
+
# builder's control (`permission_set_rules.builder.problem_fixes.<remedy>`):
|
|
96
|
+
# the join type (Advanced), the relationship, the condition, the field,
|
|
97
|
+
# or the value.
|
|
98
|
+
REMEDIES = { unknown_join_type: :join, unknown_association: :path, unjoinable_association: :path,
|
|
99
|
+
unknown_operator: :operator, missing_column: :field, unknown_table: :field,
|
|
100
|
+
foreign_table: :path, encrypted_column: :field, pattern_on_non_text: :operator,
|
|
101
|
+
unusable_value: :value }.freeze
|
|
102
|
+
# The kinds that are the rule's association path (not its column or value).
|
|
103
|
+
PATH_KINDS = %i[unknown_association unjoinable_association].freeze
|
|
104
|
+
# The kinds whose `name` is a name that is not there, shown as stored.
|
|
105
|
+
VERBATIM_KINDS = %i[missing_column unknown_association unknown_operator unknown_join_type unknown_table foreign_table].freeze
|
|
106
|
+
# key => the validations.permission_set_rule.* message, where it is not the key itself.
|
|
107
|
+
MESSAGE_KEYS = { missing_column: :unknown_column, unknown_operator: :invalid_operator,
|
|
108
|
+
unknown_join_type: :invalid_join_type }.freeze
|
|
109
|
+
# What `current_user` is typed as when a stored value is judged: an id
|
|
110
|
+
# is a number, a uuid or the text of either, whoever the user is — never
|
|
111
|
+
# a boolean, a date, an address or an enum label. The value is unusable
|
|
112
|
+
# only when it is for every one of them.
|
|
113
|
+
ANY_USER_IDS = [0, '00000000-0000-0000-0000-000000000000'].freeze
|
|
114
|
+
|
|
115
|
+
# `cause`, `value` and `column_type` are optional: a column problem has
|
|
116
|
+
# no cause, only a value problem a value, only a LIKE on a column that
|
|
117
|
+
# is not text a column type.
|
|
118
|
+
OPTIONAL = { cause: nil, value: nil, column_type: nil }.freeze
|
|
119
|
+
private_constant :OPTIONAL
|
|
120
|
+
|
|
121
|
+
def initialize(kind:, name:, model:, **optional)
|
|
122
|
+
super(kind:, name:, model:, **OPTIONAL, **optional)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @param rule [PermissionForge::PermissionSetRule]
|
|
126
|
+
# @return [RuleProblem, nil]
|
|
127
|
+
def self.for(rule)
|
|
128
|
+
of_join_type(rule) || of_path(rule) || of_operator(rule) || of_column_or_value(rule)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# A join rule whose join type is none the applier joins by.
|
|
132
|
+
#
|
|
133
|
+
# @param rule [PermissionForge::PermissionSetRule]
|
|
134
|
+
# @return [RuleProblem, nil]
|
|
135
|
+
def self.of_join_type(rule)
|
|
136
|
+
return unless rule.join_rule?
|
|
137
|
+
|
|
138
|
+
join_type = rule.join_type.to_s
|
|
139
|
+
return if Services::PermissionRuleApplier::JOIN_METHODS.key?(join_type)
|
|
140
|
+
|
|
141
|
+
new(kind: :unknown_join_type, name: join_type, model: RuleColumn.rule_model(rule))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The rule's path problem, nil when it has none (a filter rule never
|
|
145
|
+
# has one: its path is not joined).
|
|
146
|
+
#
|
|
147
|
+
# @param rule [PermissionForge::PermissionSetRule]
|
|
148
|
+
# @return [RuleProblem, nil]
|
|
149
|
+
def self.of_path(rule)
|
|
150
|
+
model = RuleColumn.rule_model(rule)
|
|
151
|
+
return unless model && rule.join_rule?
|
|
152
|
+
|
|
153
|
+
path = RulePath.follow(model, RuleColumn.path_segments(rule.field_accessor_path))
|
|
154
|
+
new(kind: path.reason, name: path.segment, model: path.owner, cause: path.cause) if path.broken?
|
|
155
|
+
rescue *RuleColumn::UNTELLABLE
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# An operator the Registry does not have.
|
|
160
|
+
#
|
|
161
|
+
# @param rule [PermissionForge::PermissionSetRule]
|
|
162
|
+
# @return [RuleProblem, nil]
|
|
163
|
+
def self.of_operator(rule)
|
|
164
|
+
operator = rule.operator.to_s
|
|
165
|
+
return if Registry.strategies.key?(operator)
|
|
166
|
+
|
|
167
|
+
new(kind: :unknown_operator, name: operator, model: RuleColumn.rule_model(rule))
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The rule's column problem, else its value problem — the column looked up once.
|
|
171
|
+
#
|
|
172
|
+
# @param rule [PermissionForge::PermissionSetRule]
|
|
173
|
+
# @return [RuleProblem, nil]
|
|
174
|
+
def self.of_column_or_value(rule)
|
|
175
|
+
column = RuleColumn.for_rule(rule)
|
|
176
|
+
of_column(rule, column) || of_value(rule, column)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# @param rule [PermissionForge::PermissionSetRule]
|
|
180
|
+
# @param column [RuleColumn, nil] the rule's column (RuleColumn.for_rule)
|
|
181
|
+
# @return [RuleProblem, nil]
|
|
182
|
+
def self.of_column(rule, column = RuleColumn.for_rule(rule))
|
|
183
|
+
return unless column
|
|
184
|
+
|
|
185
|
+
field_name = rule.field_name.to_s
|
|
186
|
+
return new(kind: :missing_column, name: field_name.split('.').last, model: column.model) if column.missing?
|
|
187
|
+
|
|
188
|
+
kind = column_kind(rule, column)
|
|
189
|
+
new(kind: kind, name: kind == :missing_column ? field_name : field_name.split('.')[-2], model: RuleColumn.rule_model(rule)) if kind
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The kind of a column problem named after the rule's own model: a
|
|
193
|
+
# name that is none, a qualifier naming no table, or — on a filter
|
|
194
|
+
# rule — another model's table.
|
|
195
|
+
def self.column_kind(rule, column)
|
|
196
|
+
if column.malformed? then :missing_column
|
|
197
|
+
elsif column.unresolvable? then :unknown_table
|
|
198
|
+
elsif column.foreign? && !rule.join_rule? then :foreign_table
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Why the rule's value is UNUSABLE for its column, as the applier types
|
|
203
|
+
# it (ValueTyping.problem; `current_user` as each of ANY_USER_IDS): an
|
|
204
|
+
# encrypted column, a LIKE operator on a column that is not text, or a
|
|
205
|
+
# value the column cannot hold. Only for a column that was found: the
|
|
206
|
+
# column's own problem comes first. The first two do not depend on the
|
|
207
|
+
# value; a blank value is no value problem (the presence validation's
|
|
208
|
+
# business; a rule being built has none yet).
|
|
209
|
+
#
|
|
210
|
+
# @param rule [PermissionForge::PermissionSetRule]
|
|
211
|
+
# @param column [RuleColumn, nil] the rule's column (RuleColumn.for_rule)
|
|
212
|
+
# @return [RuleProblem, nil]
|
|
213
|
+
def self.of_value(rule, column = RuleColumn.for_rule(rule))
|
|
214
|
+
return unless column_found?(column)
|
|
215
|
+
|
|
216
|
+
kind = value_kind(rule, column) or return
|
|
217
|
+
name = rule.field_name.to_s.split('.').last
|
|
218
|
+
model = column.model || RuleColumn.rule_model(rule)
|
|
219
|
+
return new(kind: kind, name: name, model: model, value: rule.field_value) if kind == :unusable_value
|
|
220
|
+
|
|
221
|
+
new(kind: kind, name: name, model: model, column_type: column.column&.sql_type)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def self.column_found?(column)
|
|
225
|
+
!column.nil? && !column.malformed? && !column.missing? && !column.unresolvable?
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# The kind every reading of the value gives (ValueTyping.problem), or nil.
|
|
229
|
+
def self.value_kind(rule, column)
|
|
230
|
+
operator = rule.operator.to_s
|
|
231
|
+
value = rule.field_value
|
|
232
|
+
return ValueTyping.operator_problem(operator, column) if value.blank?
|
|
233
|
+
|
|
234
|
+
values = ValueTyping.current_user?(operator, value) ? ANY_USER_IDS : [value]
|
|
235
|
+
kinds = values.map { |typed| ValueTyping.problem(operator, typed, column) }
|
|
236
|
+
kinds.first if kinds.all?
|
|
237
|
+
rescue *RuleColumn::UNTELLABLE
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
private_class_method :column_found?, :value_kind, :column_kind
|
|
241
|
+
|
|
242
|
+
# The rule's path and field name as stored (a filter rule's path is not joined).
|
|
243
|
+
#
|
|
244
|
+
# @param rule [PermissionForge::PermissionSetRule]
|
|
245
|
+
# @return [String] e.g. "ownr.owner_id"
|
|
246
|
+
def self.written_field(rule)
|
|
247
|
+
path = rule.field_accessor_path.presence if rule.join_rule?
|
|
248
|
+
[path, rule.field_name].compact.join('.')
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Whether the problem is the rule's association path (not its column,
|
|
252
|
+
# operator, join type or value).
|
|
253
|
+
def path? = PATH_KINDS.include?(kind)
|
|
254
|
+
|
|
255
|
+
# What the admin changes to correct it (REMEDIES): :join, :path,
|
|
256
|
+
# :operator, :field or :value.
|
|
257
|
+
#
|
|
258
|
+
# @return [Symbol]
|
|
259
|
+
def remedy = REMEDIES.fetch(kind)
|
|
260
|
+
|
|
261
|
+
# Whether `name` is shown as stored (`onwer_id`, `ownr`, `regex`,
|
|
262
|
+
# `inner`, `nosuch`): a name that is not there has no label — humanized
|
|
263
|
+
# it would read like a real field.
|
|
264
|
+
def verbatim? = VERBATIM_KINDS.include?(kind)
|
|
265
|
+
|
|
266
|
+
# The admin's sentence for it, under `ui.rules.problems`: the key, by
|
|
267
|
+
# cause where it has one; `_html` where the name or the value is drawn
|
|
268
|
+
# as stored (in code).
|
|
269
|
+
#
|
|
270
|
+
# @return [Symbol]
|
|
271
|
+
def ui_key
|
|
272
|
+
return key if cause
|
|
273
|
+
|
|
274
|
+
:"#{kind}#{'_html' if verbatim? || value}"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# What the problem is called in the locale files: the kind, keyed by
|
|
278
|
+
# its cause where it has one — :missing_column, :unknown_association,
|
|
279
|
+
# :empty_association, :unjoinable_polymorphic, ... (the admin's
|
|
280
|
+
# `ui.rules.problems.<key>`, the model's validation message).
|
|
281
|
+
#
|
|
282
|
+
# @return [Symbol]
|
|
283
|
+
def key
|
|
284
|
+
return :empty_association if cause == :empty_segment
|
|
285
|
+
return kind unless kind == :unjoinable_association && cause
|
|
286
|
+
|
|
287
|
+
:"unjoinable_#{cause}"
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# The `permission_forge.validations.permission_set_rule.*` key of the
|
|
291
|
+
# validation message: the key, or the message the attribute's own
|
|
292
|
+
# validator gives (an operator or join type that is not one).
|
|
293
|
+
#
|
|
294
|
+
# @return [Symbol]
|
|
295
|
+
def message_key
|
|
296
|
+
MESSAGE_KEYS.fetch(key, key)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The validation error PermissionSetRule adds for it, in the current locale.
|
|
300
|
+
#
|
|
301
|
+
# @return [String]
|
|
302
|
+
def validation_message
|
|
303
|
+
I18n.t("permission_forge.validations.permission_set_rule.#{message_key}",
|
|
304
|
+
association: name, name: name, model: model&.name, value: value, type: column_type,
|
|
305
|
+
default: RULE_PROBLEM_MESSAGES.fetch(message_key))
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Operators
|
|
5
|
+
# The names a rule's query gives its tables, as a field_name can be
|
|
6
|
+
# qualified with: the FROM table and each JOIN's table — its alias where
|
|
7
|
+
# ActiveRecord assigned one (a table joined twice), as the database then
|
|
8
|
+
# knows it by the alias alone. Read from the relation's Arel join
|
|
9
|
+
# sources, the aliases ActiveRecord actually assigns — never from the SQL
|
|
10
|
+
# text, in which a column of an ON clause (`"customers"."supplier_id"`)
|
|
11
|
+
# reads like a table. A join written as SQL (a host's `joins('INNER JOIN
|
|
12
|
+
# ...')`) gives the table or alias each of its JOINs names.
|
|
13
|
+
#
|
|
14
|
+
# RuleColumn.find compares a qualifier with them: the applier with its
|
|
15
|
+
# real query (the default scope's and the host's joins included), the
|
|
16
|
+
# model's validation and the audit with the rule's own join ({.of_join}).
|
|
17
|
+
# Each name comes with the table it stands for ({.tables}): an alias is
|
|
18
|
+
# resolved to the model it joins, so an encrypted column reached through
|
|
19
|
+
# one is seen (RuleColumn).
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# RuleSources.of(Customer.joins(:supplier)) # => ["customers", "suppliers"]
|
|
23
|
+
# RuleSources.tables(PfRulePathRecord.joins(:parent))
|
|
24
|
+
# # => { "pf_rule_path_records" => "pf_rule_path_records", "parents_pf_rule_path_records" => "pf_rule_path_records" }
|
|
25
|
+
#
|
|
26
|
+
# @api private
|
|
27
|
+
module RuleSources
|
|
28
|
+
# A word that ends a JOIN's table where an alias would stand.
|
|
29
|
+
JOIN_KEYWORDS = %w[ON USING INNER LEFT RIGHT FULL CROSS NATURAL JOIN OUTER WHERE LATERAL].freeze
|
|
30
|
+
# One identifier, bare or quoted the way any adapter quotes one.
|
|
31
|
+
IDENTIFIER = /"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*/
|
|
32
|
+
# A JOIN of SQL text: its table (possibly qualified) and its alias.
|
|
33
|
+
STRING_JOIN = /\bJOIN\s+(?<table>(?:#{IDENTIFIER})(?:\s*\.\s*(?:#{IDENTIFIER}))*)(?:\s+(?:AS\s+)?(?<alias>#{IDENTIFIER}))?/i
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# @param relation [ActiveRecord::Relation]
|
|
37
|
+
# @return [Array<String>]
|
|
38
|
+
def of(relation)
|
|
39
|
+
tables(relation).keys
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Each name the query gives a table, with the table it stands for (a
|
|
43
|
+
# table's own name stands for itself; nil when it cannot be told: a
|
|
44
|
+
# subquery's alias).
|
|
45
|
+
#
|
|
46
|
+
# @param relation [ActiveRecord::Relation]
|
|
47
|
+
# @return [Hash{String => String, nil}]
|
|
48
|
+
def tables(relation)
|
|
49
|
+
arel = relation.arel
|
|
50
|
+
[arel.source.left, *arel.join_sources].flat_map { |node| sources_of(node) }.uniq(&:first).to_h
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The names a rule's own join gives (its model's query, the rule's
|
|
54
|
+
# path joined as the applier joins it), with their tables ({.tables}).
|
|
55
|
+
#
|
|
56
|
+
# @param model [Class] the rule's model
|
|
57
|
+
# @param join_type [String] the rule's join type (an unknown one reads as `joins`)
|
|
58
|
+
# @param association_chain [Array<String>] the rule's path, split
|
|
59
|
+
# @return [Hash{String => String, nil}]
|
|
60
|
+
def of_join(model, join_type, association_chain)
|
|
61
|
+
join_method = Services::PermissionRuleApplier::JOIN_METHODS.fetch(join_type.to_s, :joins)
|
|
62
|
+
tables(RulePath.join(model.unscoped, join_method, association_chain))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# [[name, table]] for one node of the FROM or a JOIN.
|
|
68
|
+
def sources_of(node)
|
|
69
|
+
case node
|
|
70
|
+
when Arel::Nodes::StringJoin then string_join_sources(node.left.to_s)
|
|
71
|
+
when Arel::Nodes::Join then sources_of(node.left)
|
|
72
|
+
when Arel::Table then [[(node.table_alias || node.name).to_s, node.name.to_s]]
|
|
73
|
+
when Arel::Nodes::TableAlias then [[node.name.to_s, aliased_table(node.relation)]]
|
|
74
|
+
else []
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def aliased_table(relation)
|
|
79
|
+
relation.name.to_s if relation.is_a?(Arel::Table)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def string_join_sources(sql)
|
|
83
|
+
sql.scan(STRING_JOIN).map do |table, alias_name|
|
|
84
|
+
name = unquote(table.split('.').last.strip)
|
|
85
|
+
named = alias_name && !JOIN_KEYWORDS.include?(alias_name.upcase) ? unquote(alias_name.strip) : name
|
|
86
|
+
[named, name]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def unquote(identifier)
|
|
91
|
+
identifier.match?(/\A(["`\[]).*["`\]]\z/) ? identifier[1..-2] : identifier
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ipaddr'
|
|
4
|
+
require 'concurrent/map'
|
|
5
|
+
|
|
6
|
+
module PermissionForge
|
|
7
|
+
module Operators
|
|
8
|
+
module ValueCasters
|
|
9
|
+
# Casters for the column types only PostgreSQL has: `uuid`, `inet`,
|
|
10
|
+
# `cidr` and native enum types (`CREATE TYPE ... AS ENUM`). PostgreSQL
|
|
11
|
+
# raises on text it cannot read as such a type (`invalid input syntax
|
|
12
|
+
# for type uuid: "banana"`), so a value that is not one is UNUSABLE —
|
|
13
|
+
# the rule matches nothing — instead of failing every holder's query.
|
|
14
|
+
# The notations taken are narrower than PostgreSQL's own input, so that
|
|
15
|
+
# what is typed here is always read by it.
|
|
16
|
+
#
|
|
17
|
+
# None of these types has a LIKE operator in PostgreSQL ({.pattern?}).
|
|
18
|
+
module PostgresqlTypes
|
|
19
|
+
# The text of a UUID as RFC 4122 writes it: 32 hex digits in groups of
|
|
20
|
+
# 8-4-4-4-12, in either case. The version and variant digits are not
|
|
21
|
+
# looked at (the nil UUID and newer versions are UUIDs to PostgreSQL).
|
|
22
|
+
UUID_FORMAT = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
23
|
+
|
|
24
|
+
# An IPv4 or IPv6 address, then optionally a prefix length — the
|
|
25
|
+
# notation PostgreSQL's inet and cidr input share (no netmask, no zone).
|
|
26
|
+
NETWORK_FORMAT = %r{\A[\h:.]+(?:/\d{1,3})?\z}
|
|
27
|
+
|
|
28
|
+
# The other notations PostgreSQL's uuid input takes, each read as the
|
|
29
|
+
# 32 digits it holds: in braces, after `urn:uuid:`, without hyphens.
|
|
30
|
+
UUID_NOTATIONS = [/\A\{(?<digits>\h{8}-\h{4}-\h{4}-\h{4}-\h{12})\}\z/, /\Aurn:uuid:(?<digits>\h{8}-\h{4}-\h{4}-\h{4}-\h{12})\z/i,
|
|
31
|
+
/\A\{?(?<digits>\h{32})\}?\z/].freeze
|
|
32
|
+
|
|
33
|
+
# A UUID in lower case, in RFC 4122 notation; anything else — text
|
|
34
|
+
# that is no UUID, the Integer id `current_user` stands for — is UNUSABLE.
|
|
35
|
+
UUID = lambda do |value|
|
|
36
|
+
text = value.is_a?(String) ? value.strip : nil
|
|
37
|
+
next UNUSABLE unless text
|
|
38
|
+
next text.downcase if text.match?(UUID_FORMAT)
|
|
39
|
+
|
|
40
|
+
digits = UUID_NOTATIONS.lazy.filter_map { |notation| notation.match(text)&.[](:digits) }.first&.delete('-')&.downcase
|
|
41
|
+
digits ? [digits[0, 8], digits[8, 4], digits[12, 4], digits[16, 4], digits[20, 12]].join('-') : UNUSABLE
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# An address IPAddr reads, with an optional prefix length, as written.
|
|
45
|
+
INET = ->(value) { PostgresqlTypes.network(value) || UNUSABLE }
|
|
46
|
+
|
|
47
|
+
# A network: no bits set right of the prefix length (PostgreSQL
|
|
48
|
+
# raises on `10.0.0.5/24` for a cidr column).
|
|
49
|
+
CIDR = lambda do |value|
|
|
50
|
+
text = PostgresqlTypes.network(value)
|
|
51
|
+
next UNUSABLE unless text
|
|
52
|
+
|
|
53
|
+
address = text.split('/').first
|
|
54
|
+
IPAddr.new(text).to_i == IPAddr.new(address).to_i ? text : UNUSABLE
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The casters a LIKE pattern means nothing to.
|
|
58
|
+
PATTERNLESS = [UUID, INET, CIDR].freeze
|
|
59
|
+
|
|
60
|
+
# @param value [Object] a rule value
|
|
61
|
+
# @return [String, nil] the address as written, when IPAddr reads it
|
|
62
|
+
def self.network(value)
|
|
63
|
+
text = value.is_a?(String) ? value.strip : nil
|
|
64
|
+
return unless text&.match?(NETWORK_FORMAT)
|
|
65
|
+
|
|
66
|
+
IPAddr.new(text)
|
|
67
|
+
text
|
|
68
|
+
rescue IPAddr::Error
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A native enum type's caster: one of the type's labels, exactly as
|
|
73
|
+
# the database has it (surrounding spaces aside), else UNUSABLE. The
|
|
74
|
+
# labels are read from the database (PermissionForge::SchemaRead,
|
|
75
|
+
# read again once before a label is called missing: one added by a
|
|
76
|
+
# migration while the process runs). Without the model the labels
|
|
77
|
+
# cannot be read, and no value is usable.
|
|
78
|
+
class NativeEnum
|
|
79
|
+
# @param column [ActiveRecord::ConnectionAdapters::Column] a column of a native enum type
|
|
80
|
+
# @param model [Class, nil] the model whose table the column is in
|
|
81
|
+
# @return [NativeEnum]
|
|
82
|
+
def self.of(column, model)
|
|
83
|
+
new(column.sql_type, model)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @param type [String] the column's SQL type: the enum type's name
|
|
87
|
+
# @param model [Class, nil]
|
|
88
|
+
def initialize(type, model)
|
|
89
|
+
@type = type
|
|
90
|
+
@model = model
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def call(value)
|
|
94
|
+
return UNUSABLE unless @model && value.is_a?(String)
|
|
95
|
+
|
|
96
|
+
[value, value.strip].uniq.find { |label| SchemaRead.enum_label?(@model, @type, label) } || UNUSABLE
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Text PostgreSQL reads itself: a column type no caster here knows
|
|
101
|
+
# (hstore, money, interval, macaddr, bit, ...) and date and time
|
|
102
|
+
# text in a notation Temporal does not recognise (`today`, `31 Jan
|
|
103
|
+
# 2026`). PostgreSQL raises on text it cannot read as the column's
|
|
104
|
+
# type — for every holder, on every query — so the text is asked of
|
|
105
|
+
# PostgreSQL once (a CAST in a savepoint) and the answer kept for the
|
|
106
|
+
# process and database: what it reads is handed over as written,
|
|
107
|
+
# anything else is UNUSABLE and the rule matches nothing. The caster
|
|
108
|
+
# wrapped, when there is one, is tried first (a recognised date costs
|
|
109
|
+
# no query).
|
|
110
|
+
#
|
|
111
|
+
# Only a type PostgreSQL can compare is read back: one with a default
|
|
112
|
+
# btree operator class whose family has `=` and `<`
|
|
113
|
+
# (SchemaRead.ordered_type?). Reading a value is not comparing it —
|
|
114
|
+
# `CAST('<a/>' AS xml)` succeeds, but xml, point, polygon and path
|
|
115
|
+
# have no `=`, hstore no `<` — so a value of any other type is
|
|
116
|
+
# UNUSABLE (the null checks take none and still work).
|
|
117
|
+
#
|
|
118
|
+
# Only a refusal of the value itself is kept: an error of SQLSTATE
|
|
119
|
+
# class 22 (data exception: invalid input). Any other error — a
|
|
120
|
+
# statement or lock timeout, a lost connection, an aborted
|
|
121
|
+
# transaction — denies this read only and is asked again next time.
|
|
122
|
+
class ReadBack
|
|
123
|
+
# Answers kept per process before the memo starts over.
|
|
124
|
+
LIMIT = 4096
|
|
125
|
+
|
|
126
|
+
# Builds the caster for one column (ValueCasters.for calls `of`).
|
|
127
|
+
Factory = Struct.new(:inner) do
|
|
128
|
+
# @param column [ActiveRecord::ConnectionAdapters::Column]
|
|
129
|
+
# @param model [Class, nil]
|
|
130
|
+
# @return [ReadBack]
|
|
131
|
+
def of(column, model) = ReadBack.new(inner, column.sql_type, model)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
@answers = Concurrent::Map.new
|
|
135
|
+
|
|
136
|
+
class << self
|
|
137
|
+
# @param inner [#call, nil] the caster tried first
|
|
138
|
+
# @return [Factory]
|
|
139
|
+
def wrapping(inner) = Factory.new(inner)
|
|
140
|
+
|
|
141
|
+
# Whether PostgreSQL reads `text` as a value of `sql_type`; asked
|
|
142
|
+
# once per process, database and type.
|
|
143
|
+
#
|
|
144
|
+
# @param model [Class] a model on the connection
|
|
145
|
+
# @param sql_type [String] the column's SQL type, from the schema
|
|
146
|
+
# @param text [String]
|
|
147
|
+
# @return [Boolean]
|
|
148
|
+
def reads?(model, sql_type, text)
|
|
149
|
+
key = [model.connection_db_config.name, sql_type, text]
|
|
150
|
+
known = @answers[key]
|
|
151
|
+
return known unless known.nil?
|
|
152
|
+
|
|
153
|
+
answer = cast(model, sql_type, text)
|
|
154
|
+
return false if answer.nil?
|
|
155
|
+
|
|
156
|
+
@answers.clear if @answers.size >= LIMIT
|
|
157
|
+
@answers[key] = answer
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @param value [Object] a rule value
|
|
161
|
+
# @param numbers [Boolean] whether a number is asked as its text
|
|
162
|
+
# @return [String, nil] the text PostgreSQL is asked to read
|
|
163
|
+
def text_of(value, numbers:)
|
|
164
|
+
return value if value.is_a?(String)
|
|
165
|
+
|
|
166
|
+
value.to_s if numbers && value.is_a?(Numeric)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Whether the error is PostgreSQL refusing the value itself
|
|
170
|
+
# (SQLSTATE class 22, PG::DataException and its subclasses).
|
|
171
|
+
#
|
|
172
|
+
# @param error [ActiveRecord::StatementInvalid]
|
|
173
|
+
# @return [Boolean]
|
|
174
|
+
def refused_value?(error)
|
|
175
|
+
defined?(::PG::DataException) ? error.cause.is_a?(::PG::DataException) : false
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Forgets the answers (tests).
|
|
179
|
+
#
|
|
180
|
+
# @return [void]
|
|
181
|
+
def reset!
|
|
182
|
+
@answers.clear
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
# true, false (the value refused: kept), or nil (any other
|
|
188
|
+
# failure: not kept). A failed statement aborts a PostgreSQL
|
|
189
|
+
# transaction: the CAST runs in a savepoint of its own.
|
|
190
|
+
def cast(model, sql_type, text)
|
|
191
|
+
model.connection_pool.with_connection do |connection|
|
|
192
|
+
connection.transaction(requires_new: true) do
|
|
193
|
+
connection.select_value("SELECT CAST(#{connection.quote(text)} AS #{sql_type})")
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
true
|
|
197
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
198
|
+
refused_value?(e) ? false : nil
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# @param inner [#call, nil]
|
|
203
|
+
# @param sql_type [String]
|
|
204
|
+
# @param model [Class, nil] without one PostgreSQL cannot be asked: UNUSABLE
|
|
205
|
+
def initialize(inner, sql_type, model)
|
|
206
|
+
@inner = inner
|
|
207
|
+
@sql_type = sql_type
|
|
208
|
+
@model = model
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def call(value)
|
|
212
|
+
typed = @inner ? @inner.call(value) : UNUSABLE
|
|
213
|
+
typed.equal?(UNUSABLE) ? read_back(value) : typed
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
# Unless a caster is wrapped (what it refuses as no text stays
|
|
219
|
+
# refused), a number — the id `current_user` stands for — is asked as its text.
|
|
220
|
+
def read_back(value)
|
|
221
|
+
text = ReadBack.text_of(value, numbers: @inner.nil?)
|
|
222
|
+
return UNUSABLE unless text && @model && SchemaRead.ordered_type?(@model, @sql_type)
|
|
223
|
+
|
|
224
|
+
ReadBack.reads?(@model, @sql_type, text) ? value : UNUSABLE
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# @param caster [#call] a caster of ValueCasters
|
|
229
|
+
# @return [Boolean] whether a LIKE pattern can be matched against a
|
|
230
|
+
# column of the caster's type
|
|
231
|
+
def self.pattern?(caster)
|
|
232
|
+
PATTERNLESS.none? { |patternless| patternless.equal?(caster) } && !caster.is_a?(NativeEnum)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|