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,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# The validation error a uniqueness race would have shown.
|
|
5
|
+
#
|
|
6
|
+
# Four models validate uniqueness with a unique index behind the validation
|
|
7
|
+
# (a Resource or a field name per set or resource, one assignment or
|
|
8
|
+
# membership per person): two requests that pass the validation together
|
|
9
|
+
# leave the second INSERT to the index, which raises
|
|
10
|
+
# ActiveRecord::RecordNotUnique. The admin and the API answer it the way
|
|
11
|
+
# they answer the validation — a 422 keyed by the attribute, the form's
|
|
12
|
+
# page with the message — from the index the error names. The adapters
|
|
13
|
+
# word the error differently (SQLite names the columns, PostgreSQL and
|
|
14
|
+
# MySQL the index), so the index name and every `table.column` of the
|
|
15
|
+
# index are looked for, and nothing else of the message is parsed.
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
module UniqueConflict
|
|
19
|
+
# A uniqueness validation (its attribute and validator) of a model.
|
|
20
|
+
Conflict = Data.define(:model, :attribute, :validator) do
|
|
21
|
+
# The validation's own errors, as `record.errors` would hold them:
|
|
22
|
+
# `:taken` with the validator's `message:` option, as the validator
|
|
23
|
+
# adds it.
|
|
24
|
+
#
|
|
25
|
+
# @return [ActiveModel::Errors]
|
|
26
|
+
def errors
|
|
27
|
+
list = model.new.errors
|
|
28
|
+
list.add(attribute, :taken, **validator.options.slice(:message))
|
|
29
|
+
list
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# `{ user_id: ["has already been taken"] }`: the validation's message
|
|
35
|
+
# for the unique index `error` names, keyed by the attribute like
|
|
36
|
+
# `record.errors.messages`; nil when the index belongs to no
|
|
37
|
+
# uniqueness validation of the models (a uuid index, a host's own
|
|
38
|
+
# index, a message naming nothing).
|
|
39
|
+
#
|
|
40
|
+
# @param error [ActiveRecord::RecordNotUnique]
|
|
41
|
+
# @return [Hash{Symbol => Array<String>}, nil]
|
|
42
|
+
def messages(error)
|
|
43
|
+
find(error)&.errors&.messages
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The same as one sentence in the current locale ("Name already exists
|
|
47
|
+
# in this permission set", Admin::ErrorSentence); nil as for .messages.
|
|
48
|
+
#
|
|
49
|
+
# @param error [ActiveRecord::RecordNotUnique]
|
|
50
|
+
# @return [String, nil]
|
|
51
|
+
def sentence(error)
|
|
52
|
+
conflict = find(error)
|
|
53
|
+
conflict && Admin::ErrorSentence.first(conflict.errors)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The uniqueness validation behind the unique index `error` names.
|
|
57
|
+
#
|
|
58
|
+
# @param error [ActiveRecord::RecordNotUnique]
|
|
59
|
+
# @return [Conflict, nil]
|
|
60
|
+
def find(error)
|
|
61
|
+
text = error.message.to_s
|
|
62
|
+
models.each do |model|
|
|
63
|
+
index = unique_indexes(model).find { |candidate| names?(text, candidate, model.table_name) }
|
|
64
|
+
return conflict_for(model, index) if index
|
|
65
|
+
end
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The uniqueness validator of `model` whose attribute and scope are
|
|
70
|
+
# exactly the columns of `index`, with that attribute.
|
|
71
|
+
#
|
|
72
|
+
# @param model [Class] one of the models
|
|
73
|
+
# @param index [ActiveRecord::ConnectionAdapters::IndexDefinition]
|
|
74
|
+
# @return [Conflict, nil]
|
|
75
|
+
def conflict_for(model, index)
|
|
76
|
+
columns = Array(index.columns).map(&:to_s).sort
|
|
77
|
+
model.validators.each do |validator|
|
|
78
|
+
attribute = validated_attribute(validator, columns)
|
|
79
|
+
return Conflict.new(model: model, attribute: attribute, validator: validator) if attribute
|
|
80
|
+
end
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# The attribute of a uniqueness validator that, with the validator's
|
|
87
|
+
# scope, is exactly `columns` (sorted); nil for any other validator.
|
|
88
|
+
def validated_attribute(validator, columns)
|
|
89
|
+
return unless validator.kind == :uniqueness
|
|
90
|
+
|
|
91
|
+
scope = Array(validator.options[:scope]).map(&:to_s)
|
|
92
|
+
validator.attributes.find { |name| ([name.to_s] + scope).sort == columns }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def models
|
|
96
|
+
PermissionForge::MODEL_NAMES.map { |name| PermissionForge.const_get(name) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The unique indexes of the model's table; none when the table cannot
|
|
100
|
+
# be read (no table yet, no database): nothing is named then.
|
|
101
|
+
def unique_indexes(model)
|
|
102
|
+
model.connection_pool.with_connection { |connection| connection.indexes(model.table_name) }.select(&:unique)
|
|
103
|
+
rescue ActiveRecord::ActiveRecordError
|
|
104
|
+
[]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Whether the adapter's message names the index: by its name
|
|
108
|
+
# (PostgreSQL, MySQL) or by each of its columns as table.column (SQLite).
|
|
109
|
+
def names?(text, index, table)
|
|
110
|
+
return true if index.name.present? && text.match?(/\b#{Regexp.escape(index.name.to_s)}\b/)
|
|
111
|
+
|
|
112
|
+
columns = Array(index.columns).map(&:to_s)
|
|
113
|
+
columns.any? && columns.all? { |column| text.include?("#{table}.#{column}") }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -31,7 +31,8 @@ module PermissionForge
|
|
|
31
31
|
|
|
32
32
|
# @return [Array<String>] discovered names, never a column
|
|
33
33
|
def call
|
|
34
|
-
SOURCES.flat_map { |source| send(source) }.uniq
|
|
34
|
+
names = SOURCES.flat_map { |source| send(source) }.uniq
|
|
35
|
+
names - SchemaRead.columns_among(@klass, names)
|
|
35
36
|
rescue StandardError
|
|
36
37
|
[]
|
|
37
38
|
end
|
|
@@ -47,9 +47,12 @@ module PermissionForge
|
|
|
47
47
|
@extra = extra_unreadable(e)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
# Declared names that are neither a column nor an ignored field.
|
|
50
|
+
# Declared names that are neither a column nor an ignored field. Whether
|
|
51
|
+
# a declared name is a column is asked once more after the schema is
|
|
52
|
+
# read again (PermissionForge::SchemaRead): a migration may have turned
|
|
53
|
+
# it into one.
|
|
51
54
|
def declared
|
|
52
|
-
@declared ||= extra
|
|
55
|
+
@declared ||= extra.reject { |name| ignored_fields.include?(name) || SchemaRead.column?(@klass, name, ignored: false) }
|
|
53
56
|
end
|
|
54
57
|
|
|
55
58
|
# Listed only while discovery is on; never implicitly granted.
|
|
@@ -74,10 +77,13 @@ module PermissionForge
|
|
|
74
77
|
end
|
|
75
78
|
|
|
76
79
|
# Whether the model has `name` as a virtual field, listed or not (a
|
|
77
|
-
# field configured while discovery was on stays a virtual field).
|
|
80
|
+
# field configured while discovery was on stays a virtual field). A name
|
|
81
|
+
# that is no column is asked again after the schema is read again, once
|
|
82
|
+
# (PermissionForge::SchemaRead): a field a migration just turned into a
|
|
83
|
+
# column is a column.
|
|
78
84
|
def include?(name)
|
|
79
85
|
name = name.to_s
|
|
80
|
-
|
|
86
|
+
!SchemaRead.column?(@klass, name, ignored: false) && (extra.include?(name) || discoverable.include?(name))
|
|
81
87
|
end
|
|
82
88
|
|
|
83
89
|
private
|
|
@@ -87,7 +93,11 @@ module PermissionForge
|
|
|
87
93
|
end
|
|
88
94
|
|
|
89
95
|
def excluded
|
|
90
|
-
@klass
|
|
96
|
+
SchemaRead.column_names(@klass) + ignored_fields
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def ignored_fields
|
|
100
|
+
Array(@configuration.ignored_fields).map(&:to_s)
|
|
91
101
|
end
|
|
92
102
|
|
|
93
103
|
# Keeps the error for callers that must say what it costs them (reader
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent/map'
|
|
4
|
+
|
|
5
|
+
module PermissionForge
|
|
6
|
+
# The one emitter of the gem's boot and configuration warnings (a per-process
|
|
7
|
+
# cache, a fail-open setting, a model that cannot be masked, ...). Every such
|
|
8
|
+
# warning is one `emit` call; this module decides the channel, so that a
|
|
9
|
+
# warning about a fail-open condition is read by somebody.
|
|
10
|
+
#
|
|
11
|
+
# The channel policy (`channels`):
|
|
12
|
+
# - the log (Rails.logger.warn) always;
|
|
13
|
+
# - the terminal (standard error) where the log is not read: in development
|
|
14
|
+
# (`bin/rails server` boots before it attaches its console logger; a
|
|
15
|
+
# console, `bin/rails runner`, a rake task and `db:seed` never attach one)
|
|
16
|
+
# and, in every other environment but test, in any process that is not a
|
|
17
|
+
# web server (a console, a runner, a rake task, a seed, a job worker) —
|
|
18
|
+
# unless the logger already writes to standard output or error (a
|
|
19
|
+
# container, RAILS_LOG_TO_STDOUT), which would print it twice;
|
|
20
|
+
# - nothing while `bin/rails generate` or `destroy` boots the app
|
|
21
|
+
# (generator_running?): the install generator boots before it writes the
|
|
22
|
+
# initializer, so its warnings describe a configuration the app is about
|
|
23
|
+
# to stop having (R5 #120);
|
|
24
|
+
# - the terminal gets a key once per process (a human needs one line); the
|
|
25
|
+
# log gets every emission the site makes — a site that may repeat (a
|
|
26
|
+
# retried model, a store outage) decides itself when its condition is new.
|
|
27
|
+
#
|
|
28
|
+
# A SECURITY warning (severity :security) is a condition under which access
|
|
29
|
+
# is wider than configured, or a change goes unrecorded; it carries the
|
|
30
|
+
# prefix `SECURITY warning:`. Adding a severity is one SEVERITIES entry.
|
|
31
|
+
#
|
|
32
|
+
# A warning's audience (AUDIENCES) is the processes it is about. :all (the
|
|
33
|
+
# default): every process — the cache, the models, the rules, the audit
|
|
34
|
+
# table are used by a runner, a rake task, a job as much as by a server.
|
|
35
|
+
# :server: a warning about the admin screens or the JSON API, which only
|
|
36
|
+
# a web server process serves (HTTP Basic credentials, the actor of an
|
|
37
|
+
# admin change, the API's authorization lambdas, an API-only host's
|
|
38
|
+
# middlewares, tenantless admin access); outside a server process
|
|
39
|
+
# (server_process?) it goes to the log only. A runner does not serve the
|
|
40
|
+
# admin, and the credentials it would warn about are often set for the
|
|
41
|
+
# server alone (round 7, a-onboarding #23).
|
|
42
|
+
#
|
|
43
|
+
# Nothing here raises for a channel: a terminal that cannot be written is
|
|
44
|
+
# skipped, a missing logger is skipped. Never called at require time.
|
|
45
|
+
#
|
|
46
|
+
# @example
|
|
47
|
+
# PermissionForge::Warnings.emit(:api_authorization_gap,
|
|
48
|
+
# 'API is enabled but ... (privilege escalation).', severity: :security)
|
|
49
|
+
#
|
|
50
|
+
# @api private
|
|
51
|
+
module Warnings
|
|
52
|
+
# The text each severity puts before the message.
|
|
53
|
+
SEVERITIES = { security: 'SECURITY warning: ', config: '' }.freeze
|
|
54
|
+
|
|
55
|
+
# Every warning starts with it.
|
|
56
|
+
PREFIX = '[PermissionForge] '
|
|
57
|
+
|
|
58
|
+
# The commands `bin/rails generate` and `bin/rails destroy` run: Rails
|
|
59
|
+
# loads the command's class before it boots the app (canary
|
|
60
|
+
# :generator_commands). Rails::Generators alone does not say it: a gem may
|
|
61
|
+
# require rails/generators as it loads.
|
|
62
|
+
GENERATOR_COMMANDS = %w[Rails::Command::GenerateCommand Rails::Command::DestroyCommand].freeze
|
|
63
|
+
|
|
64
|
+
# The processes a warning is about (see the module): every process, or
|
|
65
|
+
# a web server's only. Adding an audience is one entry here and a rule
|
|
66
|
+
# in .channels.
|
|
67
|
+
AUDIENCES = %i[all server].freeze
|
|
68
|
+
|
|
69
|
+
# What a web server's own command has loaded before it boots the app,
|
|
70
|
+
# never what `require "<gem>"` (Bundler.require in a runner) loads:
|
|
71
|
+
# `bin/rails server` (Rails::Server), `puma` (Puma::CLI), `unicorn` and
|
|
72
|
+
# `unicorn_rails` (Unicorn::Launcher), Passenger's Rack loaders
|
|
73
|
+
# (PhusionPassenger::App), `falcon serve` / `falcon host`
|
|
74
|
+
# (Falcon::Command). Another server (thin, a custom Rack launcher)
|
|
75
|
+
# counts as a process whose log is not read: its standard error gets
|
|
76
|
+
# the warning unless the logger already writes there, and a :server
|
|
77
|
+
# warning goes to its log only.
|
|
78
|
+
SERVER_COMMANDS = %w[Rails::Server Puma::CLI Unicorn::Launcher PhusionPassenger::App Falcon::Command].freeze
|
|
79
|
+
|
|
80
|
+
# Servers whose command defines no constant of its own, by program name
|
|
81
|
+
# (the basename of Process.argv0, the name the process was started
|
|
82
|
+
# with, or of $PROGRAM_NAME): `pitchfork` (bin/pitchfork requires what
|
|
83
|
+
# `require "pitchfork"` does, and pitchfork rewrites $0 into its process
|
|
84
|
+
# title: Process.argv0 keeps the name), `rackup` (`bundle exec rackup`
|
|
85
|
+
# boots Rack's handler, never Puma::CLI; `bundle exec` loads a Ruby
|
|
86
|
+
# binstub in its own process and sets $0 to it, so Process.argv0 reads
|
|
87
|
+
# `bundle` there). A server started some other way counts as a process
|
|
88
|
+
# whose log is not read (see SERVER_COMMANDS).
|
|
89
|
+
SERVER_PROGRAMS = %w[pitchfork rackup].freeze
|
|
90
|
+
|
|
91
|
+
# The channels, in writing order.
|
|
92
|
+
CHANNELS = %i[log terminal].freeze
|
|
93
|
+
|
|
94
|
+
# What the channel policy reads about the process a warning is written
|
|
95
|
+
# from: the environment (an ActiveSupport::EnvironmentInquirer), whether
|
|
96
|
+
# it is a web server, whether a generator is booting it, and its logger.
|
|
97
|
+
# Warnings.context builds the current one; tests build their own.
|
|
98
|
+
Context = Data.define(:env, :server, :generator, :logger)
|
|
99
|
+
|
|
100
|
+
# Writes warnings to a terminal under a Context. Warnings.emit uses one
|
|
101
|
+
# for this process; tests inject an io and a Context.
|
|
102
|
+
class Emitter
|
|
103
|
+
# @param io [IO] the terminal
|
|
104
|
+
# @param context [Context]
|
|
105
|
+
def initialize(io: $stderr, context: Warnings.context)
|
|
106
|
+
@io = io
|
|
107
|
+
@context = context
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @param key [Symbol, String] what the warning is about (the terminal
|
|
111
|
+
# gets each key once per process); a per-subject warning puts the
|
|
112
|
+
# subject in the key (`"masked_models.unmaskable.#{name}"`)
|
|
113
|
+
# @param message [String] the sentence(s), without the gem prefix: it
|
|
114
|
+
# names the condition, its consequence and the remedy
|
|
115
|
+
# @param severity [Symbol] a SEVERITIES key: :security for a condition
|
|
116
|
+
# under which access widens or a change goes unrecorded, else :config
|
|
117
|
+
# @param audience [Symbol] an AUDIENCES entry: :server for a warning
|
|
118
|
+
# about the admin or the API a server process serves (the log only
|
|
119
|
+
# in any other process), else :all
|
|
120
|
+
# @return [String] the text written
|
|
121
|
+
# @raise [ArgumentError] for a severity not in SEVERITIES, an audience
|
|
122
|
+
# not in AUDIENCES
|
|
123
|
+
def emit(key, message, severity: :config, audience: :all)
|
|
124
|
+
text = "#{PREFIX}#{Warnings.severity_prefix(severity)}#{message}"
|
|
125
|
+
Warnings.channels(@context, audience: audience).each do |channel|
|
|
126
|
+
channel == :log ? @context.logger&.warn(text) : terminal(key, text)
|
|
127
|
+
end
|
|
128
|
+
text
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
# Once per process and key. A terminal that is closed or gone (a
|
|
134
|
+
# broken pipe) is skipped: the log has the warning.
|
|
135
|
+
def terminal(key, text)
|
|
136
|
+
return unless Warnings.first_on_terminal?(key)
|
|
137
|
+
|
|
138
|
+
@io.puts(text)
|
|
139
|
+
rescue IOError, SystemCallError
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
class << self
|
|
145
|
+
# Writes one warning where the policy says (an Emitter for this process).
|
|
146
|
+
#
|
|
147
|
+
# @param (see Emitter#emit)
|
|
148
|
+
# @return [String] the text written
|
|
149
|
+
def emit(key, message, severity: :config, audience: :all)
|
|
150
|
+
Emitter.new.emit(key, message, severity: severity, audience: audience)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The channels a warning goes to under `context`: [] while a generator
|
|
154
|
+
# runs, [:log] where the log is read — and for a :server warning in a
|
|
155
|
+
# process that is not a server —, [:log, :terminal] elsewhere (see the
|
|
156
|
+
# module).
|
|
157
|
+
#
|
|
158
|
+
# @param context [Context]
|
|
159
|
+
# @param audience [Symbol] an AUDIENCES entry
|
|
160
|
+
# @return [Array<Symbol>]
|
|
161
|
+
# @raise [ArgumentError] for an audience not in AUDIENCES
|
|
162
|
+
def channels(context = context(), audience: :all)
|
|
163
|
+
check_audience!(audience)
|
|
164
|
+
return [] if context.generator
|
|
165
|
+
return CHANNELS.first(1) if audience == :server && !context.server
|
|
166
|
+
|
|
167
|
+
terminal?(context) ? CHANNELS : CHANNELS.first(1)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# This process, with `overrides` for a test.
|
|
171
|
+
#
|
|
172
|
+
# @param overrides [Hash] env:, server:, generator:, logger:
|
|
173
|
+
# @return [Context]
|
|
174
|
+
def context(**overrides)
|
|
175
|
+
current = { env: Rails.env, server: server_process?, generator: generator_running?, logger: current_logger }
|
|
176
|
+
Context.new(**current, **overrides)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Forgets which keys the terminal got (tests; a process has one boot).
|
|
180
|
+
#
|
|
181
|
+
# @return [void]
|
|
182
|
+
def reset!
|
|
183
|
+
written.clear
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Whether `bin/rails generate` or `bin/rails destroy` is booting the
|
|
187
|
+
# app: their command class is loaded; a server, a console, a runner or
|
|
188
|
+
# a rake task loads its own.
|
|
189
|
+
#
|
|
190
|
+
# @return [Boolean]
|
|
191
|
+
def generator_running?
|
|
192
|
+
GENERATOR_COMMANDS.any? { |name| Object.const_defined?(name, false) }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Whether this process is a web server (SERVER_COMMANDS,
|
|
196
|
+
# SERVER_PROGRAMS): its log is where its warnings are read, outside
|
|
197
|
+
# development, and the :server warnings are about it.
|
|
198
|
+
#
|
|
199
|
+
# @param started_as [String] the name the process was started with (Process.argv0)
|
|
200
|
+
# @param program_name [String] $PROGRAM_NAME as it is now
|
|
201
|
+
# @return [Boolean]
|
|
202
|
+
def server_process?(started_as: Process.argv0, program_name: $PROGRAM_NAME)
|
|
203
|
+
SERVER_COMMANDS.any? { |name| Object.const_defined?(name, false) } ||
|
|
204
|
+
[started_as, program_name].any? { |program| SERVER_PROGRAMS.include?(File.basename(program.to_s)) }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Whether `logger` already writes to standard output or error (a
|
|
208
|
+
# container setup, RAILS_LOG_TO_STDOUT, a logger the app configured):
|
|
209
|
+
# the terminal then has the warning once the log is written.
|
|
210
|
+
#
|
|
211
|
+
# @param logger [Logger, nil]
|
|
212
|
+
# @return [Boolean]
|
|
213
|
+
def logger_on_terminal?(logger = current_logger)
|
|
214
|
+
!logger.nil? && ActiveSupport::Logger.logger_outputs_to?(logger, $stdout, $stderr)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# @param severity [Symbol]
|
|
218
|
+
# @return [String] what SEVERITIES puts before the message
|
|
219
|
+
# @raise [ArgumentError] for a severity not in SEVERITIES
|
|
220
|
+
def severity_prefix(severity)
|
|
221
|
+
SEVERITIES.fetch(severity) do
|
|
222
|
+
raise ArgumentError, "unknown warning severity #{severity.inspect}; one of #{SEVERITIES.keys.inspect}"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Whether the terminal has not had `key` in this process yet (and
|
|
227
|
+
# remembers that it now has).
|
|
228
|
+
#
|
|
229
|
+
# @param key [Symbol, String]
|
|
230
|
+
# @return [Boolean]
|
|
231
|
+
def first_on_terminal?(key)
|
|
232
|
+
written.put_if_absent(key.to_s, true).nil?
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
def check_audience!(audience)
|
|
238
|
+
return if AUDIENCES.include?(audience)
|
|
239
|
+
|
|
240
|
+
raise ArgumentError, "unknown warning audience #{audience.inspect}; one of #{AUDIENCES.inspect}"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Rails.logger, nil before the app has one.
|
|
244
|
+
def current_logger
|
|
245
|
+
Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Never in test (a test run reads its log); in development always; in
|
|
249
|
+
# any other environment in a process that is not a server — each unless
|
|
250
|
+
# the log already shows on the terminal.
|
|
251
|
+
def terminal?(context)
|
|
252
|
+
env = context.env
|
|
253
|
+
return false if env.test?
|
|
254
|
+
|
|
255
|
+
(env.development? || !context.server) && !logger_on_terminal?(context.logger)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def written
|
|
259
|
+
@written ||= Concurrent::Map.new
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack/body_proxy'
|
|
4
|
+
|
|
5
|
+
module PermissionForge
|
|
6
|
+
# The one definition of "a web request is in progress": the Rack request
|
|
7
|
+
# the host application is serving right now, from Middleware until the
|
|
8
|
+
# response is finished. The engine puts Middleware at the top of the
|
|
9
|
+
# host's stack (Engine: `config.app_middleware.unshift`), unless a
|
|
10
|
+
# middleware is inserted above it (`config.middleware.insert_before 0`, as
|
|
11
|
+
# rack-cors documents, or a gem that inserts at 0 later): StackCheck warns
|
|
12
|
+
# at boot and SecurityAudit lists them. So the host's own controllers, the
|
|
13
|
+
# mounted engine's admin screens and API, every middleware below it (a
|
|
14
|
+
# tenant lookup, an authentication middleware) and the before_actions of
|
|
15
|
+
# the host's ApplicationController count.
|
|
16
|
+
#
|
|
17
|
+
# Outside one: background jobs run by a worker, `perform_later` on an
|
|
18
|
+
# adapter that runs elsewhere (`:async` runs on its own threads), Action
|
|
19
|
+
# Cable channel actions (they run on the cable server's worker pool), rake
|
|
20
|
+
# tasks, `bin/rails runner`, the console, and ActionController::TestCase
|
|
21
|
+
# controller tests (they call the controller without the middleware
|
|
22
|
+
# stack; integration and system tests go through it). Inside one: a job
|
|
23
|
+
# run with `perform_now` (or the `:inline` adapter) and a mail delivered
|
|
24
|
+
# with `deliver_now` during the request.
|
|
25
|
+
#
|
|
26
|
+
# It decides config.masked_attributes_nil_user_strategy = :hide_in_requests
|
|
27
|
+
# (Configuration#masked_attributes_without_user_for) and where
|
|
28
|
+
# field.unmasked_without_user is recorded.
|
|
29
|
+
#
|
|
30
|
+
# The state is ExecutionState slot :pf_web_request (the request's env), in
|
|
31
|
+
# the :execution scope, kept at the end of an executor run: the middleware
|
|
32
|
+
# sets it before the request's run starts and puts back what it found when
|
|
33
|
+
# the response is finished (`rack.response_finished`, else when the server
|
|
34
|
+
# closes the body), or at once when the application raises. Under Rails'
|
|
35
|
+
# :thread isolation (the default) every fiber of the request's thread sees
|
|
36
|
+
# it (an Enumerator, a streamed body), and so does the thread
|
|
37
|
+
# ActionController::Live runs the action in (it shares the request's
|
|
38
|
+
# isolated state). Under `isolation_level = :fiber` each fiber has a state
|
|
39
|
+
# of its own: a fiber the request starts (Enumerator#next, a Live thread)
|
|
40
|
+
# does not see it, and a read there is outside the request — Falcon needs
|
|
41
|
+
# :fiber, as Rails itself does.
|
|
42
|
+
#
|
|
43
|
+
# A slot left behind (a server that neither runs the callbacks nor closes
|
|
44
|
+
# the body) hides more where it is seen, never less, and is never put back
|
|
45
|
+
# by a later request: an env whose application call has returned is not
|
|
46
|
+
# a request in progress around the next one.
|
|
47
|
+
#
|
|
48
|
+
# @api private
|
|
49
|
+
module WebRequest
|
|
50
|
+
KEY = :pf_web_request
|
|
51
|
+
|
|
52
|
+
# Recorded request values are cut to this many characters (as the audit
|
|
53
|
+
# context's request id and IP are).
|
|
54
|
+
VALUE_LIMIT = 255
|
|
55
|
+
|
|
56
|
+
# Reads the slot (ExecutionState.reader).
|
|
57
|
+
READ = ExecutionState.reader(KEY)
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
# @return [Boolean] whether this code runs inside a web request (asked
|
|
61
|
+
# on every guarded read with nobody signed in: the slot's store is
|
|
62
|
+
# read directly)
|
|
63
|
+
def active?
|
|
64
|
+
!READ.call.nil?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Hash, nil] the Rack env of the request in progress
|
|
68
|
+
def env
|
|
69
|
+
ExecutionState[KEY]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The request in progress, for an audit event or a log line: its
|
|
73
|
+
# method, path (without the query string) and, once the router has
|
|
74
|
+
# matched it, endpoint ("controller_path#action"); {} outside a
|
|
75
|
+
# request. Read when asked, never kept.
|
|
76
|
+
#
|
|
77
|
+
# @return [Hash{String => String, nil}]
|
|
78
|
+
def details
|
|
79
|
+
env = self.env
|
|
80
|
+
return {} unless env
|
|
81
|
+
|
|
82
|
+
request = ActionDispatch::Request.new(env)
|
|
83
|
+
{ 'method' => cut(request.request_method), 'path' => cut(request.path), 'endpoint' => endpoint(request) }
|
|
84
|
+
rescue StandardError
|
|
85
|
+
{}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Runs the block as a web request (tests; Middleware keeps it past the
|
|
89
|
+
# block itself).
|
|
90
|
+
#
|
|
91
|
+
# @param env [Hash] a Rack env
|
|
92
|
+
# @return the block's value
|
|
93
|
+
def within(env = {}, &)
|
|
94
|
+
ExecutionState.with(KEY, env, &)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# What the router matched, read without writing: Request#path_parameters
|
|
100
|
+
# would store {} in the env of a request not routed yet (canary
|
|
101
|
+
# :path_parameters_key).
|
|
102
|
+
def endpoint(request)
|
|
103
|
+
params = request.get_header(ActionDispatch::Http::Parameters::PARAMETERS_KEY)
|
|
104
|
+
controller = params[:controller] if params.is_a?(Hash)
|
|
105
|
+
cut("#{controller}##{params[:action]}") if controller
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def cut(value)
|
|
109
|
+
value.to_s[0, VALUE_LIMIT].presence
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Marks every request the host application serves (see WebRequest). At
|
|
114
|
+
# the top of the host's middleware stack, above ActionDispatch::Executor
|
|
115
|
+
# (StackCheck names what was inserted above it).
|
|
116
|
+
#
|
|
117
|
+
# @api private
|
|
118
|
+
class Middleware
|
|
119
|
+
# Set in the env once the application has returned the response: an
|
|
120
|
+
# env still in the slot then is no request around the next one.
|
|
121
|
+
RETURNED = 'permission_forge.web_request.returned'
|
|
122
|
+
|
|
123
|
+
# @param app [#call] the next middleware
|
|
124
|
+
def initialize(app)
|
|
125
|
+
@app = app
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @param env [Hash]
|
|
129
|
+
# @return [Array] the Rack response, its body wrapped when the server
|
|
130
|
+
# does not announce `rack.response_finished`
|
|
131
|
+
def call(env)
|
|
132
|
+
finish = Finish.new(env)
|
|
133
|
+
returned = false
|
|
134
|
+
begin
|
|
135
|
+
status, headers, body = @app.call(env)
|
|
136
|
+
returned = true
|
|
137
|
+
ensure
|
|
138
|
+
returned ? env[RETURNED] = true : finish.call
|
|
139
|
+
end
|
|
140
|
+
[status, headers, finished(env, body, finish)]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
# Until the response is finished: Rack 3 servers call the
|
|
146
|
+
# `rack.response_finished` callbacks once it is sent (ActionDispatch::Executor
|
|
147
|
+
# completes its run there too); others close the body.
|
|
148
|
+
def finished(env, body, finish)
|
|
149
|
+
callbacks = env['rack.response_finished']
|
|
150
|
+
return Rack::BodyProxy.new(body, &finish) unless callbacks
|
|
151
|
+
|
|
152
|
+
callbacks << finish
|
|
153
|
+
body
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The end of one request: puts back what the slot held (a request around
|
|
158
|
+
# it, or nothing) and, for the outermost request, forgets what runtime
|
|
159
|
+
# events it published once (Audit::Runtime.once?): reads made above
|
|
160
|
+
# ActionDispatch::Executor are outside its run, which would not clear
|
|
161
|
+
# them.
|
|
162
|
+
#
|
|
163
|
+
# @api private
|
|
164
|
+
class Finish
|
|
165
|
+
# @param env [Hash] the request's env, which the slot holds from now on
|
|
166
|
+
def initialize(env)
|
|
167
|
+
ExecutionState.put_back(KEY, nil) if Finish.left_behind?(ExecutionState[KEY])
|
|
168
|
+
@outermost = !WebRequest.active?
|
|
169
|
+
@restore = ExecutionState.set_until_restored(KEY, env)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Whether the env in the slot is a request left behind: its application
|
|
173
|
+
# call returned and nobody finished its response (not a request around
|
|
174
|
+
# the next one).
|
|
175
|
+
#
|
|
176
|
+
# @param env [Hash, nil]
|
|
177
|
+
# @return [Boolean]
|
|
178
|
+
def self.left_behind?(env)
|
|
179
|
+
env.is_a?(Hash) && env[Middleware::RETURNED] == true
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Takes and ignores the arguments of a `rack.response_finished` callback.
|
|
183
|
+
# @return [void]
|
|
184
|
+
def call(*)
|
|
185
|
+
Audit::Runtime.reset! if @restore.call && @outermost
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# @return [Proc] for Rack::BodyProxy's block
|
|
189
|
+
def to_proc = method(:call).to_proc
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Whether anything sits above Middleware in the built stack, read once the
|
|
193
|
+
# application has booted (BootChecks; SecurityAudit): a read there is
|
|
194
|
+
# outside the request, so :hide_in_requests does not hide it and :bypass
|
|
195
|
+
# does not record it.
|
|
196
|
+
#
|
|
197
|
+
# @api private
|
|
198
|
+
module StackCheck
|
|
199
|
+
class << self
|
|
200
|
+
# @param application [Rails::Application, nil]
|
|
201
|
+
# @return [Array<String>, nil] the names above Middleware (every name
|
|
202
|
+
# when it is not in the stack at all); nil when the stack cannot be
|
|
203
|
+
# read yet
|
|
204
|
+
def middlewares_above(application = default_application)
|
|
205
|
+
names = application && ApiOnlyAdmin.middleware_names(application)
|
|
206
|
+
return unless names
|
|
207
|
+
|
|
208
|
+
names.first(names.index(Middleware.name) || names.size)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The boot warning, under the strategies that depend on the request
|
|
212
|
+
# being marked: :hide_in_requests (reads above are not hidden) and
|
|
213
|
+
# :bypass (they are not recorded).
|
|
214
|
+
#
|
|
215
|
+
# @param config [Configuration]
|
|
216
|
+
# @return [void]
|
|
217
|
+
def check(config, application = default_application)
|
|
218
|
+
strategy = config.masked_attributes_nil_user_strategy
|
|
219
|
+
return if strategy == :hide
|
|
220
|
+
|
|
221
|
+
above = middlewares_above(application)
|
|
222
|
+
return if above.blank?
|
|
223
|
+
|
|
224
|
+
if strategy == :hide_in_requests
|
|
225
|
+
Warnings.emit(:web_request_middleware_above_hide, message(above, 'are not hidden'), severity: :security, audience: :server)
|
|
226
|
+
else
|
|
227
|
+
Warnings.emit(:web_request_middleware_above_record, message(above, 'are not recorded'), severity: :config, audience: :server)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# @param above [Array<String>]
|
|
232
|
+
# @return [String]
|
|
233
|
+
def message(above, consequence)
|
|
234
|
+
"#{above.join(', ')} #{above.one? ? 'runs' : 'run'} above PermissionForge::WebRequest::Middleware: masked " \
|
|
235
|
+
"attributes read there with nobody signed in #{consequence} (masked_attributes_nil_user_strategy). Move " \
|
|
236
|
+
'the middleware below it (config.middleware.insert_after PermissionForge::WebRequest::Middleware, ...) ' \
|
|
237
|
+
'if it reads masked models.'
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
def default_application
|
|
243
|
+
Rails.application if defined?(Rails) && Rails.respond_to?(:application)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|