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
|
@@ -8,6 +8,14 @@ module PermissionForge
|
|
|
8
8
|
# They do NOT constitute GDPR-compliant anonymization or pseudonymization.
|
|
9
9
|
# See docs/gdpr_masking_disclaimer.md for details.
|
|
10
10
|
#
|
|
11
|
+
# A strategy name this app does not know — a field row written past the
|
|
12
|
+
# model, a custom strategy since removed from `config.custom_mask_strategies`,
|
|
13
|
+
# a blank — never raises on a read path: {.call} masks fully
|
|
14
|
+
# ({FALLBACK_STRATEGY}) and reports the name once per process (a warning
|
|
15
|
+
# and the {UNKNOWN_STRATEGY_EVENT} notification); `rake permission_forge:audit`
|
|
16
|
+
# lists the rows (`unknown_mask_strategies`). {.call!} is the strict form
|
|
17
|
+
# for the admin preview and the validation.
|
|
18
|
+
#
|
|
11
19
|
# @api public
|
|
12
20
|
class MaskValue
|
|
13
21
|
BUILT_IN_STRATEGIES = {
|
|
@@ -19,42 +27,95 @@ module PermissionForge
|
|
|
19
27
|
|
|
20
28
|
STRATEGIES = BUILT_IN_STRATEGIES.keys.freeze
|
|
21
29
|
MASK_CHAR = '*'.freeze
|
|
30
|
+
# The strategy applied in place of one this app does not know: the strictest built-in.
|
|
31
|
+
FALLBACK_STRATEGY = 'full'.freeze
|
|
32
|
+
# Published once per process for each strategy name this app does not know
|
|
33
|
+
# (payload: `strategy`, the name as stored; `fallback`, what was applied).
|
|
34
|
+
UNKNOWN_STRATEGY_EVENT = 'mask_strategy_unknown.permission_forge'.freeze
|
|
35
|
+
|
|
36
|
+
REPORTED_MUTEX = Mutex.new
|
|
37
|
+
@reported = Set.new
|
|
22
38
|
|
|
23
39
|
# @return [Hash{String => #call}] the built-in strategies merged with `config.custom_mask_strategies`
|
|
24
40
|
def self.strategies
|
|
25
41
|
custom = PermissionForge.configuration.custom_mask_strategies
|
|
26
42
|
return BUILT_IN_STRATEGIES if custom.blank?
|
|
27
43
|
|
|
28
|
-
BUILT_IN_STRATEGIES.merge(custom)
|
|
44
|
+
BUILT_IN_STRATEGIES.merge(custom.transform_keys(&:to_s))
|
|
29
45
|
end
|
|
30
46
|
|
|
31
|
-
#
|
|
47
|
+
# Whether `strategy` names a built-in or configured custom strategy.
|
|
48
|
+
# @param strategy [String, Symbol, nil]
|
|
49
|
+
# @return [Boolean]
|
|
50
|
+
def self.known?(strategy)
|
|
51
|
+
!strategy.nil? && strategies.key?(strategy.to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Masks a value with a built-in or custom strategy. A strategy this app
|
|
55
|
+
# does not know masks fully and is reported once per process per name:
|
|
56
|
+
# a read never raises.
|
|
32
57
|
# @param value [Object]
|
|
33
58
|
# @param strategy [String] `full`, `email`, `last4`, `first1_last4` or a custom one
|
|
34
59
|
# @return [Object] the masked value (nil stays nil)
|
|
35
|
-
def self.call(value, strategy:
|
|
60
|
+
def self.call(value, strategy: FALLBACK_STRATEGY)
|
|
36
61
|
return value if value.nil?
|
|
37
62
|
|
|
38
63
|
str = value.to_s
|
|
39
64
|
return str if str.empty?
|
|
40
65
|
|
|
41
|
-
new(str, strategy).call
|
|
66
|
+
new(str, resolve(strategy)).call
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Masks a value, strictly: a strategy this app does not know raises (the
|
|
70
|
+
# admin preview, the validation — never a read path).
|
|
71
|
+
# @param value [Object]
|
|
72
|
+
# @param strategy [String]
|
|
73
|
+
# @return [Object] the masked value (nil stays nil)
|
|
74
|
+
# @raise [ArgumentError] for a strategy this app does not know
|
|
75
|
+
def self.call!(value, strategy: FALLBACK_STRATEGY)
|
|
76
|
+
raise ArgumentError, unknown_message(strategy) unless known?(strategy)
|
|
77
|
+
|
|
78
|
+
call(value, strategy: strategy)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Forgets which unknown strategies were reported (for tests).
|
|
82
|
+
# @return [void]
|
|
83
|
+
def self.reset_reports!
|
|
84
|
+
REPORTED_MUTEX.synchronize { @reported.clear }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The strategy to apply: the name when this app knows it, else the
|
|
88
|
+
# fallback, the name reported once.
|
|
89
|
+
def self.resolve(strategy)
|
|
90
|
+
return strategy.to_s if known?(strategy)
|
|
91
|
+
|
|
92
|
+
report_unknown(strategy.to_s)
|
|
93
|
+
FALLBACK_STRATEGY
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.report_unknown(name)
|
|
97
|
+
return unless REPORTED_MUTEX.synchronize { @reported.add?(name) }
|
|
98
|
+
|
|
99
|
+
Rails.logger&.warn("[PermissionForge] #{unknown_message(name)} Values are masked fully until the field row or " \
|
|
100
|
+
'config.custom_mask_strategies is fixed (rake permission_forge:audit lists the rows).')
|
|
101
|
+
ActiveSupport::Notifications.instrument(UNKNOWN_STRATEGY_EVENT, strategy: name, fallback: FALLBACK_STRATEGY)
|
|
42
102
|
end
|
|
43
103
|
|
|
104
|
+
def self.unknown_message(strategy)
|
|
105
|
+
"Unknown mask strategy: #{strategy.inspect}. Valid strategies: #{strategies.keys.join(', ')}."
|
|
106
|
+
end
|
|
107
|
+
private_class_method :resolve, :report_unknown, :unknown_message
|
|
108
|
+
|
|
109
|
+
# @param value [String] a non-empty String
|
|
110
|
+
# @param strategy [String] a known strategy (see .resolve)
|
|
44
111
|
def initialize(value, strategy)
|
|
45
112
|
@value = value
|
|
46
113
|
@strategy = strategy
|
|
47
|
-
all_strategies = self.class.strategies
|
|
48
|
-
return if all_strategies.key?(@strategy)
|
|
49
|
-
|
|
50
|
-
raise ArgumentError,
|
|
51
|
-
"Unknown mask strategy: #{strategy.inspect}. Valid strategies: #{all_strategies.keys.join(', ')}"
|
|
52
114
|
end
|
|
53
115
|
|
|
54
116
|
# @return [Object] the masked value
|
|
55
117
|
def call
|
|
56
|
-
|
|
57
|
-
strategy_ref = all_strategies[@strategy]
|
|
118
|
+
strategy_ref = self.class.strategies[@strategy]
|
|
58
119
|
|
|
59
120
|
if strategy_ref.is_a?(Symbol)
|
|
60
121
|
send(strategy_ref)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../cache/report'
|
|
4
|
+
|
|
3
5
|
module PermissionForge
|
|
4
6
|
module Services
|
|
5
7
|
# Removes every cached permission hash — this version's entries, the
|
|
@@ -34,17 +36,22 @@ module PermissionForge
|
|
|
34
36
|
#
|
|
35
37
|
# @example
|
|
36
38
|
# result = PermissionCacheClearer.call(actor: 'ops: TCK-12', reason: 'deploy 0.14')
|
|
37
|
-
# # => { strategy: :enumerated, users: 240, tenants: 2, unconfirmed: 0
|
|
38
|
-
# #
|
|
39
|
+
# # => { strategy: :enumerated, users: 240, tenants: 2, unconfirmed: 0,
|
|
40
|
+
# # cache: { confirmed: true, process_local: false, unconfirmed: 0 } }
|
|
41
|
+
# # => { strategy: :pattern, users: nil, tenants: 2, unconfirmed: 0, cache: { ... } }
|
|
39
42
|
#
|
|
40
43
|
# `users` counts visits (a user once per tenant); `unconfirmed` the ones
|
|
41
44
|
# the cache store did not confirm (each also reported through
|
|
42
|
-
# `config.on_cache_failure`, and owed)
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
45
|
+
# `config.on_cache_failure`, and owed); `cache` is the one Cache::Outcome
|
|
46
|
+
# every cache-touching operation answers (Cache::Report). The audit log:
|
|
47
|
+
# one `cache.cleared` event per tenant, in that tenant with that tenant's
|
|
48
|
+
# counts, plus one without a tenant with the totals (`across_tenants`);
|
|
49
|
+
# under :pattern the counts are not known and the events say
|
|
50
|
+
# `counted: false`. Every event carries the outcome's keys, and says
|
|
51
|
+
# `process_local: true` when the store lives in this process only (a
|
|
52
|
+
# memory store, Cache::Base#process_local?): the clear then did not reach
|
|
53
|
+
# the web and job processes, and the audit log says so. `actor:` and
|
|
54
|
+
# `reason:` as the other housekeeping services (rake: ACTOR=, REASON=).
|
|
48
55
|
#
|
|
49
56
|
# @api public
|
|
50
57
|
class PermissionCacheClearer
|
|
@@ -57,7 +64,7 @@ module PermissionForge
|
|
|
57
64
|
#
|
|
58
65
|
# @param actor [Object, nil] who runs it, for the cache.cleared events
|
|
59
66
|
# @param reason [String, nil] why
|
|
60
|
-
# @return [Hash] strategy:, users:, tenants:, unconfirmed:
|
|
67
|
+
# @return [Hash] strategy:, users:, tenants:, unconfirmed:, cache:
|
|
61
68
|
def self.call(...) = new(...).call
|
|
62
69
|
|
|
63
70
|
def initialize(actor: nil, reason: nil)
|
|
@@ -65,18 +72,21 @@ module PermissionForge
|
|
|
65
72
|
@reason = reason
|
|
66
73
|
end
|
|
67
74
|
|
|
68
|
-
# @return [Hash] strategy:, users:, tenants:, unconfirmed:
|
|
75
|
+
# @return [Hash] strategy:, users:, tenants:, unconfirmed:, cache:
|
|
69
76
|
def call
|
|
70
77
|
audited(actor: @actor, reason: @reason) { clear }
|
|
71
78
|
end
|
|
72
79
|
|
|
73
80
|
private
|
|
74
81
|
|
|
82
|
+
# The whole clear is one measured operation (Cache::Report); the
|
|
83
|
+
# unconfirmed count is this service's own (by slice).
|
|
75
84
|
def clear
|
|
76
|
-
|
|
85
|
+
@report = Cache::Report.new
|
|
86
|
+
result = @report.measure { clear_by_pattern || clear_by_enumeration }.value
|
|
77
87
|
# retire every memo in this process, as an invalidation does
|
|
78
88
|
PermissionForge.bump_permissions_cache_version!
|
|
79
|
-
result
|
|
89
|
+
result.merge(cache: @report.outcome(unconfirmed: result[:unconfirmed]).to_h)
|
|
80
90
|
end
|
|
81
91
|
|
|
82
92
|
# @return [Hash, nil] nil when the store cannot remove by pattern
|
|
@@ -160,9 +170,20 @@ module PermissionForge
|
|
|
160
170
|
defined?(ActsAsTenant) ? ActsAsTenant.without_tenant(&) : yield
|
|
161
171
|
end
|
|
162
172
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
173
|
+
# With the outcome of its rows (Cache::Outcome#event_data:
|
|
174
|
+
# `process_local` says the store lives in this process only, so the
|
|
175
|
+
# clear did not reach the web and job processes).
|
|
176
|
+
def record(tenant_id, **counts)
|
|
177
|
+
PermissionForge::Audit.record(EVENT, tenant_id: tenant_id, data: event_data(**counts))
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def event_data(unconfirmed:, **counts)
|
|
181
|
+
{ 'operation' => 'cache_clear', **counts.transform_keys(&:to_s), **outcome_data(unconfirmed) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The outcome's keys, `unconfirmed` among them.
|
|
185
|
+
def outcome_data(unconfirmed)
|
|
186
|
+
@report.outcome(unconfirmed: unconfirmed).event_data
|
|
166
187
|
end
|
|
167
188
|
|
|
168
189
|
# The event without a tenant — recorded with none ambient, so it is one.
|
|
@@ -28,10 +28,12 @@ module PermissionForge
|
|
|
28
28
|
|
|
29
29
|
# Resources and their rows in id order, in place. The database returns
|
|
30
30
|
# them in whatever order its plan gives, and order decides: the first row
|
|
31
|
-
# of a name wins a tie (two fields mapping to
|
|
32
|
-
# mask_strategy wins"
|
|
33
|
-
#
|
|
34
|
-
#
|
|
31
|
+
# of a name wins a tie between derived variants (two fields mapping to
|
|
32
|
+
# one name — "first mask_strategy wins"; an explicit row for the name
|
|
33
|
+
# decides it whatever the order, FieldPermissionMerger.targets_of), and
|
|
34
|
+
# rules and actions keep it in the hash. The one definition:
|
|
35
|
+
# Admin::EffectiveAccess sorts the rows it shows with it. An unsaved row
|
|
36
|
+
# (no id yet) sorts first.
|
|
35
37
|
def self.sort_rows(permission_set)
|
|
36
38
|
resources = sorted(permission_set.association(:resources))
|
|
37
39
|
resources.each { |resource| RESOURCE_ROWS.each { |rows| sorted(resource.association(rows)) } }
|
|
@@ -42,6 +44,23 @@ module PermissionForge
|
|
|
42
44
|
end
|
|
43
45
|
private_class_method :sorted
|
|
44
46
|
|
|
47
|
+
# The flags a granting Resource row grants, each only with the
|
|
48
|
+
# permission it depends on (ValidatesDependency: edit and delete need
|
|
49
|
+
# read, Modify All needs View All) — a row written past the validation
|
|
50
|
+
# grants no more than the dependency allows, so `can_edit?` is never
|
|
51
|
+
# true where `can_read?` is false. The one definition; the admin's
|
|
52
|
+
# effective-access view follows it.
|
|
53
|
+
#
|
|
54
|
+
# @param resource [Resource, #read_access]
|
|
55
|
+
# @return [Hash{Symbol => Boolean}] RESOURCE_PERMISSION_OPTIONS
|
|
56
|
+
def self.granted_flags(resource)
|
|
57
|
+
read = resource.read_access == true
|
|
58
|
+
view_all = resource.view_all == true
|
|
59
|
+
{ read_access: read, create_access: resource.create_access == true,
|
|
60
|
+
edit_access: read && resource.edit_access == true, delete_access: read && resource.delete_access == true,
|
|
61
|
+
view_all: view_all, modify_all: view_all && resource.modify_all == true }
|
|
62
|
+
end
|
|
63
|
+
|
|
45
64
|
# Builds a user's permission hash from their permission sets.
|
|
46
65
|
# @param user [Object]
|
|
47
66
|
# @param permission_sets [Enumerable<PermissionSet>]
|
|
@@ -123,7 +142,7 @@ module PermissionForge
|
|
|
123
142
|
|
|
124
143
|
add_source_permission_set(resource_data[:source_permission_sets], permission_set)
|
|
125
144
|
merge_resource_permissions(resource_data[:permissions], resource)
|
|
126
|
-
FieldPermissionMerger.call(fields: resource_data[:fields], resource: resource)
|
|
145
|
+
FieldPermissionMerger.call(fields: resource_data[:fields], resource: resource, variants: field_variants(resource.name))
|
|
127
146
|
merge_resource_actions(resource_data[:actions], resource)
|
|
128
147
|
merge_permission_set_rules(resource_data[:permission_set_rules], resource)
|
|
129
148
|
end
|
|
@@ -172,8 +191,8 @@ module PermissionForge
|
|
|
172
191
|
end
|
|
173
192
|
|
|
174
193
|
def merge_resource_permissions(permissions, resource)
|
|
175
|
-
|
|
176
|
-
permissions[action] ||=
|
|
194
|
+
self.class.granted_flags(resource).each do |action, granted|
|
|
195
|
+
permissions[action] ||= granted
|
|
177
196
|
end
|
|
178
197
|
end
|
|
179
198
|
|
|
@@ -216,30 +235,34 @@ module PermissionForge
|
|
|
216
235
|
# row naming a subclass reaches that subclass alone.
|
|
217
236
|
def apply_muting_permission_set(data, permission_set)
|
|
218
237
|
permission_set.resources.select(&:is_active).each do |resource|
|
|
219
|
-
entries_under(data, resource.name).each do |
|
|
220
|
-
apply_muting_resource(
|
|
238
|
+
entries_under(data, resource.name).each do |entry|
|
|
239
|
+
apply_muting_resource(entry, permission_set, resource)
|
|
221
240
|
end
|
|
222
241
|
end
|
|
223
242
|
end
|
|
224
243
|
|
|
225
|
-
#
|
|
226
|
-
# each entry's chain is worked out once per build.
|
|
244
|
+
# [entry name, entry] for the entries whose chain includes the resource
|
|
245
|
+
# name, in hash order; each entry's chain is worked out once per build.
|
|
227
246
|
def entries_under(data, name)
|
|
228
247
|
muted = name.to_sym
|
|
229
248
|
@entry_chains ||= {}
|
|
230
|
-
data[:resources].
|
|
231
|
-
|
|
232
|
-
resource_data if chain.include?(muted)
|
|
249
|
+
data[:resources].select do |entry_name, _|
|
|
250
|
+
(@entry_chains[entry_name] ||= PermissionForge::ResourceChain.names_for(entry_name)).include?(muted)
|
|
233
251
|
end
|
|
234
252
|
end
|
|
235
253
|
|
|
236
254
|
# The set is listed once per entry: one with rows for a base and its
|
|
237
|
-
# subclass reaches the subclass entry twice.
|
|
238
|
-
|
|
255
|
+
# subclass reaches the subclass entry twice. `entry`: [entry name,
|
|
256
|
+
# entry]; the variants muted are the entry's own (FieldVariants of the
|
|
257
|
+
# entry's model, not of the muting row's Resource): a base row reaching
|
|
258
|
+
# a subclass entry mutes the variants the subclass has, as the granting
|
|
259
|
+
# phase wrote them.
|
|
260
|
+
def apply_muting_resource(entry, permission_set, resource)
|
|
261
|
+
entry_name, resource_data = entry
|
|
239
262
|
sources = resource_data[:source_permission_sets]
|
|
240
263
|
add_source_permission_set(sources, permission_set) if sources.none? { |source| source[:uuid] == permission_set.uuid }
|
|
241
264
|
mute_resource_permissions(resource_data[:permissions], resource)
|
|
242
|
-
mute_field_permissions(resource_data[:fields], resource)
|
|
265
|
+
mute_field_permissions(resource_data[:fields], resource, field_variants(entry_name))
|
|
243
266
|
mute_fields_of_resource(resource_data[:fields], resource)
|
|
244
267
|
mute_resource_actions(resource_data[:actions], resource)
|
|
245
268
|
end
|
|
@@ -267,7 +290,7 @@ module PermissionForge
|
|
|
267
290
|
end
|
|
268
291
|
end
|
|
269
292
|
|
|
270
|
-
def mute_field_permissions(fields, resource)
|
|
293
|
+
def mute_field_permissions(fields, resource, variants)
|
|
271
294
|
resource.resource_fields.select(&:is_active).each do |field|
|
|
272
295
|
next unless field.is_active
|
|
273
296
|
|
|
@@ -275,12 +298,18 @@ module PermissionForge
|
|
|
275
298
|
# _cents, etc.), matching the variants the granting phase writes — otherwise
|
|
276
299
|
# a muting set that revokes `user_id` would leave `user_uuid` readable.
|
|
277
300
|
mute_single_field(fields, field.name, field)
|
|
278
|
-
|
|
301
|
+
variants.call(field.name).each do |variant|
|
|
279
302
|
mute_single_field(fields, variant, field)
|
|
280
303
|
end
|
|
281
304
|
end
|
|
282
305
|
end
|
|
283
306
|
|
|
307
|
+
# The field variants of an entry's model (FieldVariants), one lookup
|
|
308
|
+
# per Resource name for the whole build.
|
|
309
|
+
def field_variants(name)
|
|
310
|
+
(@field_variants ||= {})[name.to_s] ||= PermissionForge::FieldVariants.new(name.to_s)
|
|
311
|
+
end
|
|
312
|
+
|
|
284
313
|
# A row that mutes read mutes edit too: a field nobody may read is not
|
|
285
314
|
# one they may write (the admin UI's Hide writes both flags; the API
|
|
286
315
|
# and code can write read alone, which left the field writable through
|
|
@@ -8,12 +8,22 @@ module PermissionForge
|
|
|
8
8
|
# a malformed field_name can never reach SQL as a literal even if a rule was
|
|
9
9
|
# created through a path that bypasses model validation.
|
|
10
10
|
#
|
|
11
|
+
# A stored rule never raises here. A value of a rule row the applier
|
|
12
|
+
# cannot interpret — a join type or operator this version does not know,
|
|
13
|
+
# a path that cannot be followed, a field name that is not a name or
|
|
14
|
+
# names a column its table does not have or a table that is not there, a
|
|
15
|
+
# value the column cannot hold — makes the rule match nothing: every
|
|
16
|
+
# holder is denied every record of the resource, never handed an error
|
|
17
|
+
# (a 500 for every holder, on every query under auto_query_scoping). The
|
|
18
|
+
# same reading describes such a rule to the admin and the audit
|
|
19
|
+
# (Operators::RuleProblem).
|
|
20
|
+
#
|
|
11
21
|
# @api private
|
|
12
22
|
class PermissionRuleApplier
|
|
13
23
|
# join_type => the relation method that makes the join. The model
|
|
14
24
|
# validates join_type, but a rule row can be written past the model (the
|
|
15
25
|
# tables are shared with other applications): the value is looked up
|
|
16
|
-
# here, never sent to the relation, and anything else
|
|
26
|
+
# here, never sent to the relation, and anything else matches nothing.
|
|
17
27
|
JOIN_METHODS = {
|
|
18
28
|
PermissionForge::Operators::Constants::JOINS => :joins,
|
|
19
29
|
PermissionForge::Operators::Constants::LEFT_JOINS => :left_joins,
|
|
@@ -49,7 +59,30 @@ module PermissionForge
|
|
|
49
59
|
@operator_registry = operator_registry
|
|
50
60
|
end
|
|
51
61
|
|
|
52
|
-
#
|
|
62
|
+
# The query, the denial's messages (`[Forbidden by Rule: <name>] with
|
|
63
|
+
# message: <failure_message>`, one per denying rule) and the denying
|
|
64
|
+
# rules (the rule hashes themselves, in the same order); both empty when
|
|
65
|
+
# nothing denied.
|
|
66
|
+
#
|
|
67
|
+
# What a rule says when it denies: its `failure_message` as an admin
|
|
68
|
+
# wrote it, or — for a rule left without one, or holding the install
|
|
69
|
+
# migration's English default (PermissionForge::GeneratedText: a rule
|
|
70
|
+
# created before 0.15 without a message) — the gem's own sentence in
|
|
71
|
+
# the current locale (permission_forge.denials.rule_default). The one
|
|
72
|
+
# definition: NotAuthorizedError#failure_messages reads it too. The
|
|
73
|
+
# permission hash keeps the stored text (it is cached per user, not per
|
|
74
|
+
# locale): the reading happens here, at denial time.
|
|
75
|
+
#
|
|
76
|
+
# @param rule [Hash] a rule hash of the permission hash
|
|
77
|
+
# @return [String]
|
|
78
|
+
def self.failure_message_for(rule)
|
|
79
|
+
message = rule[:failure_message].to_s.strip
|
|
80
|
+
return message unless message.empty? || PermissionForge::GeneratedText.failure_message?(message)
|
|
81
|
+
|
|
82
|
+
I18n.t('permission_forge.denials.rule_default')
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [Array(ActiveRecord::Relation, Array<String>, Array<Hash>)]
|
|
53
86
|
def call
|
|
54
87
|
report_no_rules if @rules.empty?
|
|
55
88
|
query = @rules.reduce(@model_class.where(nil)) { |q, rule| build_rule_query(q, rule) }
|
|
@@ -57,11 +90,12 @@ module PermissionForge
|
|
|
57
90
|
# Happy path is a single existence check: rule constraints are monotonic
|
|
58
91
|
# (each only removes rows), so if the fully-constrained query still matches
|
|
59
92
|
# the target, no individual rule denied it.
|
|
60
|
-
return [query, []] if @rules.empty? || query
|
|
93
|
+
return [query, [], []] if @rules.empty? || matches?(query)
|
|
61
94
|
|
|
62
|
-
# Denial path only: attribute the denial to
|
|
63
|
-
#
|
|
64
|
-
|
|
95
|
+
# Denial path only: attribute the denial to the rules that deny the
|
|
96
|
+
# record (one query per rule, solely when access is denied).
|
|
97
|
+
denying = denying_rules
|
|
98
|
+
[query, denying.map { |rule| rule_failure_message(rule) }, denying]
|
|
65
99
|
end
|
|
66
100
|
|
|
67
101
|
private
|
|
@@ -73,89 +107,96 @@ module PermissionForge
|
|
|
73
107
|
end
|
|
74
108
|
|
|
75
109
|
def rule_failure_message(rule)
|
|
76
|
-
"[Forbidden by Rule: #{rule[:name]}] with message: #{rule
|
|
110
|
+
"[Forbidden by Rule: #{rule[:name]}] with message: #{self.class.failure_message_for(rule)}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The rules that deny the record each on its own: the model's relation,
|
|
114
|
+
# that one rule and the args — rules combine with AND, so a rule that
|
|
115
|
+
# keeps the record on its own did not deny it, whatever the others do.
|
|
116
|
+
# In rule order. Should no single rule deny (an interplay with a default
|
|
117
|
+
# scope, say), the cumulative attribution — the first rule whose
|
|
118
|
+
# addition removes the record, and every rule after it — so a denial
|
|
119
|
+
# never names no rule.
|
|
120
|
+
def denying_rules
|
|
121
|
+
denying = @rules.reject { |rule| matches?(build_rule_query(@model_class.where(nil), rule)) }
|
|
122
|
+
denying.empty? ? cumulatively_denying_rules : denying
|
|
77
123
|
end
|
|
78
124
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
# after it, matching the original cumulative semantics).
|
|
82
|
-
def denial_messages
|
|
83
|
-
messages = []
|
|
125
|
+
def cumulatively_denying_rules
|
|
126
|
+
denying = []
|
|
84
127
|
@rules.reduce(@model_class.where(nil)) do |q, rule|
|
|
85
128
|
next_query = build_rule_query(q, rule)
|
|
86
|
-
|
|
129
|
+
denying << rule if denying.any? || !matches?(next_query)
|
|
87
130
|
next_query
|
|
88
131
|
end
|
|
89
|
-
|
|
132
|
+
denying
|
|
90
133
|
end
|
|
91
134
|
|
|
135
|
+
def matches?(query)
|
|
136
|
+
query.where(*@args).exists?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A join type that is none of JOIN_METHODS (written past the model, or
|
|
140
|
+
# by a newer version) matches nothing: it is never sent to the relation.
|
|
141
|
+
# Neither is a field_name that is not a name (RuleColumn.name?): quoting
|
|
142
|
+
# one with a NUL byte raises on PostgreSQL.
|
|
92
143
|
def build_rule_query(query, rule)
|
|
144
|
+
return query.none unless PermissionForge::Operators::RuleColumn.name?(rule[:field_name])
|
|
145
|
+
|
|
93
146
|
join_type = rule[:join_type].to_s
|
|
94
147
|
return apply_filter(query, rule) if PermissionForge::PermissionSetRule.filter_join_type?(join_type)
|
|
95
148
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
# @raise [PermissionForge::ConfigurationError] for a join_type that is not one
|
|
100
|
-
def join_method(join_type)
|
|
101
|
-
JOIN_METHODS.fetch(join_type) do
|
|
102
|
-
raise PermissionForge::ConfigurationError,
|
|
103
|
-
"Rule join_type #{join_type.inspect} is not a join type. Allowed: #{JOIN_METHODS.keys.join(', ')}"
|
|
104
|
-
end
|
|
149
|
+
join_method = JOIN_METHODS[join_type]
|
|
150
|
+
join_method ? apply_join(query, rule, join_method) : query.none
|
|
105
151
|
end
|
|
106
152
|
|
|
153
|
+
# A filter rule compares its model's own table: a field_name
|
|
154
|
+
# qualified with another table (RuleColumn::FOREIGN) matches nothing,
|
|
155
|
+
# whatever the query joins (RuleProblem :foreign_table).
|
|
107
156
|
def apply_filter(query, rule)
|
|
108
|
-
|
|
109
|
-
|
|
157
|
+
field_name = rule[:field_name]
|
|
158
|
+
target = PermissionForge::Operators::RuleColumn.find(field_name, [@model_class])
|
|
159
|
+
build_query(query, quoted_column(@model_class.table_name, field_name), rule, target)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# A path that cannot be followed (Operators::RulePath#broken?: no such
|
|
163
|
+
# association, a polymorphic one, a class that cannot be loaded...)
|
|
164
|
+
# matches nothing: ActiveRecord would raise for the join when the query
|
|
165
|
+
# runs, and every holder would get an error instead of a denial. A
|
|
166
|
+
# qualifier is compared with the names the joined query gives its
|
|
167
|
+
# tables (Operators::RuleSources: the aliases ActiveRecord assigns,
|
|
168
|
+
# the default scope's and the host's joins included); one it does not
|
|
169
|
+
# give matches nothing (RuleColumn::FOREIGN / UNRESOLVABLE).
|
|
170
|
+
def apply_join(query, rule, join_method)
|
|
171
|
+
association_chain = PermissionForge::Operators::RuleColumn.path_segments(rule[:field_accessor_path])
|
|
172
|
+
models = joinable_models(association_chain)
|
|
173
|
+
return query.none unless models
|
|
174
|
+
|
|
175
|
+
query = PermissionForge::Operators::RulePath.join(query, join_method, association_chain)
|
|
176
|
+
field_name = rule[:field_name]
|
|
177
|
+
joined = field_name.to_s.include?('.') ? PermissionForge::Operators::RuleSources.tables(query) : {}
|
|
178
|
+
target = PermissionForge::Operators::RuleColumn.find(field_name, models, joined: joined)
|
|
179
|
+
build_query(query, quoted_column(models.last.table_name, field_name), rule, target)
|
|
110
180
|
end
|
|
111
181
|
|
|
112
|
-
#
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
column = quoted_column((models.last || @model_class).table_name, rule[:field_name])
|
|
117
|
-
build_query(query, column, rule, models)
|
|
182
|
+
# The models along a path ActiveRecord can join, or nil (RulePath#broken?).
|
|
183
|
+
def joinable_models(association_chain)
|
|
184
|
+
path = PermissionForge::Operators::RulePath.follow(@model_class, association_chain)
|
|
185
|
+
path.models unless path.broken?
|
|
118
186
|
end
|
|
119
187
|
|
|
120
188
|
# Quotes a column reference with the connection's identifier quoting.
|
|
121
189
|
# A dotted field_name is treated as already-qualified path segments and
|
|
122
|
-
# each segment is quoted independently.
|
|
190
|
+
# each segment is quoted independently. Through the pool, never the
|
|
191
|
+
# model's `connection` (it raises under Rails 7.2+
|
|
192
|
+
# `permanent_connection_checkout = :disallowed`).
|
|
123
193
|
def quoted_column(table, field_name)
|
|
124
|
-
conn = @model_class.connection
|
|
125
194
|
name = field_name.to_s
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
"#{conn.quote_table_name(table)}.#{conn.quote_column_name(name)}"
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def resolve_join(query, rule, join_method)
|
|
132
|
-
return [query, [@model_class]] if rule[:field_accessor_path].blank?
|
|
133
|
-
|
|
134
|
-
association_chain = rule[:field_accessor_path].split('.')
|
|
135
|
-
query = apply_join_to_query(query, join_method, association_chain)
|
|
136
|
-
|
|
137
|
-
[query, PermissionForge::Operators::RuleColumn.models_along(@model_class, association_chain)]
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# @param join_method [Symbol] one of JOIN_METHODS — never a rule's own text
|
|
141
|
-
def apply_join_to_query(query, join_method, association_chain)
|
|
142
|
-
if association_chain.size > 1
|
|
143
|
-
query.public_send(join_method, build_nested_joins_structure(association_chain))
|
|
144
|
-
else
|
|
145
|
-
query.public_send(join_method, association_chain.first.to_sym)
|
|
146
|
-
end
|
|
147
|
-
end
|
|
195
|
+
@model_class.connection_pool.with_connection do |conn|
|
|
196
|
+
next name.split('.').map { |seg| conn.quote_column_name(seg) }.join('.') if name.include?('.')
|
|
148
197
|
|
|
149
|
-
|
|
150
|
-
structure = {}
|
|
151
|
-
current = structure
|
|
152
|
-
|
|
153
|
-
association_chain.each_with_index do |assoc, index|
|
|
154
|
-
current[assoc.to_sym] = {}
|
|
155
|
-
current = current[assoc.to_sym] unless index == association_chain.length - 1
|
|
198
|
+
"#{conn.quote_table_name(table)}.#{conn.quote_column_name(name)}"
|
|
156
199
|
end
|
|
157
|
-
|
|
158
|
-
structure
|
|
159
200
|
end
|
|
160
201
|
|
|
161
202
|
# A built-in operator gets its value in the type of the column it is
|
|
@@ -163,17 +204,39 @@ module PermissionForge
|
|
|
163
204
|
# of that column makes the rule match nothing, whatever the database
|
|
164
205
|
# would have made of the comparison. A custom operator's value is passed
|
|
165
206
|
# as it is and its strategy runs. A field_name that is not a name
|
|
166
|
-
# (Operators::RuleColumn.name?) matches nothing under any operator
|
|
167
|
-
|
|
207
|
+
# (Operators::RuleColumn.name?) matches nothing under any operator, and
|
|
208
|
+
# one naming a column its table does not have matches nothing under a
|
|
209
|
+
# built-in operator (Operators::ValueTyping.no_column?) — the database
|
|
210
|
+
# would reject the query, and every holder would get an error instead
|
|
211
|
+
# of a denial. So does one qualified with a table that is not there
|
|
212
|
+
# (RuleColumn#unresolvable?) or that is none of the rule's query
|
|
213
|
+
# (RuleColumn#foreign?), and a rule whose operator the registry
|
|
214
|
+
# does not have (written past the model, or by a newer version).
|
|
215
|
+
def build_query(query, quoted_column, rule, target)
|
|
168
216
|
operator = rule[:operator]
|
|
169
|
-
strategy =
|
|
170
|
-
target
|
|
217
|
+
strategy = strategy_for(operator)
|
|
218
|
+
return query.none if strategy.nil? || target.unresolvable? || target.foreign?
|
|
219
|
+
|
|
171
220
|
value = PermissionForge::Operators::ValueTyping.call(operator, formatted_field_value(rule), target)
|
|
172
221
|
return query.none if value.equal?(PermissionForge::Operators::ValueTyping::UNUSABLE)
|
|
173
222
|
|
|
174
223
|
strategy.call(query, quoted_column, rule, value)
|
|
175
224
|
end
|
|
176
225
|
|
|
226
|
+
# The operator's strategy from the registry — Operators::Registry, or an
|
|
227
|
+
# injected one: anything answering `strategies` (a Hash of them) or
|
|
228
|
+
# `fetch(name)` (a Hash, an object of the host's) — and nil for an
|
|
229
|
+
# operator it does not have, whether it answers nil or raises for one
|
|
230
|
+
# (ArgumentError as Registry.fetch does, KeyError as a Hash does).
|
|
231
|
+
def strategy_for(operator)
|
|
232
|
+
registry = @operator_registry
|
|
233
|
+
return registry.strategies[operator] if registry.respond_to?(:strategies)
|
|
234
|
+
|
|
235
|
+
registry.fetch(operator)
|
|
236
|
+
rescue ArgumentError, KeyError
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
|
|
177
240
|
# The only magic value is 'current_user', substituted with the current
|
|
178
241
|
# user's id where one value is compared (in a list it is an element like
|
|
179
242
|
# any other). Every other field_value reaches ValueTyping as it is
|