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
|
@@ -27,7 +27,10 @@ module PermissionForge
|
|
|
27
27
|
#
|
|
28
28
|
# With no current user every value is returned as stored, unless
|
|
29
29
|
# config.masked_attributes_nil_user_strategy = :hide, which hides every
|
|
30
|
-
# guarded attribute instead (the primary key and the raw paths excepted)
|
|
30
|
+
# guarded attribute instead (the primary key and the raw paths excepted),
|
|
31
|
+
# or :hide_in_requests, which does so inside a web request only
|
|
32
|
+
# (PermissionForge::WebRequest). Under :bypass a value returned as stored
|
|
33
|
+
# inside a web request is recorded (field.unmasked_without_user).
|
|
31
34
|
#
|
|
32
35
|
# @example Include in a model
|
|
33
36
|
# class Customer < ApplicationRecord
|
|
@@ -105,8 +108,10 @@ module PermissionForge
|
|
|
105
108
|
# (`generated_attribute_methods`) and includes it in the model. Calling
|
|
106
109
|
# `customer.email` hits that fast path, bypassing `read_attribute`. We
|
|
107
110
|
# prepend an override module so direct getters (and setters, for the
|
|
108
|
-
# write-back guard) route through our logic
|
|
109
|
-
#
|
|
111
|
+
# write-back guard) route through our logic: one per column the model
|
|
112
|
+
# has now, and one per column Rails finds each time it defines the
|
|
113
|
+
# attribute methods again (define_attribute_methods below: a column a
|
|
114
|
+
# migration added after the include, once the schema is read again).
|
|
110
115
|
#
|
|
111
116
|
# A getter raises ActiveModel::MissingAttributeError for a column that
|
|
112
117
|
# was not loaded (`select(:id)`), as the getter Rails generates does.
|
|
@@ -116,16 +121,7 @@ module PermissionForge
|
|
|
116
121
|
# the tenant model when computing cache keys). Masking `id` would create
|
|
117
122
|
# a recursion: `id` → guard → `permissions_hash` → `current_tenant.id`.
|
|
118
123
|
masked_attribute_module = ColumnAccessors.new
|
|
119
|
-
|
|
120
|
-
columns_hash.each_key do |col_name|
|
|
121
|
-
next if col_name.to_s == primary_key
|
|
122
|
-
|
|
123
|
-
attr_sym = col_name.to_sym
|
|
124
|
-
masked_attribute_module.define_method(attr_sym) { read_attribute(attr_sym) { |name| missing_attribute(name, caller) } }
|
|
125
|
-
masked_attribute_module.define_method("#{attr_sym}=") do |value|
|
|
126
|
-
next super(value) unless pf_skip_masked_write?(attr_sym, value)
|
|
127
|
-
end
|
|
128
|
-
end
|
|
124
|
+
PermissionForge::Concerns::MaskedAttributes.define_column_accessors(self, masked_attribute_module)
|
|
129
125
|
prepend(masked_attribute_module)
|
|
130
126
|
|
|
131
127
|
const_set(:MaskedAttributeReaders, masked_attribute_module)
|
|
@@ -139,6 +135,50 @@ module PermissionForge
|
|
|
139
135
|
@_pf_masked_attributes_applied = true
|
|
140
136
|
end
|
|
141
137
|
|
|
138
|
+
class_methods do
|
|
139
|
+
# Rails defines a model's attribute methods lazily, and again after
|
|
140
|
+
# the schema is read again (reset_column_information — what
|
|
141
|
+
# PermissionForge::SchemaRead does when the database's columns
|
|
142
|
+
# changed): each time, a column the guarded module lacks gets its
|
|
143
|
+
# guarded getter and setter, so `record.new_column` answers what
|
|
144
|
+
# `record[:new_column]` and `as_json` answer. A subclass asks its
|
|
145
|
+
# parent first (Rails), whose module then has the method: Rails
|
|
146
|
+
# generates none of its own for the subclass.
|
|
147
|
+
def define_attribute_methods
|
|
148
|
+
super.tap { |generated| pf_define_late_column_accessors if generated }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def pf_define_late_column_accessors
|
|
154
|
+
return unless const_defined?(:MaskedAttributeReaders, false)
|
|
155
|
+
|
|
156
|
+
PermissionForge::Concerns::MaskedAttributes.define_column_accessors(self, const_get(:MaskedAttributeReaders, false))
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Defines on `accessors` a guarded getter and setter for every column
|
|
161
|
+
# of `klass` it does not have yet (the primary key never: it is used by
|
|
162
|
+
# routing, associations and tenant scoping). The columns are the ones
|
|
163
|
+
# Rails just loaded for the model (`columns_hash`, ignored columns
|
|
164
|
+
# excluded): those it generates methods for.
|
|
165
|
+
#
|
|
166
|
+
# @param klass [Class]
|
|
167
|
+
# @param accessors [ColumnAccessors]
|
|
168
|
+
# @return [void]
|
|
169
|
+
def self.define_column_accessors(klass, accessors)
|
|
170
|
+
primary_key = klass.primary_key&.to_s
|
|
171
|
+
klass.columns_hash.each_key do |col_name|
|
|
172
|
+
next if col_name.to_s == primary_key || accessors.method_defined?(col_name, false)
|
|
173
|
+
|
|
174
|
+
attr_sym = col_name.to_sym
|
|
175
|
+
accessors.define_method(attr_sym) { read_attribute(attr_sym) { |name| missing_attribute(name, caller) } }
|
|
176
|
+
accessors.define_method("#{attr_sym}=") do |value|
|
|
177
|
+
next super(value) unless pf_skip_masked_write?(attr_sym, value)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
142
182
|
# Whether MaskedAttributes has already been applied to a class. Used by the
|
|
143
183
|
# engine's auto-apply to avoid re-prepending the readers module.
|
|
144
184
|
def self.applied?(klass)
|
|
@@ -206,8 +246,7 @@ module PermissionForge
|
|
|
206
246
|
masked_attributes_applied: applied_in_chain?(klass),
|
|
207
247
|
primary_key: klass.primary_key,
|
|
208
248
|
is_primary_key: klass.primary_key.to_s == attr_name.to_s,
|
|
209
|
-
current_user_present: !user.nil?,
|
|
210
|
-
masked_attributes_nil_user_strategy: config.masked_attributes_nil_user_strategy, user_model_exempt: config.user_model_class?(klass),
|
|
249
|
+
current_user_present: !user.nil?, **explain_without_user(config, klass),
|
|
211
250
|
user_responds_to_field_permissions: user.respond_to?(:field_permissions),
|
|
212
251
|
resource_in_permissions_hash: entries?(user, chain),
|
|
213
252
|
field_configured: !field.nil?,
|
|
@@ -219,6 +258,13 @@ module PermissionForge
|
|
|
219
258
|
}
|
|
220
259
|
end
|
|
221
260
|
|
|
261
|
+
# What explain reports about reads with nobody signed in.
|
|
262
|
+
def self.explain_without_user(config, klass)
|
|
263
|
+
{ masked_attributes_nil_user_strategy: config.masked_attributes_nil_user_strategy,
|
|
264
|
+
user_model_exempt: config.user_model_class?(klass), in_web_request: PermissionForge::WebRequest.active? }
|
|
265
|
+
end
|
|
266
|
+
private_class_method :explain_without_user
|
|
267
|
+
|
|
222
268
|
# Whether the user's hash has a resource entry along the chain.
|
|
223
269
|
def self.entries?(user, chain)
|
|
224
270
|
user.respond_to?(:permissions_hash) && PermissionForge::ResourceChain.entries(user, chain).any?
|
|
@@ -280,12 +326,13 @@ module PermissionForge
|
|
|
280
326
|
private_class_method :decide
|
|
281
327
|
|
|
282
328
|
# What the guard does with no user (config.masked_attributes_nil_user_strategy;
|
|
283
|
-
# the user model is exempt from :hide).
|
|
329
|
+
# the user model is exempt from :hide and :hide_in_requests).
|
|
284
330
|
def self.without_user_decision(klass)
|
|
285
331
|
config = PermissionForge.configuration
|
|
286
|
-
return :
|
|
332
|
+
return :no_user_hidden if config.hide_masked_attributes_without_user_for?(klass)
|
|
333
|
+
return :no_user_user_model_exempt if config.hide_masked_attributes_without_user_in_requests? && config.user_model_class?(klass)
|
|
287
334
|
|
|
288
|
-
|
|
335
|
+
:no_user_bypassed
|
|
289
336
|
end
|
|
290
337
|
private_class_method :without_user_decision
|
|
291
338
|
|
|
@@ -304,6 +351,13 @@ module PermissionForge
|
|
|
304
351
|
end
|
|
305
352
|
private_class_method :print_explain_table
|
|
306
353
|
|
|
354
|
+
# Forgets the attributes read as hidden with nobody signed in
|
|
355
|
+
# (pf_hidden_without_user_reads): the record holds fresh values.
|
|
356
|
+
def reload(*)
|
|
357
|
+
pf_forget_hidden_without_user_reads
|
|
358
|
+
super
|
|
359
|
+
end
|
|
360
|
+
|
|
307
361
|
# Reads an attribute through the field permissions of the current user.
|
|
308
362
|
# @param attr_name [String, Symbol]
|
|
309
363
|
def read_attribute(attr_name, &)
|
|
@@ -315,8 +369,12 @@ module PermissionForge
|
|
|
315
369
|
attr_name = name = pf_alias_end(target) if target
|
|
316
370
|
return raw if pf_read_as_stored?(name)
|
|
317
371
|
|
|
318
|
-
|
|
319
|
-
value =
|
|
372
|
+
user = pf_guarded_user
|
|
373
|
+
include_field, value = pf_guard(attr_name, raw, user)
|
|
374
|
+
unless include_field
|
|
375
|
+
value = PermissionForge.configuration.hidden_attribute_value
|
|
376
|
+
pf_note_hidden_without_user(name, raw) unless user
|
|
377
|
+
end
|
|
320
378
|
# A returned value that differs from the raw column value was masked or
|
|
321
379
|
# hidden; remember the exact sentinel so a write-back of it can be refused.
|
|
322
380
|
pf_record_masked_read(name, value) unless include_field && value == raw
|
|
@@ -325,13 +383,15 @@ module PermissionForge
|
|
|
325
383
|
|
|
326
384
|
# The user is resolved once for the whole hash. With no user every
|
|
327
385
|
# value is returned as stored (or hidden, under
|
|
328
|
-
# config.masked_attributes_nil_user_strategy = :hide
|
|
329
|
-
# (bypassed) are still published,
|
|
330
|
-
# when someone listens
|
|
386
|
+
# config.masked_attributes_nil_user_strategy = :hide, and :hide_in_requests
|
|
387
|
+
# inside a web request); the decisions (bypassed) are still published,
|
|
388
|
+
# as for a getter or as_json, but only when someone listens, and each
|
|
389
|
+
# attribute returned as stored inside a web request is recorded
|
|
390
|
+
# (field.unmasked_without_user).
|
|
331
391
|
def attributes
|
|
332
392
|
raw = super
|
|
333
393
|
user = pf_guarded_user
|
|
334
|
-
return raw unless user ||
|
|
394
|
+
return raw unless user || pf_guard_without_user? || PermissionForge::Audit::FieldAccess::GATE.open?
|
|
335
395
|
|
|
336
396
|
PermissionForge::Audit::FieldAccess.read(self, self.class.name, record: self) do
|
|
337
397
|
pf_guard_attributes_hash(raw, user)
|
|
@@ -440,14 +500,25 @@ module PermissionForge
|
|
|
440
500
|
name == self.class.primary_key || pf_raw_store_column?(name) || name == pf_validation_read
|
|
441
501
|
end
|
|
442
502
|
|
|
443
|
-
# config.masked_attributes_nil_user_strategy = :hide
|
|
444
|
-
#
|
|
445
|
-
#
|
|
446
|
-
# signed in.
|
|
503
|
+
# config.masked_attributes_nil_user_strategy = :hide (and
|
|
504
|
+
# :hide_in_requests inside a web request) — with no user every guarded
|
|
505
|
+
# attribute is hidden (AttributeAccessGuard#without_user), except on
|
|
506
|
+
# the user model, which sign-in reads before anyone is signed in.
|
|
447
507
|
def pf_hide_without_user?
|
|
448
508
|
PermissionForge.configuration.hide_masked_attributes_without_user_for?(self.class)
|
|
449
509
|
end
|
|
450
510
|
|
|
511
|
+
# Whether a read with no user goes through the guard attribute by
|
|
512
|
+
# attribute: it hides them, or records them (inside a web request
|
|
513
|
+
# under :bypass, while there is something left to record).
|
|
514
|
+
def pf_guard_without_user?
|
|
515
|
+
case PermissionForge.configuration.masked_attributes_without_user_for(self.class)
|
|
516
|
+
when :hide then true
|
|
517
|
+
when :record then PermissionForge::Audit::UnmaskedWithoutUser.pending?(self.class)
|
|
518
|
+
else false
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
451
522
|
# Every guarded read of this record is one field.access read
|
|
452
523
|
# (Audit::FieldAccess), joined by the reads made inside it. Nobody
|
|
453
524
|
# listening (the gate is shut): one check, nothing collected.
|
|
@@ -491,12 +562,45 @@ module PermissionForge
|
|
|
491
562
|
pf_masked_reads[attr_name.to_s] = sentinel
|
|
492
563
|
end
|
|
493
564
|
|
|
565
|
+
# The attributes this instance read as hidden because nobody was signed
|
|
566
|
+
# in (masked_attributes_nil_user_strategy :hide, or :hide_in_requests in a
|
|
567
|
+
# web request) while they held a value. Whatever is written to one of
|
|
568
|
+
# them afterwards was worked out from the hidden value, or replaces a
|
|
569
|
+
# value nobody could see: pf_skip_masked_write? treats it as a
|
|
570
|
+
# write-back. Only something real counts: a value the record holds in
|
|
571
|
+
# memory, stored or assigned (an empty column, or an attribute of a new
|
|
572
|
+
# record nobody filled, hides nothing: a default or a fill-if-blank is
|
|
573
|
+
# written; a value submitted on a new record is kept from a normalizer
|
|
574
|
+
# that reads it). The value is the one just read, not
|
|
575
|
+
# attribute_in_database, which would build the record's mutation
|
|
576
|
+
# tracker on every hidden read. A debugging read (inspect) does not
|
|
577
|
+
# count. Forgotten on reload only: a save does not make the hidden read
|
|
578
|
+
# visible, so a value derived from it before the save is still refused
|
|
579
|
+
# after it.
|
|
580
|
+
def pf_hidden_without_user_reads
|
|
581
|
+
@pf_hidden_without_user_reads ||= Set.new
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def pf_note_hidden_without_user(attr_name, value)
|
|
585
|
+
return if value.nil? || PermissionForge::ExecutionState[INSPECT_KEY]
|
|
586
|
+
|
|
587
|
+
pf_hidden_without_user_reads << attr_name.to_s
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def pf_forget_hidden_without_user_reads
|
|
591
|
+
@pf_hidden_without_user_reads = nil
|
|
592
|
+
end
|
|
593
|
+
|
|
494
594
|
# True when the guard should suppress this write because `value` is a masked/
|
|
495
595
|
# hidden sentinel being written back over real data. Honors
|
|
496
596
|
# config.masked_write_protection. Two detections:
|
|
497
597
|
# 1. Ledger — this instance handed out that exact sentinel earlier (covers
|
|
498
598
|
# same-instance read-modify-write, e.g. a before_save callback).
|
|
499
|
-
# 2.
|
|
599
|
+
# 2. Hidden without a user — this instance read the attribute as hidden
|
|
600
|
+
# because nobody was signed in: any value written to it was derived
|
|
601
|
+
# from the hidden one (`self.name = name.to_s.strip` stores "") or
|
|
602
|
+
# replaces a value nobody could see.
|
|
603
|
+
# 3. Stateless — `value` equals what the mask would produce for the value
|
|
500
604
|
# CURRENTLY persisted in the DB. This survives a request boundary: a form
|
|
501
605
|
# rendered "********" and submitted back onto a freshly-loaded record has
|
|
502
606
|
# no ledger entry, but still matches the persisted value's mask.
|
|
@@ -504,19 +608,29 @@ module PermissionForge
|
|
|
504
608
|
protection = PermissionForge.configuration.masked_write_protection
|
|
505
609
|
return false if protection == :off
|
|
506
610
|
|
|
507
|
-
|
|
508
|
-
return false unless
|
|
611
|
+
detected_by = pf_masked_write_detection(attr_name.to_s, value)
|
|
612
|
+
return false unless detected_by
|
|
509
613
|
|
|
510
|
-
pf_masked_write_refused(attr_name, protection,
|
|
614
|
+
pf_masked_write_refused(attr_name, protection, detected_by)
|
|
615
|
+
PermissionForge::Audit::UnmaskedWithoutUser.write_refused(self.class, attr_name, protection) if detected_by == 'hidden_without_user'
|
|
511
616
|
raise MaskedWriteError, attr_name if protection == :raise
|
|
512
617
|
|
|
513
618
|
true # :skip — preserve the stored value by not calling super
|
|
514
619
|
end
|
|
515
620
|
|
|
621
|
+
# How a write was recognised as a write-back (write.masked_skipped
|
|
622
|
+
# `detected_by`), nil for a write that is not one.
|
|
623
|
+
def pf_masked_write_detection(name, value)
|
|
624
|
+
return 'ledger' if pf_masked_reads.key?(name) && pf_masked_reads[name] == value
|
|
625
|
+
return 'hidden_without_user' if pf_hidden_without_user_reads.include?(name)
|
|
626
|
+
|
|
627
|
+
'persisted_value' if pf_matches_persisted_sentinel?(name, value)
|
|
628
|
+
end
|
|
629
|
+
|
|
516
630
|
# write.masked_skipped: which attribute, how it was refused (:skip
|
|
517
631
|
# keeps the stored value, :raise raised) and how the sentinel was
|
|
518
632
|
# recognised. Never the value.
|
|
519
|
-
def pf_masked_write_refused(attr_name, protection,
|
|
633
|
+
def pf_masked_write_refused(attr_name, protection, detected_by)
|
|
520
634
|
event = 'write.masked_skipped'
|
|
521
635
|
return unless PermissionForge::Audit.instrument?(event)
|
|
522
636
|
|
|
@@ -524,7 +638,7 @@ module PermissionForge
|
|
|
524
638
|
PermissionForge::Audit.instrument(event) do
|
|
525
639
|
{ record: self, subject_user_id: PermissionForge::Audit::Runtime.user_id(user),
|
|
526
640
|
data: { 'model' => self.class.name, 'attribute' => attr_name.to_s, 'protection' => protection.to_s,
|
|
527
|
-
'detected_by' =>
|
|
641
|
+
'detected_by' => detected_by },
|
|
528
642
|
objects: { user: user, record: self } }
|
|
529
643
|
end
|
|
530
644
|
end
|
|
@@ -562,6 +676,8 @@ module PermissionForge
|
|
|
562
676
|
|
|
563
677
|
include_field, masked = pf_guard(key, value, user)
|
|
564
678
|
next [key, masked] if include_field
|
|
679
|
+
|
|
680
|
+
pf_note_hidden_without_user(key, value) unless user
|
|
565
681
|
next if omit_hidden
|
|
566
682
|
|
|
567
683
|
[key, hidden_value]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Concerns
|
|
5
|
+
# The one definition of what a blank label is stored as, for every model
|
|
6
|
+
# with a `label` column: a field row, an action row and a Resource. A
|
|
7
|
+
# label that is nil, empty or only whitespace is "no label", and so is
|
|
8
|
+
# the install migration's column default on a new row ('Untitled Field');
|
|
9
|
+
# such a label is stored as #generated_label — on create and on update,
|
|
10
|
+
# in code, through the JSON API and in the admin alike.
|
|
11
|
+
#
|
|
12
|
+
# A field or action row is labelled with its name (what the gem writes
|
|
13
|
+
# for a row it generates, and what PermissionForge::GeneratedText reads
|
|
14
|
+
# as no label); the field's column is NOT NULL, so nil cannot stand for
|
|
15
|
+
# "no label" there. A Resource stores nil (its label column is nullable
|
|
16
|
+
# and the admin names a model from the host's translation).
|
|
17
|
+
#
|
|
18
|
+
# A rename that leaves a label the gem would generate for the old name
|
|
19
|
+
# (the name or its humanized form: GeneratedText.label?) relabels the row
|
|
20
|
+
# with its new name: the old name would read as a label an admin wrote.
|
|
21
|
+
#
|
|
22
|
+
# Checked on create, when the label changes and on a rename only: a row
|
|
23
|
+
# stored with a blank label before keeps it through a save of its other
|
|
24
|
+
# attributes (no label change in that save's audit event).
|
|
25
|
+
#
|
|
26
|
+
# Field test round 8 (D#25): through the API a field label `null` was a
|
|
27
|
+
# 422 "can't be blank" while `""` was stored, and an action stored both
|
|
28
|
+
# as sent — the admin stored a blanked label as the name.
|
|
29
|
+
#
|
|
30
|
+
# @api private
|
|
31
|
+
module NormalizesLabel
|
|
32
|
+
extend ActiveSupport::Concern
|
|
33
|
+
|
|
34
|
+
included do
|
|
35
|
+
before_validation :normalize_label
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# What a label `text` written to this row is stored as: a blank one
|
|
39
|
+
# (nil, empty, white space) as no label — the row's name (a
|
|
40
|
+
# Resource: nil) — anything else as written. The admin's review asks
|
|
41
|
+
# it for the value it shows.
|
|
42
|
+
#
|
|
43
|
+
# @param text [String, nil]
|
|
44
|
+
# @return [String, nil]
|
|
45
|
+
def label_to_store(text)
|
|
46
|
+
text.to_s.strip.empty? ? generated_label : text
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# The label "no label" is stored as: the row's name.
|
|
52
|
+
#
|
|
53
|
+
# @return [String, nil]
|
|
54
|
+
def generated_label
|
|
55
|
+
name.to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def normalize_label
|
|
59
|
+
return unless has_attribute?(:label) && label_check_due?
|
|
60
|
+
|
|
61
|
+
self.label = unwritten_label? ? generated_label : label_to_store(label)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def label_check_due?
|
|
65
|
+
new_record? || will_save_change_to_label? || will_save_change_to_name?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# On a new row the column default; on a rename, a label generated for
|
|
69
|
+
# the old name that this save leaves as it is.
|
|
70
|
+
def unwritten_label?
|
|
71
|
+
return label == self.class.column_defaults['label'] if new_record?
|
|
72
|
+
|
|
73
|
+
will_save_change_to_name? && !will_save_change_to_label? && !generated_label.nil? &&
|
|
74
|
+
PermissionForge::GeneratedText.label?(label, name_in_database)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -389,9 +389,9 @@ module PermissionForge
|
|
|
389
389
|
# @return [Array<PermissionForge::PermissionSet>]
|
|
390
390
|
def pf_active_non_expired_permission_sets
|
|
391
391
|
active_psu_ids = permission_set_users.active_assignments.select(:permission_set_id)
|
|
392
|
-
# Deterministic order so "first mask_strategy wins" in
|
|
393
|
-
# is stable across requests and DB adapters
|
|
394
|
-
PermissionForge::PermissionSet.active.where(id: active_psu_ids).
|
|
392
|
+
# Deterministic order (PermissionSet.in_hash_order) so "first mask_strategy wins" in
|
|
393
|
+
# FieldPermissionMerger is stable across requests and DB adapters.
|
|
394
|
+
PermissionForge::PermissionSet.active.where(id: active_psu_ids).in_hash_order.to_a
|
|
395
395
|
end
|
|
396
396
|
|
|
397
397
|
# The 0.13 name of pf_active_non_expired_permission_sets, kept for host
|
|
@@ -8,7 +8,11 @@ module PermissionForge
|
|
|
8
8
|
# `find_by!` apply the user's cached permission set rules before returning a
|
|
9
9
|
# record, and `permission_scoped` returns a rule-constrained relation you can
|
|
10
10
|
# chain (`.where`, `.first`, `.find`, `.count`, …). If view_all or modify_all
|
|
11
|
-
# is enabled for the resource, rules are bypassed entirely.
|
|
11
|
+
# is enabled for the resource, rules are bypassed entirely. A signed-in
|
|
12
|
+
# user the model's Resource chain grants no read_access gets the rules
|
|
13
|
+
# alone under config.no_read_access_strategy :rules_only (the default:
|
|
14
|
+
# no rules, every row) and no rows under :deny (PermissionForge::
|
|
15
|
+
# RecordReadAccess, asked before the rules).
|
|
12
16
|
#
|
|
13
17
|
# Level 1 — ENFORCE (config.auto_query_scoping = :filter_only): a
|
|
14
18
|
# default_scope applies the current user's FILTER-type rules to every
|
|
@@ -87,14 +91,17 @@ module PermissionForge
|
|
|
87
91
|
class << self
|
|
88
92
|
# `find_by` constrained by the current user's row-level rules.
|
|
89
93
|
# @return [ActiveRecord::Base, nil] nil for a missing record
|
|
90
|
-
# @raise [NotAuthorizedError] when a rule
|
|
94
|
+
# @raise [NotAuthorizedError] when a rule (or, under
|
|
95
|
+
# no_read_access_strategy :deny, the missing read access) denies an
|
|
96
|
+
# existing record
|
|
91
97
|
def find_by(*args)
|
|
92
98
|
find_by_with_permission(*args)
|
|
93
99
|
end
|
|
94
100
|
|
|
95
101
|
# `find_by!` constrained by the current user's row-level rules.
|
|
96
102
|
# @raise [ActiveRecord::RecordNotFound] for a missing record
|
|
97
|
-
# @raise [NotAuthorizedError] when a rule
|
|
103
|
+
# @raise [NotAuthorizedError] when a rule (or the missing read access)
|
|
104
|
+
# denies an existing record
|
|
98
105
|
def find_by!(*args)
|
|
99
106
|
find_by_with_permission(*args) || raise(ActiveRecord::RecordNotFound)
|
|
100
107
|
end
|
|
@@ -102,13 +109,16 @@ module PermissionForge
|
|
|
102
109
|
end
|
|
103
110
|
|
|
104
111
|
# Raised when a row-level rule denies an existing record (find_by /
|
|
105
|
-
# find_by!)
|
|
112
|
+
# find_by!), when the user has no read access to the model under
|
|
113
|
+
# no_read_access_strategy :deny, or there is no user under
|
|
114
|
+
# nil_user_strategy :raise.
|
|
106
115
|
#
|
|
107
116
|
# `user` is the user the rules were applied for, `record_id` the id of
|
|
108
117
|
# the record that was denied. `record` is nil when the gem raises: the
|
|
109
118
|
# row the user may not see is not loaded to be handed out with the
|
|
110
|
-
# error.
|
|
111
|
-
# and `raise NotAuthorizedError,
|
|
119
|
+
# error. They and `failure_messages` are optional keywords;
|
|
120
|
+
# `NotAuthorizedError.new(details)` and `raise NotAuthorizedError,
|
|
121
|
+
# details` work as before.
|
|
112
122
|
#
|
|
113
123
|
# `details` keeps whatever was raised with (the gem raises the Array of
|
|
114
124
|
# rule failure messages; a Hash with :message for a missing user).
|
|
@@ -116,14 +126,43 @@ module PermissionForge
|
|
|
116
126
|
# error reporter and `rescue_from` handler expects: the failure
|
|
117
127
|
# messages joined, the :message of a Hash, or the class name.
|
|
118
128
|
#
|
|
129
|
+
# `failure_messages` is what the denying rules say on their own (each
|
|
130
|
+
# rule's `failure_message`, as an admin wrote it, or the gem's default
|
|
131
|
+
# sentence for a rule without one), for a friendly 403 without parsing
|
|
132
|
+
# `message`.
|
|
133
|
+
#
|
|
134
|
+
# Unless the host rescues it, Rails answers this error with 403
|
|
135
|
+
# Forbidden (the engine registers it in
|
|
136
|
+
# `config.action_dispatch.rescue_responses`).
|
|
137
|
+
#
|
|
138
|
+
# @example A friendly 403
|
|
139
|
+
# rescue_from PermissionForge::Concerns::PermissionScopedFindable::NotAuthorizedError do |error|
|
|
140
|
+
# render json: { error: error.failure_messages.first || 'Forbidden' }, status: :forbidden
|
|
141
|
+
# end
|
|
142
|
+
#
|
|
119
143
|
# @api public
|
|
120
144
|
class NotAuthorizedError < StandardError
|
|
121
145
|
# Why the lookup was refused.
|
|
122
146
|
# @return [Hash, nil]
|
|
123
147
|
attr_reader :details, :user, :record, :record_id
|
|
124
148
|
|
|
149
|
+
# The `failure_message` of each rule that denied the record, in rule
|
|
150
|
+
# order, without repeats (two Permission Sets may carry the same
|
|
151
|
+
# rule); a rule left without one says the gem's own sentence in the
|
|
152
|
+
# current locale (`permission_forge.denials.rule_default`:
|
|
153
|
+
# `Services::PermissionRuleApplier.failure_message_for`). A user
|
|
154
|
+
# without read access under no_read_access_strategy :deny gets one
|
|
155
|
+
# sentence, `permission_forge.denials.no_read_access`. Empty when
|
|
156
|
+
# no rule denied: nobody signed in under `nil_user_strategy = :raise`,
|
|
157
|
+
# or an error the host raised without `failure_messages:`. Rule
|
|
158
|
+
# messages are written by admins: decide whether they belong in a
|
|
159
|
+
# response.
|
|
160
|
+
#
|
|
161
|
+
# @return [Array<String>] frozen
|
|
162
|
+
attr_reader :failure_messages
|
|
163
|
+
|
|
125
164
|
NO_DETAILS = Object.new.freeze
|
|
126
|
-
READERS = %i[user record record_id].freeze
|
|
165
|
+
READERS = %i[user record record_id failure_messages].freeze
|
|
127
166
|
private_constant :NO_DETAILS, :READERS
|
|
128
167
|
|
|
129
168
|
# The String a details value reads as.
|
|
@@ -136,9 +175,9 @@ module PermissionForge
|
|
|
136
175
|
end
|
|
137
176
|
end
|
|
138
177
|
|
|
139
|
-
# The :message of a Hash, else the Hash without the user, record
|
|
140
|
-
# record_id keywords (a user's inspect must not
|
|
141
|
-
# tracker), else nothing.
|
|
178
|
+
# The :message of a Hash, else the Hash without the user, record,
|
|
179
|
+
# record_id and failure_messages keywords (a user's inspect must not
|
|
180
|
+
# land in an error tracker), else nothing.
|
|
142
181
|
def self.hash_text(details)
|
|
143
182
|
text = text_for(details[:message] || details['message'])
|
|
144
183
|
return text if text
|
|
@@ -148,12 +187,22 @@ module PermissionForge
|
|
|
148
187
|
end
|
|
149
188
|
private_class_method :hash_text
|
|
150
189
|
|
|
151
|
-
#
|
|
152
|
-
#
|
|
190
|
+
# The failure_messages: keyword as a frozen Array of distinct,
|
|
191
|
+
# non-blank Strings.
|
|
192
|
+
#
|
|
193
|
+
# @api private
|
|
194
|
+
# @return [Array<String>]
|
|
195
|
+
def self.message_list(value)
|
|
196
|
+
Array(value).filter_map { |text| text.to_s.presence }.uniq.freeze
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Keywords other than user:, record:, record_id: and failure_messages:
|
|
200
|
+
# are details, as they always were (`new(message: 'x')`); without a positional
|
|
153
201
|
# argument every keyword given stays in details too, so a host's
|
|
154
202
|
# `new(record: r, message: 'x')` reads as before.
|
|
155
203
|
def initialize(details = NO_DETAILS, **keywords)
|
|
156
204
|
@user, @record, @record_id = keywords.values_at(:user, :record, :record_id)
|
|
205
|
+
@failure_messages = self.class.message_list(keywords[:failure_messages])
|
|
157
206
|
if details.equal?(NO_DETAILS)
|
|
158
207
|
details = keywords
|
|
159
208
|
elsif !(extra = keywords.except(*READERS)).empty?
|
|
@@ -179,22 +228,15 @@ module PermissionForge
|
|
|
179
228
|
|
|
180
229
|
next handle_nil_user(*args, host_suspended: host_suspended) unless current_user
|
|
181
230
|
|
|
182
|
-
|
|
183
|
-
next tenant_safe_find_by(*args) unless rules
|
|
184
|
-
|
|
185
|
-
query, failure_messages = PermissionForge::Services::PermissionRuleApplier.call(
|
|
186
|
-
model_class: self, rules: rules, args: args, current_user: current_user
|
|
187
|
-
)
|
|
188
|
-
|
|
189
|
-
next deny_or_missing(failure_messages, current_user, *args) if failure_messages.any?
|
|
190
|
-
|
|
191
|
-
query.where(*args).take
|
|
231
|
+
pf_find_by_as(current_user, *args)
|
|
192
232
|
end
|
|
193
233
|
end
|
|
194
234
|
|
|
195
235
|
# Returns a relation constrained by the current user's row-level rules, so
|
|
196
236
|
# any further chaining (`.where`, `.first`, `.find`, `.count`) stays scoped.
|
|
197
237
|
# Honors nil_user_strategy: :bypass -> all, :deny -> none, :raise -> raises.
|
|
238
|
+
# A signed-in user the model's Resource chain grants no read_access gets
|
|
239
|
+
# none under no_read_access_strategy :deny (RecordReadAccess).
|
|
198
240
|
# view_all/modify_all and the no-rules case return the unconstrained relation.
|
|
199
241
|
#
|
|
200
242
|
# Built with auto-scoping suspended (rules are applied here explicitly —
|
|
@@ -213,6 +255,22 @@ module PermissionForge
|
|
|
213
255
|
|
|
214
256
|
private
|
|
215
257
|
|
|
258
|
+
# find_by for a signed-in user: the read access the model needs
|
|
259
|
+
# (RecordReadAccess), then the rules.
|
|
260
|
+
def pf_find_by_as(current_user, *args)
|
|
261
|
+
return deny_or_missing(pf_no_read_access_denial, current_user, *args) if pf_no_read_access?(current_user)
|
|
262
|
+
|
|
263
|
+
rules = pf_applicable_rules(current_user, 'find_by')
|
|
264
|
+
return tenant_safe_find_by(*args) unless rules
|
|
265
|
+
|
|
266
|
+
query, failure_messages, denying_rules = PermissionForge::Services::PermissionRuleApplier.call(
|
|
267
|
+
model_class: self, rules: rules, args: args, current_user: current_user
|
|
268
|
+
)
|
|
269
|
+
return deny_or_missing(pf_rule_denial(failure_messages, denying_rules), current_user, *args) if failure_messages.any?
|
|
270
|
+
|
|
271
|
+
query.where(*args).take
|
|
272
|
+
end
|
|
273
|
+
|
|
216
274
|
# The gem's own suspension of Level 1 (the queries find_by and
|
|
217
275
|
# permission_scoped make while they apply the rules themselves): not
|
|
218
276
|
# reported as a scope_suspended bypass, which is about the host's
|
|
@@ -224,6 +282,7 @@ module PermissionForge
|
|
|
224
282
|
def build_permission_scope(host_suspended)
|
|
225
283
|
current_user = PermissionForge.current_user
|
|
226
284
|
return permission_scoped_without_user(host_suspended) unless current_user
|
|
285
|
+
return pf_no_read_access_scope(current_user) if pf_no_read_access?(current_user)
|
|
227
286
|
|
|
228
287
|
rules = pf_applicable_rules(current_user, 'permission_scoped')
|
|
229
288
|
return all unless rules
|
|
@@ -233,20 +292,53 @@ module PermissionForge
|
|
|
233
292
|
)
|
|
234
293
|
end
|
|
235
294
|
|
|
236
|
-
# Distinguishes "denied
|
|
237
|
-
#
|
|
238
|
-
# only raise NotAuthorizedError when the record exists but
|
|
239
|
-
|
|
295
|
+
# Distinguishes "denied" from "no such record". find_by must honor its
|
|
296
|
+
# nil contract for a genuinely missing record (matching stock Rails)
|
|
297
|
+
# and only raise NotAuthorizedError when the record exists but the
|
|
298
|
+
# rules (or the missing read access) deny it.
|
|
299
|
+
#
|
|
300
|
+
# `denial` is [reason, messages, failure_messages]: pf_rule_denial or
|
|
301
|
+
# pf_no_read_access_denial.
|
|
302
|
+
def deny_or_missing(denial, current_user, *args)
|
|
240
303
|
relation = tenant_safe_relation.where(*args)
|
|
241
304
|
return nil unless relation.exists?
|
|
242
305
|
|
|
306
|
+
reason, messages, failure_messages = denial
|
|
243
307
|
record_id = pf_denied_record_id(relation)
|
|
244
308
|
PermissionForge::Audit::Runtime.denied(
|
|
245
|
-
PermissionForge::Audit::Runtime::DENIED, reason:
|
|
309
|
+
PermissionForge::Audit::Runtime::DENIED, reason: reason, model: self, via: 'find_by',
|
|
246
310
|
user: current_user, record_id: record_id,
|
|
247
|
-
data: { 'messages' =>
|
|
311
|
+
data: { 'messages' => messages }
|
|
312
|
+
)
|
|
313
|
+
raise NotAuthorizedError.new(messages, user: current_user, record_id: record_id, failure_messages: failure_messages)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# What the applier answered: the failure messages and what each
|
|
317
|
+
# denying rule says on its own.
|
|
318
|
+
def pf_rule_denial(messages, denying_rules)
|
|
319
|
+
[:rule, messages, denying_rules.map { |rule| PermissionForge::Services::PermissionRuleApplier.failure_message_for(rule) }]
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# The user's entries along the chain do not grant read_access, under
|
|
323
|
+
# no_read_access_strategy :deny: one message, from the locale.
|
|
324
|
+
def pf_no_read_access_denial
|
|
325
|
+
message = PermissionForge::RecordReadAccess.failure_message(self)
|
|
326
|
+
[PermissionForge::RecordReadAccess::REASON, [message], [message]]
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# RecordReadAccess, the one definition.
|
|
330
|
+
def pf_no_read_access?(current_user)
|
|
331
|
+
PermissionForge::RecordReadAccess.denied?(current_user, self)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# permission_scoped for a user denied by RecordReadAccess: no rows,
|
|
335
|
+
# published as access.denied (no_read_access).
|
|
336
|
+
def pf_no_read_access_scope(current_user)
|
|
337
|
+
PermissionForge::Audit::Runtime.denied(
|
|
338
|
+
PermissionForge::Audit::Runtime::DENIED,
|
|
339
|
+
reason: PermissionForge::RecordReadAccess::REASON, model: self, via: 'permission_scoped', user: current_user
|
|
248
340
|
)
|
|
249
|
-
|
|
341
|
+
none
|
|
250
342
|
end
|
|
251
343
|
|
|
252
344
|
# The id of the row the rules denied, for access.denied and the error:
|
|
@@ -57,7 +57,7 @@ module PermissionForge
|
|
|
57
57
|
# With no tenant ambient the associations above reached every row.
|
|
58
58
|
def pf_destroy_permission_rows_in_other_tenants
|
|
59
59
|
config = PermissionForge.configuration
|
|
60
|
-
return unless
|
|
60
|
+
return unless PermissionForge.ambient_tenant
|
|
61
61
|
|
|
62
62
|
[PermissionForge::PermissionSetUser, PermissionForge::PermissionSetGroupUser].each do |model|
|
|
63
63
|
model.unscoped.where(user_id: id).find_each do |row|
|