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
|
@@ -12,8 +12,9 @@ module PermissionForge
|
|
|
12
12
|
# offset is converted to it, and SQLite gets the text it compares with.
|
|
13
13
|
# A date column takes the day of a date-time, a time column the time of
|
|
14
14
|
# day. Anything else — another notation, `now`, `tomorrow`, the id that
|
|
15
|
-
# `current_user` stands for — is UNUSABLE
|
|
16
|
-
#
|
|
15
|
+
# `current_user` stands for — is UNUSABLE. (On PostgreSQL, which reads
|
|
16
|
+
# date and time text itself, such text is asked of it:
|
|
17
|
+
# PostgresqlTypes::ReadBack.)
|
|
17
18
|
class Temporal
|
|
18
19
|
DAY = /(?<year>\d{4})-(?<month>\d\d?)-(?<day>\d\d?)/
|
|
19
20
|
CLOCK = /(?:[01]?\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?/
|
|
@@ -23,28 +24,13 @@ module PermissionForge
|
|
|
23
24
|
|
|
24
25
|
# @param format [Regexp] what the text has to look like
|
|
25
26
|
# @param type [ActiveModel::Type::Value] casts the text and serializes the result
|
|
26
|
-
|
|
27
|
-
def initialize(format, type, as_written: false)
|
|
27
|
+
def initialize(format, type)
|
|
28
28
|
@format = format
|
|
29
29
|
@type = type
|
|
30
|
-
@as_written = as_written
|
|
31
30
|
end
|
|
32
31
|
|
|
33
32
|
def call(value)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
recognised(value.strip) || (@as_written ? value : UNUSABLE)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# The same caster for a database that reads date and time text itself
|
|
40
|
-
# and raises on what it cannot read (PostgreSQL). Text this caster does
|
|
41
|
-
# not recognise is handed over as it is written, as it was before
|
|
42
|
-
# values were typed: `today`, a date in a local notation. What is not
|
|
43
|
-
# text stays UNUSABLE.
|
|
44
|
-
#
|
|
45
|
-
# @return [Temporal]
|
|
46
|
-
def as_written
|
|
47
|
-
self.class.new(@format, @type, as_written: true)
|
|
33
|
+
recognised(value) || UNUSABLE
|
|
48
34
|
end
|
|
49
35
|
|
|
50
36
|
DATE = new(DAY_AND_CLOCK, ActiveRecord::Type::Date.new)
|
|
@@ -53,7 +39,10 @@ module PermissionForge
|
|
|
53
39
|
|
|
54
40
|
private
|
|
55
41
|
|
|
56
|
-
def recognised(
|
|
42
|
+
def recognised(value)
|
|
43
|
+
return unless value.is_a?(String)
|
|
44
|
+
|
|
45
|
+
text = value.strip
|
|
57
46
|
parts = @format.match(text)
|
|
58
47
|
@type.serialize(@type.cast(text)) if parts && real_day?(parts)
|
|
59
48
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'value_casters/number'
|
|
4
4
|
require_relative 'value_casters/temporal'
|
|
5
|
+
require_relative 'value_casters/postgresql_types'
|
|
5
6
|
|
|
6
7
|
module PermissionForge
|
|
7
8
|
module Operators
|
|
@@ -30,33 +31,61 @@ module PermissionForge
|
|
|
30
31
|
# Compared as written; the id `current_user` stands for, as its text.
|
|
31
32
|
TEXT = ->(value) { value.nil? ? UNUSABLE : value.to_s }
|
|
32
33
|
|
|
33
|
-
# A column type without a caster (
|
|
34
|
-
#
|
|
34
|
+
# A column type without a caster (hstore, money, xml, ...) or a field
|
|
35
|
+
# name that names no known column: the value is left to the database.
|
|
35
36
|
UNTYPED = ->(value) { value }
|
|
36
37
|
|
|
38
|
+
# A json, jsonb or array column: no rule value is one of its values.
|
|
39
|
+
# PostgreSQL raised on most texts compared with one, and the others
|
|
40
|
+
# (an array or JSON literal) compare a whole document; MySQL and SQLite
|
|
41
|
+
# compare text. A rule on such a column matches nothing under every
|
|
42
|
+
# built-in operator that takes a value — the null checks take none and
|
|
43
|
+
# work — and a custom operator gets the value as written.
|
|
44
|
+
OPAQUE = ->(_value) { UNUSABLE }
|
|
45
|
+
|
|
37
46
|
BY_TYPE = {
|
|
38
47
|
integer: NUMBER, float: NUMBER, decimal: NUMBER,
|
|
39
48
|
boolean: BOOLEAN,
|
|
40
49
|
string: TEXT, text: TEXT,
|
|
41
50
|
date: Temporal::DATE, time: Temporal::TIME,
|
|
42
|
-
datetime: Temporal::DATETIME, timestamp: Temporal::DATETIME, timestamptz: Temporal::DATETIME
|
|
51
|
+
datetime: Temporal::DATETIME, timestamp: Temporal::DATETIME, timestamptz: Temporal::DATETIME,
|
|
52
|
+
json: OPAQUE, jsonb: OPAQUE,
|
|
53
|
+
# Types only PostgreSQL has (PostgresqlTypes); no other adapter reports them.
|
|
54
|
+
uuid: PostgresqlTypes::UUID, inet: PostgresqlTypes::INET, cidr: PostgresqlTypes::CIDR,
|
|
55
|
+
enum: PostgresqlTypes::NativeEnum
|
|
43
56
|
}.freeze
|
|
44
57
|
|
|
45
|
-
# PostgreSQL is typed like the others, with
|
|
46
|
-
# always did them, so that no rule that works there stops working:
|
|
58
|
+
# PostgreSQL is typed like the others, with three differences:
|
|
47
59
|
#
|
|
48
60
|
# - It reads date and time text itself and raises on what it cannot
|
|
49
|
-
# read. Text the Temporal casters do not recognise is
|
|
50
|
-
# (
|
|
51
|
-
#
|
|
61
|
+
# read. Text the Temporal casters do not recognise is asked of it
|
|
62
|
+
# (PostgresqlTypes::ReadBack): what it reads is handed over
|
|
63
|
+
# (`due_on less_than '31 Jan 2026'`, `due_on equal 'today'`), anything
|
|
64
|
+
# else is UNUSABLE. On SQLite and MySQL, which compare or convert such
|
|
65
|
+
# text, it is UNUSABLE.
|
|
66
|
+
# - A column type no caster knows (hstore, money, interval, ...) is
|
|
67
|
+
# asked of it the same way ({ADAPTER_RULES}); `citext` is text.
|
|
52
68
|
# - A `real` column is compared in single precision (Number::SINGLE_PRECISION).
|
|
53
69
|
# The SQL type is the key: its ActiveRecord type is `float` as well.
|
|
54
|
-
POSTGRESQL = BY_TYPE.transform_values { |caster| caster.is_a?(Temporal) ? caster
|
|
55
|
-
.merge('real' => Number::SINGLE_PRECISION).freeze
|
|
70
|
+
POSTGRESQL = BY_TYPE.transform_values { |caster| caster.is_a?(Temporal) ? PostgresqlTypes::ReadBack.wrapping(caster) : caster }
|
|
71
|
+
.merge('real' => Number::SINGLE_PRECISION, 'citext' => TEXT).freeze
|
|
56
72
|
|
|
57
73
|
# Adapter name => the casters of that database, where they are not BY_TYPE.
|
|
58
74
|
BY_ADAPTER = { 'PostgreSQL' => POSTGRESQL, 'PostGIS' => POSTGRESQL }.freeze
|
|
59
75
|
|
|
76
|
+
# What a database's casters decide apart from the column types:
|
|
77
|
+
# - `unknown_type`: the caster of a column type none of its casters
|
|
78
|
+
# knows. UNTYPED (the value is left to the database) where a value of
|
|
79
|
+
# another notation is compared or converted; read back where the
|
|
80
|
+
# database raises on one.
|
|
81
|
+
# - `patterns`: the casters a LIKE pattern can be matched against, where
|
|
82
|
+
# not every type has LIKE. PostgreSQL has it for text alone (an
|
|
83
|
+
# integer, a date, a boolean raise there) — and a column nothing is
|
|
84
|
+
# known about (an alias a join gives) is left to it.
|
|
85
|
+
ADAPTER_RULES = {}.compare_by_identity.merge!(
|
|
86
|
+
POSTGRESQL => { unknown_type: PostgresqlTypes::ReadBack.wrapping(nil), patterns: [TEXT, UNTYPED].freeze }.freeze
|
|
87
|
+
).freeze
|
|
88
|
+
|
|
60
89
|
# The casters that read the two bounds of `between` together, where the
|
|
61
90
|
# others cast one bound at a time. With nothing known about the column
|
|
62
91
|
# the pair is read as it was before values were typed: both integers,
|
|
@@ -68,12 +97,31 @@ module PermissionForge
|
|
|
68
97
|
|
|
69
98
|
# @param column [ActiveRecord::ConnectionAdapters::Column, nil]
|
|
70
99
|
# @param adapter_name [String, nil] the adapter of the column's model
|
|
100
|
+
# @param model [Class, nil] the model whose table the column is in (a
|
|
101
|
+
# native enum's labels are read through it)
|
|
71
102
|
# @return [#call] the caster for the column
|
|
72
|
-
def self.for(column, adapter_name = nil)
|
|
73
|
-
return UNTYPED if column.nil?
|
|
103
|
+
def self.for(column, adapter_name = nil, model = nil)
|
|
104
|
+
return UNTYPED if column.nil?
|
|
105
|
+
return OPAQUE if column.try(:array?)
|
|
74
106
|
|
|
75
107
|
casters = BY_ADAPTER.fetch(adapter_name, BY_TYPE)
|
|
76
|
-
casters.fetch(column.sql_type) { casters.fetch(column.type,
|
|
108
|
+
caster = casters.fetch(column.sql_type) { casters.fetch(column.type) { ADAPTER_RULES.dig(casters, :unknown_type) || UNTYPED } }
|
|
109
|
+
caster.respond_to?(:of) ? caster.of(column, model) : caster
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Whether a LIKE pattern can be matched against a column of the
|
|
113
|
+
# caster's type. Not a json or array column, nor one of the PostgreSQL
|
|
114
|
+
# types without LIKE (uuid, inet, cidr, a native enum), nor — on
|
|
115
|
+
# PostgreSQL — anything but text ({ADAPTER_RULES}): a rule with a LIKE
|
|
116
|
+
# operator on one matches nothing.
|
|
117
|
+
#
|
|
118
|
+
# @param caster [#call] a caster {.for} returned
|
|
119
|
+
# @param adapter_name [String, nil] the adapter of the column's model
|
|
120
|
+
def self.pattern?(caster, adapter_name = nil)
|
|
121
|
+
only = ADAPTER_RULES.dig(BY_ADAPTER[adapter_name], :patterns)
|
|
122
|
+
return only.any? { |allowed| allowed.equal?(caster) } if only
|
|
123
|
+
|
|
124
|
+
!caster.equal?(OPAQUE) && PostgresqlTypes.pattern?(caster)
|
|
77
125
|
end
|
|
78
126
|
end
|
|
79
127
|
end
|
|
@@ -23,8 +23,16 @@ module PermissionForge
|
|
|
23
23
|
# Only the built-in operators are typed, each by the shape of its value
|
|
24
24
|
# ({SHAPES}). A custom operator's value is returned as it came, and a
|
|
25
25
|
# column nothing is known about leaves the value to the database. A rule
|
|
26
|
-
#
|
|
27
|
-
#
|
|
26
|
+
# with no column to compare with ({.no_column?}) is UNUSABLE: one whose
|
|
27
|
+
# field_name is not a name, whatever the operator, and one that names a
|
|
28
|
+
# column its table does not have, under a built-in operator. So is a
|
|
29
|
+
# value with a NUL byte, under any operator ({.unquotable?}): no SQL
|
|
30
|
+
# literal carries one — the database reads the statement cut short, and
|
|
31
|
+
# every holder's query failed instead of being denied. So is any value
|
|
32
|
+
# under a built-in operator that takes one, on a column that holds
|
|
33
|
+
# ciphertext ({.encrypted?}): the statement compares the stored
|
|
34
|
+
# ciphertext with the rule's text (Active Record's own query rewriting
|
|
35
|
+
# never runs on a raw comparison). Why a value is UNUSABLE is {.problem}.
|
|
28
36
|
module ValueTyping
|
|
29
37
|
UNUSABLE = ValueCasters::UNUSABLE
|
|
30
38
|
CURRENT_USER = 'current_user'
|
|
@@ -50,12 +58,73 @@ module PermissionForge
|
|
|
50
58
|
# @return [Object] what the operator's strategy binds: one value, an Array
|
|
51
59
|
# for `in` / `not_in` / `between`, or {UNUSABLE}
|
|
52
60
|
def call(operator, value, target)
|
|
53
|
-
return UNUSABLE if target
|
|
61
|
+
return UNUSABLE if no_column?(operator, target) || unquotable?(value) || encrypted?(operator, target)
|
|
54
62
|
|
|
55
63
|
shape = SHAPES[operator]
|
|
56
64
|
shape ? send(shape, value, target) : value
|
|
57
65
|
end
|
|
58
66
|
|
|
67
|
+
# Whether the value cannot be written into a statement as a literal: a
|
|
68
|
+
# String with a NUL byte (a row written past the model; ParamShapes
|
|
69
|
+
# drops NUL bytes from request parameters).
|
|
70
|
+
#
|
|
71
|
+
# @param value [Object] the rule's value
|
|
72
|
+
def unquotable?(value)
|
|
73
|
+
value.is_a?(String) && value.include?("\0")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Whether the rule has no column to compare with: its field_name is not a
|
|
77
|
+
# name (RuleColumn#malformed?), or — under a built-in operator — it names
|
|
78
|
+
# a column its table does not have (RuleColumn#missing?), which the
|
|
79
|
+
# database would reject. A custom operator's strategy decides what it
|
|
80
|
+
# does with the name, so a missing column is left to it.
|
|
81
|
+
#
|
|
82
|
+
# @param operator [String] the rule's operator
|
|
83
|
+
# @param target [RuleColumn] the column the rule names
|
|
84
|
+
def no_column?(operator, target)
|
|
85
|
+
target.malformed? || (target.missing? && Constants::OPERATORS.include?(operator))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Whether a built-in operator that takes a value ({SHAPES}) compares a
|
|
89
|
+
# column that holds ciphertext (RuleColumn#encrypted?). The null checks
|
|
90
|
+
# take no value and work: the ciphertext is NULL exactly when the value
|
|
91
|
+
# is. A custom operator's strategy decides for itself (a `where(attr:
|
|
92
|
+
# value)` of its own goes through the attribute's type).
|
|
93
|
+
#
|
|
94
|
+
# @param operator [String] the rule's operator
|
|
95
|
+
# @param target [RuleColumn] the column the rule names
|
|
96
|
+
def encrypted?(operator, target)
|
|
97
|
+
SHAPES.key?(operator) && target.encrypted?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Why the value is {UNUSABLE} for the column it is compared with, nil
|
|
101
|
+
# when it is not: the operator and the column alone decide the first
|
|
102
|
+
# two — :encrypted_column ({.encrypted?}), :pattern_on_non_text (a LIKE
|
|
103
|
+
# operator on a column no pattern can be matched against,
|
|
104
|
+
# RuleColumn#pattern?) —, else :unusable_value (a value the column
|
|
105
|
+
# cannot hold). For a column that was found (RuleProblem judges a
|
|
106
|
+
# missing one first).
|
|
107
|
+
#
|
|
108
|
+
# @param operator [String] the rule's operator
|
|
109
|
+
# @param value [Object] the rule's value, as {.call} takes it
|
|
110
|
+
# @param target [RuleColumn] the column it is compared with
|
|
111
|
+
# @return [Symbol, nil]
|
|
112
|
+
def problem(operator, value, target)
|
|
113
|
+
operator_problem(operator, target) || (:unusable_value if call(operator, value, target).equal?(UNUSABLE))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The reason the operator cannot be used on the column whatever the
|
|
117
|
+
# value (see {.problem}), or nil.
|
|
118
|
+
#
|
|
119
|
+
# @param operator [String] the rule's operator
|
|
120
|
+
# @param target [RuleColumn] the column it is compared with
|
|
121
|
+
# @return [Symbol, nil] :encrypted_column or :pattern_on_non_text
|
|
122
|
+
def operator_problem(operator, target)
|
|
123
|
+
return :encrypted_column if encrypted?(operator, target)
|
|
124
|
+
|
|
125
|
+
:pattern_on_non_text if SHAPES[operator] == :pattern && !target.column.nil? && !target.pattern?
|
|
126
|
+
end
|
|
127
|
+
|
|
59
128
|
# `current_user` stands for the user's id wherever one value is compared.
|
|
60
129
|
# In a list it is an element like any other.
|
|
61
130
|
def current_user?(operator, value)
|
|
@@ -78,9 +147,10 @@ module PermissionForge
|
|
|
78
147
|
caster(target).call(value)
|
|
79
148
|
end
|
|
80
149
|
|
|
81
|
-
# A LIKE pattern is built from the text of the value,
|
|
82
|
-
|
|
83
|
-
|
|
150
|
+
# A LIKE pattern is built from the text of the value, on a column a
|
|
151
|
+
# pattern can be matched against (ValueCasters.pattern?).
|
|
152
|
+
def pattern(value, target)
|
|
153
|
+
target.pattern? ? ValueCasters::TEXT.call(value) : UNUSABLE
|
|
84
154
|
end
|
|
85
155
|
|
|
86
156
|
# Every element has to be a value of the column; a list of none is no list.
|
|
@@ -100,6 +170,12 @@ module PermissionForge
|
|
|
100
170
|
together ? together.call(bounds) : all_typed(bounds, typed)
|
|
101
171
|
end
|
|
102
172
|
|
|
173
|
+
# The elements of a list or a range as written: the text split at every
|
|
174
|
+
# comma, each stripped (a trailing comma adds none). The rule's
|
|
175
|
+
# sentence splits them the same way.
|
|
176
|
+
#
|
|
177
|
+
# @param value [Object]
|
|
178
|
+
# @return [Array<String>]
|
|
103
179
|
def split(value)
|
|
104
180
|
value.to_s.split(',').map(&:strip)
|
|
105
181
|
end
|
|
@@ -109,7 +185,7 @@ module PermissionForge
|
|
|
109
185
|
typed.include?(UNUSABLE) ? UNUSABLE : typed
|
|
110
186
|
end
|
|
111
187
|
|
|
112
|
-
private_class_method :caster, :single, :pattern, :list, :pair, :
|
|
188
|
+
private_class_method :caster, :single, :pattern, :list, :pair, :all_typed
|
|
113
189
|
end
|
|
114
190
|
end
|
|
115
191
|
end
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PermissionForge
|
|
4
|
-
# Boot warnings for the three query-scoping levels (called by BootChecks
|
|
4
|
+
# Boot warnings for the three query-scoping levels (called by BootChecks;
|
|
5
|
+
# each goes through Warnings.emit).
|
|
5
6
|
#
|
|
6
7
|
# @api private
|
|
7
8
|
module QueryScopingWarnings
|
|
@@ -13,9 +14,10 @@ module PermissionForge
|
|
|
13
14
|
return unless pf_config.unscoped_query_detection == :raise
|
|
14
15
|
return unless pf_config.unscoped_query_detection_environments.include?('production')
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
Warnings.emit(:unscoped_detection_raise_in_production,
|
|
18
|
+
'WARNING: unscoped_query_detection = :raise is enabled for production. A single unscoped ' \
|
|
19
|
+
'query will raise UnscopedQueryError in live traffic — prefer :log in production and ' \
|
|
20
|
+
':raise in test/CI.')
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
# Level 1 applies filter-type rules only; join-type rules still require
|
|
@@ -23,13 +25,15 @@ module PermissionForge
|
|
|
23
25
|
def warn_join_rules_not_auto_scoped(pf_config)
|
|
24
26
|
return unless pf_config.auto_query_scoping_enabled?
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
# The join rules (PermissionSetRule#join_rule?, the one definition; the scope is the SQL pre-filter).
|
|
29
|
+
count = PermissionForge::PermissionSetRule.join_type_rules.pluck(:join_type)
|
|
30
|
+
.count { |join_type| !PermissionForge::PermissionSetRule.filter_join_type?(join_type) }
|
|
31
|
+
return if count.zero?
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
Warnings.emit(:join_rules_not_auto_scoped,
|
|
34
|
+
"auto_query_scoping is enabled but #{count} join-type rule(s) exist. Join rules " \
|
|
35
|
+
'are NOT applied by auto-scoping — queries relying on them must go through ' \
|
|
36
|
+
'Model.permission_scoped.', severity: :security)
|
|
33
37
|
rescue ActiveRecord::StatementInvalid
|
|
34
38
|
# Tables don't exist yet (pre-migration)
|
|
35
39
|
end
|
|
@@ -42,9 +46,10 @@ module PermissionForge
|
|
|
42
46
|
return unless pf_config.database_row_level_security
|
|
43
47
|
|
|
44
48
|
unless PermissionForge::RLS.postgres?
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
Warnings.emit(:rls_not_postgresql,
|
|
50
|
+
'database_row_level_security is enabled but the database adapter is not ' \
|
|
51
|
+
'PostgreSQL. RLS policies cannot be enforced — row-level security falls back to ' \
|
|
52
|
+
'application-level scoping only.', severity: :security)
|
|
48
53
|
return
|
|
49
54
|
end
|
|
50
55
|
|
|
@@ -57,10 +62,10 @@ module PermissionForge
|
|
|
57
62
|
stale = PermissionForge::RLS.drift_report.reject { |report| report[:in_sync] }
|
|
58
63
|
return if stale.empty?
|
|
59
64
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
Warnings.emit(:rls_policy_drift,
|
|
66
|
+
"RLS policies are missing or stale for table(s): #{stale.map { |report| report[:table] }.join(', ')}. " \
|
|
67
|
+
'Rule rows have changed since the last policy sync (or policies were never installed) — ' \
|
|
68
|
+
'run `rake permission_forge:rls:sync`.', severity: :security)
|
|
64
69
|
rescue ActiveRecord::StatementInvalid
|
|
65
70
|
# Tables don't exist yet (pre-migration)
|
|
66
71
|
end
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'rake_output'
|
|
4
|
+
|
|
3
5
|
module PermissionForge
|
|
4
6
|
# What every rake task of the gem does about the audit log: names who ran it
|
|
5
7
|
# (ACTOR=, stored as actor_label with actor_type 'system' and source 'rake'),
|
|
6
8
|
# records `report.generated` for the read-only ones, and logs through
|
|
7
|
-
# Rails.logger.
|
|
9
|
+
# Rails.logger. What the tasks print is RakeOutput's.
|
|
8
10
|
#
|
|
9
11
|
# PermissionForge::RakeAudit.changing('permission_forge:erasure') { ... } # warns without ACTOR
|
|
10
12
|
# PermissionForge::RakeAudit.reporting('permission_forge:audit', counts: ->(r) { r.counts }) { report }
|
|
@@ -52,9 +54,8 @@ module PermissionForge
|
|
|
52
54
|
def run(task, env, &)
|
|
53
55
|
actor = label(task, env)
|
|
54
56
|
log(task, "run by #{actor}")
|
|
55
|
-
PermissionForge::Audit::
|
|
56
|
-
|
|
57
|
-
)
|
|
57
|
+
context = { actor: PermissionForge::Audit::Actor.system(actor), source: 'rake', reason: env['REASON'].presence }
|
|
58
|
+
PermissionForge::Audit::Context.with(**context, &)
|
|
58
59
|
end
|
|
59
60
|
|
|
60
61
|
def missing_actor_warning(task)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# The one reading of DRY_RUN= for the gem's rake tasks that delete data
|
|
5
|
+
# (`permission_forge:erasure`, `permission_forge:audit_events:purge`). Only
|
|
6
|
+
# an explicit false (`false`, `0`, `no`, any case) runs for real; any other
|
|
7
|
+
# value (`true`, `1`, `yes`, a typo) is a dry run. Unset or empty, the task
|
|
8
|
+
# keeps its own default: the erasure is a dry run, the purge deletes (a
|
|
9
|
+
# scheduled purge must not silently stop).
|
|
10
|
+
#
|
|
11
|
+
# dry_run = PermissionForge::RakeDryRun.dry_run?(ENV.fetch('DRY_RUN', nil), default: true)
|
|
12
|
+
#
|
|
13
|
+
# The task reads the variable itself, so the rake lints see it read.
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
16
|
+
module RakeDryRun
|
|
17
|
+
# The values that run a task for real.
|
|
18
|
+
EXECUTE = %w[false 0 no].freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @param value [String, nil] what DRY_RUN= was set to
|
|
23
|
+
# @param default [Boolean] the answer when it is unset or empty
|
|
24
|
+
# @return [Boolean] whether the task only counts
|
|
25
|
+
def dry_run?(value, default:)
|
|
26
|
+
given = value.to_s.strip.downcase
|
|
27
|
+
return default if given.empty?
|
|
28
|
+
|
|
29
|
+
!EXECUTE.include?(given)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# What the gem's rake tasks print: one presenter for a run's result, so the
|
|
5
|
+
# terminal says what the summary audit event records — one line per key,
|
|
6
|
+
# named — the cache outcome (Cache::Outcome: confirmed, scope, unconfirmed)
|
|
7
|
+
# where the result carries one, and people named as the admin names them
|
|
8
|
+
# (Admin::PersonLabel), never "User 2". Rake output stays English.
|
|
9
|
+
#
|
|
10
|
+
# PermissionForge::RakeOutput.report("Erasure complete for #{RakeOutput.person(user)}:", result)
|
|
11
|
+
# PermissionForge::RakeOutput.people(ids) # => { "12" => "Rae Rake (id 12)" }
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
module RakeOutput
|
|
15
|
+
# The label of each key a service answers or an event records — one
|
|
16
|
+
# table: a key whose label says a change was made carries its dry-run
|
|
17
|
+
# form as a second label ([done, would]), so a dry run never prints
|
|
18
|
+
# "Audit events deleted" (field test round 7, E#55). The event key and
|
|
19
|
+
# the result key of one fact share a label (set_names / permission_sets),
|
|
20
|
+
# so a reader finds every event key in the output. A key without one is
|
|
21
|
+
# printed as its words.
|
|
22
|
+
LABELS = {
|
|
23
|
+
'assignments_removed' => ['Permission set assignments removed', 'Permission set assignments that would be removed'],
|
|
24
|
+
'assignments_count' => 'Permission set assignments',
|
|
25
|
+
'group_assignments_removed' => ['Group memberships removed', 'Group memberships that would be removed'],
|
|
26
|
+
'group_assignments_count' => 'Group memberships',
|
|
27
|
+
'assignments_remaining_elsewhere' => 'Assignments remaining in other tenants',
|
|
28
|
+
'group_assignments_remaining_elsewhere' => 'Group memberships remaining in other tenants',
|
|
29
|
+
'permission_sets' => 'Permission sets', 'set_names' => 'Permission sets',
|
|
30
|
+
'groups' => 'Groups', 'group_names' => 'Groups',
|
|
31
|
+
'actor_labels_cleared' => ['Audit log name labels cleared as actor', 'Audit log name labels that would be cleared as actor'],
|
|
32
|
+
'record_labels_cleared' => ['Audit log name labels cleared as subject', 'Audit log name labels that would be cleared as subject'],
|
|
33
|
+
'actor_labels_to_clear' => 'Audit log name labels to clear as actor',
|
|
34
|
+
'record_labels_to_clear' => 'Audit log name labels to clear as subject',
|
|
35
|
+
'events_scrubbed' => ['Audit events with a name label cleared', 'Audit events whose name label would be cleared'],
|
|
36
|
+
'callback_invoked' => 'on_erasure callback',
|
|
37
|
+
'removed' => ['Expired assignments removed', 'Expired assignments that would be removed'],
|
|
38
|
+
'expired_count' => 'Expired assignments',
|
|
39
|
+
'invalidated' => ['People whose cached permissions were invalidated', 'People whose cached permissions would be invalidated'],
|
|
40
|
+
'affected_user_ids' => 'People affected',
|
|
41
|
+
'strategy' => 'Strategy', 'users' => ['User visits invalidated', 'User visits that would be invalidated'], 'tenants' => 'Tenants',
|
|
42
|
+
'purged' => ['Audit events deleted', 'Audit events that would be deleted'],
|
|
43
|
+
'count' => ['Audit events deleted', 'Audit events that would be deleted'],
|
|
44
|
+
'cutoff' => ['Deleted when older than', 'Would delete the events older than'],
|
|
45
|
+
'period' => 'Retention period', 'period_seconds' => 'Retention period',
|
|
46
|
+
'retention_configured' => 'Retention configured',
|
|
47
|
+
'cache_confirmed' => ['Cached permissions removed', 'Cached permissions that would be removed'],
|
|
48
|
+
'process_local' => 'Cache scope', 'in_request' => 'Ran in',
|
|
49
|
+
'unconfirmed' => 'People the cache store did not confirm'
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
# Keys of a result the cache lines or the title say already.
|
|
53
|
+
SILENT = %w[cache_cleared dry_run].freeze
|
|
54
|
+
# Keys printed as people.
|
|
55
|
+
PEOPLE_KEYS = %w[affected_user_ids].freeze
|
|
56
|
+
PEOPLE_SHOWN = 50
|
|
57
|
+
STRATEGIES = { 'pattern' => 'pattern (the store removed the keys by pattern; users not counted)',
|
|
58
|
+
'enumerated' => 'enumerated (every user invalidated, in slices)' }.freeze
|
|
59
|
+
# "Cached permissions removed", by confirmed and by the store's scope: one
|
|
60
|
+
# outcome reads as one message, never as a success line contradicted by
|
|
61
|
+
# the scope line.
|
|
62
|
+
# :none — an operation that did no cache work (Cache::Outcome#none?: a dry run).
|
|
63
|
+
CONFIRMED = { true => 'yes (the cache store confirmed it)',
|
|
64
|
+
false => 'NO — the cache store did not confirm it; the old permissions may be served until cache_ttl',
|
|
65
|
+
nil => 'not known yet — the invalidation follows the commit of the surrounding transaction',
|
|
66
|
+
none: 'none — no cache work was done (a dry run changes nothing)' }.freeze
|
|
67
|
+
CONFIRMED_HERE = 'yes, in this process (its own store confirmed it; see Cache scope)'
|
|
68
|
+
CALLBACK = { true => 'ran', false => 'none configured', nil => 'runs when the surrounding transaction commits' }.freeze
|
|
69
|
+
# The "Cache scope" line of a process-local store (Cache::Outcome#process_local?) and of a shared one.
|
|
70
|
+
PROCESS_LOCAL_NOTE = 'this process only — an in-memory store (cache_backend :memory, or :memory_store) lives in each ' \
|
|
71
|
+
'process, so the web and job processes keep their cached permissions until they restart or the ' \
|
|
72
|
+
'entries expire (cache_ttl). Invalidate the users from inside those processes, or restart them.'
|
|
73
|
+
SHARED_NOTE = 'a shared store — every process sees the change; each user rebuilds on their next request.'
|
|
74
|
+
RAN_IN = { true => 'a request served by a web process', false => 'a process of its own (rake, runner, console or job)' }.freeze
|
|
75
|
+
NOT_IN_LIST = 'not in the people list'
|
|
76
|
+
|
|
77
|
+
module_function
|
|
78
|
+
|
|
79
|
+
# Prints the title and one line per key of `data` (a service's result,
|
|
80
|
+
# symbol or string keys); `cache:` (a Cache::Outcome or its Hash) prints
|
|
81
|
+
# as the cache lines. A dry run (the result's `dry_run: true`) prints
|
|
82
|
+
# each label in its "would" form.
|
|
83
|
+
#
|
|
84
|
+
# @param title [String]
|
|
85
|
+
# @param data [Hash]
|
|
86
|
+
# @param io [IO]
|
|
87
|
+
# @return [void]
|
|
88
|
+
def report(title, data, io: $stdout)
|
|
89
|
+
io.puts title
|
|
90
|
+
lines(data).each { |line| io.puts " #{line}" }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [Array<String>] "Label: value" per key
|
|
94
|
+
def lines(data)
|
|
95
|
+
data = data.transform_keys(&:to_s)
|
|
96
|
+
dry_run = data['dry_run'] == true
|
|
97
|
+
data.except(*SILENT).flat_map do |key, value|
|
|
98
|
+
key == 'cache' ? cache_lines(value, data, dry_run: dry_run) : ["#{label(key, dry_run: dry_run)}: #{format_value(key, value)}"]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @param key [String, Symbol]
|
|
103
|
+
# @param dry_run [Boolean] the label's "would" form, when it has one
|
|
104
|
+
# @return [String]
|
|
105
|
+
def label(key, dry_run: false)
|
|
106
|
+
labels = Array(LABELS.fetch(key.to_s) { key.to_s.tr('_', ' ').then { |words| words[0].to_s.upcase + words[1..].to_s } })
|
|
107
|
+
dry_run ? labels.last : labels.first
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @param user [Object, String, Integer] a host user, or an id
|
|
111
|
+
# @return [String] "Rae Rake (id 12)"
|
|
112
|
+
def person(user)
|
|
113
|
+
return people([user]).values.first if user.is_a?(String) || user.is_a?(Integer)
|
|
114
|
+
|
|
115
|
+
"#{person_name(user)} (id #{user.id})"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The people `ids` name, within config.user_list_resolver
|
|
119
|
+
# (Admin::PeopleDirectory, as the admin looks people up).
|
|
120
|
+
#
|
|
121
|
+
# @param ids [Array]
|
|
122
|
+
# @return [Hash{String => String}] id → "Name (id N)", or "id N (not in the people list)"
|
|
123
|
+
def people(ids)
|
|
124
|
+
found = Admin::PeopleDirectory.new.find_all(ids).index_by { |user| user.id.to_s }
|
|
125
|
+
ids.map(&:to_s).uniq.to_h do |id|
|
|
126
|
+
user = found[id]
|
|
127
|
+
[id, user ? person(user) : "id #{id} (#{NOT_IN_LIST})"]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def person_name(user)
|
|
132
|
+
label = Admin::PersonLabel.new(user)
|
|
133
|
+
name = label.name
|
|
134
|
+
name == I18n.t('permission_forge.shared.user_fallback', id: user.id) ? 'no name or email on record' : name
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The outcome's lines: what the store confirmed (in this process, when
|
|
138
|
+
# the store is process-local), the store's scope, where it ran, and the
|
|
139
|
+
# unconfirmed count unless the result prints its own — or, for an
|
|
140
|
+
# operation that did no cache work (Outcome#none?), that one line.
|
|
141
|
+
def cache_lines(value, data, dry_run: false)
|
|
142
|
+
outcome = Cache::Outcome.wrap(value)
|
|
143
|
+
removed = label('cache_confirmed', dry_run: dry_run)
|
|
144
|
+
return ["#{removed}: #{CONFIRMED.fetch(:none)}"] if outcome.none?
|
|
145
|
+
|
|
146
|
+
lines = ["#{removed}: #{confirmed_text(outcome)}",
|
|
147
|
+
"#{label('process_local')}: #{outcome.process_local? ? PROCESS_LOCAL_NOTE : SHARED_NOTE}",
|
|
148
|
+
"#{label('in_request')}: #{RAN_IN.fetch(outcome.in_request?)}"]
|
|
149
|
+
lines << "#{label('unconfirmed')}: #{plain(outcome.unconfirmed)}" unless data.key?('unconfirmed')
|
|
150
|
+
lines
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# What the store confirmed: in this process when the store is process-local.
|
|
154
|
+
def confirmed_text(outcome)
|
|
155
|
+
outcome.confirmed? && outcome.process_local? ? CONFIRMED_HERE : CONFIRMED.fetch(outcome.confirmed)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# One table of value shapes.
|
|
159
|
+
def format_value(key, value)
|
|
160
|
+
case key
|
|
161
|
+
when 'callback_invoked' then CALLBACK.fetch(value)
|
|
162
|
+
when 'strategy' then STRATEGIES.fetch(value.to_s, value.to_s)
|
|
163
|
+
when 'users' then value.nil? ? 'not counted (removed by key pattern)' : value.to_s
|
|
164
|
+
when 'period', 'period_seconds' then "#{value} seconds (#{(value.to_i / 86_400.0).round(1)} days)"
|
|
165
|
+
when *PEOPLE_KEYS then people_list(Array(value))
|
|
166
|
+
else plain(value)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def people_list(ids)
|
|
171
|
+
return 'none' if ids.empty?
|
|
172
|
+
|
|
173
|
+
names = people(ids.first(PEOPLE_SHOWN)).values
|
|
174
|
+
names << "and #{ids.size - PEOPLE_SHOWN} more" if ids.size > PEOPLE_SHOWN
|
|
175
|
+
"#{ids.size} — #{names.join(', ')}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def plain(value)
|
|
179
|
+
case value
|
|
180
|
+
when true then 'yes'
|
|
181
|
+
when false then 'no'
|
|
182
|
+
when nil then 'not known'
|
|
183
|
+
when Array then value.empty? ? 'none' : value.join(', ')
|
|
184
|
+
when Time, DateTime, ActiveSupport::TimeWithZone then value.iso8601
|
|
185
|
+
else value.to_s
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|