permission_forge 0.14.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2372 -0
- data/README.md +76 -25
- data/app/controllers/concerns/permission_forge/api/crud_actions.rb +3 -2
- data/app/controllers/concerns/permission_forge/api/input_shapes.rb +200 -0
- data/app/controllers/concerns/permission_forge/api/pagination.rb +12 -5
- data/app/controllers/concerns/permission_forge/api/response_renderer.rb +37 -1
- data/app/controllers/concerns/permission_forge/api_authentication.rb +13 -2
- data/app/controllers/concerns/permission_forge/cache_confirmation.rb +42 -11
- data/app/controllers/concerns/permission_forge/change_review.rb +42 -0
- data/app/controllers/concerns/permission_forge/copy_refusal.rb +39 -0
- data/app/controllers/concerns/permission_forge/param_shapes.rb +207 -7
- data/app/controllers/concerns/permission_forge/save_notice.rb +69 -0
- data/app/controllers/permission_forge/api/audit_events_controller.rb +1 -1
- data/app/controllers/permission_forge/api/base_controller.rb +163 -12
- data/app/controllers/permission_forge/api/dashboard_controller.rb +29 -4
- data/app/controllers/permission_forge/api/permission_set_group_users_controller.rb +5 -2
- data/app/controllers/permission_forge/api/permission_set_groups_controller.rb +5 -4
- data/app/controllers/permission_forge/api/permission_set_rules_controller.rb +7 -5
- data/app/controllers/permission_forge/api/permission_set_users_controller.rb +80 -19
- data/app/controllers/permission_forge/api/permission_sets_controller.rb +12 -6
- data/app/controllers/permission_forge/api/resource_actions_controller.rb +1 -1
- data/app/controllers/permission_forge/api/resource_fields_controller.rb +3 -4
- data/app/controllers/permission_forge/api/resources_controller.rb +26 -12
- data/app/controllers/permission_forge/api/unmatched_routes_controller.rb +20 -0
- data/app/controllers/permission_forge/application_controller.rb +39 -5
- data/app/controllers/permission_forge/assignments_controller.rb +36 -34
- data/app/controllers/permission_forge/bulk_assignments_controller.rb +8 -4
- data/app/controllers/permission_forge/field_lookups_controller.rb +1 -1
- data/app/controllers/permission_forge/maintenance_controller.rb +6 -2
- data/app/controllers/permission_forge/people_controller.rb +14 -14
- data/app/controllers/permission_forge/permission_set_editor.rb +20 -6
- data/app/controllers/permission_forge/permission_set_group_users_controller.rb +3 -1
- data/app/controllers/permission_forge/permission_set_groups_controller.rb +23 -9
- data/app/controllers/permission_forge/permission_set_rules_controller.rb +20 -10
- data/app/controllers/permission_forge/permission_sets_controller.rb +23 -24
- data/app/controllers/permission_forge/resource_actions_controller.rb +11 -6
- data/app/controllers/permission_forge/resource_fields_controller.rb +37 -19
- data/app/controllers/permission_forge/resources_controller.rb +12 -11
- data/app/frontend/permission_forge/pf_admin.css +99 -17
- data/app/frontend/permission_forge/pf_admin.js +36 -0
- data/app/helpers/permission_forge/application_helper.rb +22 -72
- data/app/helpers/permission_forge/audit_attribution_helper.rb +77 -0
- data/app/helpers/permission_forge/audit_helper.rb +57 -119
- data/app/helpers/permission_forge/audit_records_helper.rb +103 -0
- data/app/helpers/permission_forge/audit_sentences_helper.rb +56 -2
- data/app/helpers/permission_forge/badges_helper.rb +74 -17
- data/app/helpers/permission_forge/changes_helper.rb +24 -6
- data/app/helpers/permission_forge/conditions_helper.rb +119 -0
- data/app/helpers/permission_forge/labels_helper.rb +134 -0
- data/app/helpers/permission_forge/people_helper.rb +11 -19
- data/app/helpers/permission_forge/rule_problems_helper.rb +73 -0
- data/app/helpers/permission_forge/sentences_helper.rb +119 -98
- data/app/helpers/permission_forge/settings_helper.rb +2 -2
- data/app/helpers/permission_forge/ui_helper.rb +65 -4
- data/app/models/permission_forge/audit_event.rb +5 -1
- data/app/models/permission_forge/permission_set.rb +208 -18
- data/app/models/permission_forge/permission_set_group.rb +10 -4
- data/app/models/permission_forge/permission_set_group_user.rb +3 -1
- data/app/models/permission_forge/permission_set_rule.rb +254 -17
- data/app/models/permission_forge/permission_set_user.rb +38 -3
- data/app/models/permission_forge/resource.rb +18 -8
- data/app/models/permission_forge/resource_action.rb +15 -6
- data/app/models/permission_forge/resource_field.rb +97 -11
- data/app/views/permission_forge/assignments/confirm_destroy.html.erb +4 -4
- data/app/views/permission_forge/assignments/new.html.erb +10 -6
- data/app/views/permission_forge/audit_events/index.html.erb +1 -1
- data/app/views/permission_forge/audit_events/show.html.erb +16 -9
- data/app/views/permission_forge/bulk_assignments/confirm_revoke.html.erb +3 -3
- data/app/views/permission_forge/bulk_assignments/new.html.erb +3 -3
- data/app/views/permission_forge/field_lookups/show.html.erb +2 -2
- data/app/views/permission_forge/home/_broken_rules_card.html.erb +35 -0
- data/app/views/permission_forge/home/_conditions_card.html.erb +28 -0
- data/app/views/permission_forge/home/_ending_card.html.erb +11 -5
- data/app/views/permission_forge/home/_expired_card.html.erb +1 -1
- data/app/views/permission_forge/home/_mask_strategies_card.html.erb +27 -0
- data/app/views/permission_forge/home/_privacy_card.html.erb +21 -7
- data/app/views/permission_forge/home/_rules_card.html.erb +1 -1
- data/app/views/permission_forge/home/_secret_rules_card.html.erb +33 -0
- data/app/views/permission_forge/home/_stale_card.html.erb +7 -5
- data/app/views/permission_forge/home/_tidy_card.html.erb +1 -1
- data/app/views/permission_forge/home/index.html.erb +5 -6
- data/app/views/permission_forge/people/_access_tab.html.erb +26 -19
- data/app/views/permission_forge/people/_audit_tab.html.erb +8 -8
- data/app/views/permission_forge/people/_effective_field_source.html.erb +13 -7
- data/app/views/permission_forge/people/_effective_tab.html.erb +16 -15
- data/app/views/permission_forge/people/_effective_why.html.erb +20 -16
- data/app/views/permission_forge/people/_why_item.html.erb +1 -1
- data/app/views/permission_forge/people/confirm_remove_all.html.erb +3 -3
- data/app/views/permission_forge/people/erasure_preview.html.erb +1 -1
- data/app/views/permission_forge/permission_set_groups/confirm_destroy.html.erb +3 -3
- data/app/views/permission_forge/permission_set_groups/index.html.erb +3 -3
- data/app/views/permission_forge/permission_set_groups/show.html.erb +28 -19
- data/app/views/permission_forge/permission_set_rules/_builder.html.erb +35 -15
- data/app/views/permission_forge/permission_set_rules/_preview.html.erb +17 -0
- data/app/views/permission_forge/permission_set_rules/confirm_destroy.html.erb +3 -3
- data/app/views/permission_forge/permission_set_rules/preview.html.erb +12 -0
- data/app/views/permission_forge/permission_sets/_assignments_tab.html.erb +37 -22
- data/app/views/permission_forge/permission_sets/_audit_tab.html.erb +4 -4
- data/app/views/permission_forge/permission_sets/_condition_problems.html.erb +15 -0
- data/app/views/permission_forge/permission_sets/_conditions_tab.html.erb +14 -8
- data/app/views/permission_forge/permission_sets/_header.html.erb +6 -5
- data/app/views/permission_forge/permission_sets/_records_summary.html.erb +2 -2
- data/app/views/permission_forge/permission_sets/_resources_tab.html.erb +4 -4
- data/app/views/permission_forge/permission_sets/confirm_destroy.html.erb +4 -4
- data/app/views/permission_forge/permission_sets/confirm_switch_mode.html.erb +3 -3
- data/app/views/permission_forge/permission_sets/index.html.erb +6 -6
- data/app/views/permission_forge/permission_sets/new.html.erb +2 -2
- data/app/views/permission_forge/permission_sets/review.html.erb +13 -3
- data/app/views/permission_forge/permission_sets/show.html.erb +7 -5
- data/app/views/permission_forge/resource_actions/_across_sets.html.erb +7 -10
- data/app/views/permission_forge/resource_actions/_effect.html.erb +5 -5
- data/app/views/permission_forge/resource_actions/_form.html.erb +1 -1
- data/app/views/permission_forge/resource_actions/_managed_note.html.erb +5 -3
- data/app/views/permission_forge/resource_actions/confirm_destroy.html.erb +1 -1
- data/app/views/permission_forge/resource_actions/new.html.erb +1 -1
- data/app/views/permission_forge/resource_actions/show.html.erb +2 -2
- data/app/views/permission_forge/resource_fields/_explain.html.erb +19 -15
- data/app/views/permission_forge/resource_fields/show.html.erb +21 -12
- data/app/views/permission_forge/resources/_actions_tab.html.erb +4 -4
- data/app/views/permission_forge/resources/_fields_tab.html.erb +17 -9
- data/app/views/permission_forge/resources/_rules_tab.html.erb +9 -6
- data/app/views/permission_forge/resources/_sync.html.erb +8 -5
- data/app/views/permission_forge/resources/confirm_destroy.html.erb +3 -3
- data/app/views/permission_forge/resources/new.html.erb +3 -2
- data/app/views/permission_forge/resources/show.html.erb +6 -6
- data/app/views/permission_forge/settings/_row.html.erb +2 -2
- data/app/views/permission_forge/settings/show.html.erb +10 -1
- data/app/views/permission_forge/shared/_across_sets.html.erb +8 -9
- data/app/views/permission_forge/shared/_audit_events.html.erb +2 -2
- data/app/views/permission_forge/shared/_audit_footer.html.erb +13 -2
- data/app/views/permission_forge/shared/_crud_cell.html.erb +6 -6
- data/app/views/permission_forge/shared/_mode_banners.html.erb +1 -1
- data/app/views/permission_forge/shared/_savebar.html.erb +4 -2
- data/config/locales/permission_forge.en.yml +480 -69
- data/config/locales/permission_forge.mk.yml +499 -79
- data/config/locales/permission_forge.sq.yml +509 -80
- data/config/routes.rb +38 -23
- data/lib/generators/permission_forge/install/install_generator.rb +21 -0
- data/lib/generators/permission_forge/install/templates/initializer.rb.tt +35 -34
- data/lib/generators/permission_forge/install/templates/migration.rb.tt +5 -5
- data/lib/permission_forge/activation_conditions.rb +200 -0
- data/lib/permission_forge/admin/action_label.rb +33 -0
- data/lib/permission_forge/admin/action_rows.rb +3 -1
- data/lib/permission_forge/admin/actor_label.rb +52 -0
- data/lib/permission_forge/admin/attention_items.rb +152 -31
- data/lib/permission_forge/admin/audit_log.rb +4 -7
- data/lib/permission_forge/admin/audit_trail.rb +33 -17
- data/lib/permission_forge/admin/bulk_people_matcher.rb +32 -14
- data/lib/permission_forge/admin/chain_entries.rb +42 -0
- data/lib/permission_forge/admin/change_set.rb +12 -1
- data/lib/permission_forge/admin/change_set_parts/base.rb +5 -4
- data/lib/permission_forge/admin/change_set_parts/context.rb +3 -1
- data/lib/permission_forge/admin/change_set_parts/field_edit.rb +4 -1
- data/lib/permission_forge/admin/change_set_parts/field_levels.rb +1 -1
- data/lib/permission_forge/admin/change_set_parts/new_actions.rb +1 -1
- data/lib/permission_forge/admin/change_set_parts/new_fields.rb +38 -11
- data/lib/permission_forge/admin/change_set_parts/resource_edit.rb +17 -1
- data/lib/permission_forge/admin/change_set_parts/resource_flags.rb +1 -1
- data/lib/permission_forge/admin/change_set_parts/set_attrs.rb +4 -0
- data/lib/permission_forge/admin/change_set_parts/values.rb +10 -4
- data/lib/permission_forge/admin/column_fields.rb +22 -21
- data/lib/permission_forge/admin/conditions_form.rb +47 -15
- data/lib/permission_forge/admin/controller_catalog.rb +42 -14
- data/lib/permission_forge/admin/editable_lock.rb +83 -3
- data/lib/permission_forge/admin/effective_access.rb +147 -48
- data/lib/permission_forge/admin/effective_fields.rb +75 -67
- data/lib/permission_forge/admin/error_sentence.rb +47 -0
- data/lib/permission_forge/admin/expiry_presets.rb +17 -0
- data/lib/permission_forge/admin/exports/holdings.rb +6 -3
- data/lib/permission_forge/admin/field_across_sets.rb +7 -4
- data/lib/permission_forge/admin/field_chain.rb +109 -0
- data/lib/permission_forge/admin/field_detail.rb +24 -11
- data/lib/permission_forge/admin/field_label.rb +109 -0
- data/lib/permission_forge/admin/field_level.rb +4 -68
- data/lib/permission_forge/admin/field_provenance.rb +132 -0
- data/lib/permission_forge/admin/field_rows.rb +26 -11
- data/lib/permission_forge/admin/last_changes.rb +14 -0
- data/lib/permission_forge/admin/list_sentence.rb +32 -0
- data/lib/permission_forge/admin/mask_strategy_choices.rb +57 -0
- data/lib/permission_forge/admin/model_catalog.rb +83 -39
- data/lib/permission_forge/admin/model_label.rb +66 -0
- data/lib/permission_forge/admin/people_candidates.rb +21 -9
- data/lib/permission_forge/admin/people_directory.rb +23 -5
- data/lib/permission_forge/admin/permission_set_factory.rb +6 -15
- data/lib/permission_forge/admin/person_label.rb +105 -16
- data/lib/permission_forge/admin/prefill.rb +92 -0
- data/lib/permission_forge/admin/record_name.rb +45 -0
- data/lib/permission_forge/admin/rule_builder.rb +77 -3
- data/lib/permission_forge/admin/rule_form.rb +44 -10
- data/lib/permission_forge/admin/rule_form_input.rb +27 -11
- data/lib/permission_forge/admin/rule_notes.rb +43 -0
- data/lib/permission_forge/admin/rule_preview.rb +96 -0
- data/lib/permission_forge/admin/sensitive_columns.rb +10 -62
- data/lib/permission_forge/admin/settings_catalog.rb +64 -12
- data/lib/permission_forge/admin/state_label.rb +97 -0
- data/lib/permission_forge/admin_method_override.rb +44 -0
- data/lib/permission_forge/api/schema.rb +322 -0
- data/lib/permission_forge/api/serializers/base_serializer.rb +87 -1
- data/lib/permission_forge/api/serializers/permission_set_group_user_serializer.rb +1 -1
- data/lib/permission_forge/api/serializers/permission_set_rule_serializer.rb +1 -1
- data/lib/permission_forge/api/serializers/permission_set_serializer.rb +5 -2
- data/lib/permission_forge/api/serializers/permission_set_user_serializer.rb +1 -1
- data/lib/permission_forge/api/serializers/resource_action_serializer.rb +1 -1
- data/lib/permission_forge/api/serializers/resource_field_serializer.rb +1 -1
- data/lib/permission_forge/api/serializers/resource_serializer.rb +1 -1
- data/lib/permission_forge/api_authorizations.rb +59 -0
- data/lib/permission_forge/api_only_admin.rb +81 -0
- data/lib/permission_forge/audit/actor_codes.rb +32 -0
- data/lib/permission_forge/audit/actor_resolver.rb +1 -2
- data/lib/permission_forge/audit/boot_warnings.rb +29 -9
- data/lib/permission_forge/audit/event_catalog.rb +16 -6
- data/lib/permission_forge/audit/event_query.rb +3 -3
- data/lib/permission_forge/audit/reason_codes.rb +25 -0
- data/lib/permission_forge/audit/runtime.rb +9 -2
- data/lib/permission_forge/audit/settings_observer.rb +2 -2
- data/lib/permission_forge/audit/unmasked_without_user.rb +169 -0
- data/lib/permission_forge/audit.rb +24 -10
- data/lib/permission_forge/boot_checks.rb +180 -63
- data/lib/permission_forge/cache/base.rb +10 -0
- data/lib/permission_forge/cache/null_cache.rb +5 -0
- data/lib/permission_forge/cache/outcome.rb +119 -0
- data/lib/permission_forge/cache/rails_cache.rb +6 -0
- data/lib/permission_forge/cache/report.rb +150 -0
- data/lib/permission_forge/cache/user_keys.rb +8 -4
- data/lib/permission_forge/cache_warnings.rb +140 -0
- data/lib/permission_forge/concerns/auto_permission_scoping.rb +19 -2
- data/lib/permission_forge/concerns/has_tenant_scoping.rb +1 -1
- data/lib/permission_forge/concerns/has_uuid.rb +1 -1
- data/lib/permission_forge/concerns/keeps_copied_values.rb +48 -0
- data/lib/permission_forge/concerns/masked_attributes/alias_access.rb +12 -2
- data/lib/permission_forge/concerns/masked_attributes/virtual_readers.rb +6 -4
- data/lib/permission_forge/concerns/masked_attributes.rb +150 -34
- data/lib/permission_forge/concerns/normalizes_label.rb +78 -0
- data/lib/permission_forge/concerns/permission_cache_accessor.rb +3 -3
- data/lib/permission_forge/concerns/permission_scoped_findable.rb +121 -29
- data/lib/permission_forge/concerns/permissioned_user.rb +1 -1
- data/lib/permission_forge/concerns/records_audit_events.rb +13 -6
- data/lib/permission_forge/concerns/requires_name.rb +37 -0
- data/lib/permission_forge/concerns/validates_column_lengths.rb +24 -0
- data/lib/permission_forge/concerns/validates_dependency.rb +5 -2
- data/lib/permission_forge/concerns/validates_required_columns.rb +74 -0
- data/lib/permission_forge/conditional_sets.rb +13 -6
- data/lib/permission_forge/configuration/admin_ui_settings.rb +18 -4
- data/lib/permission_forge/configuration/audit_settings.rb +2 -1
- data/lib/permission_forge/configuration/controller_action_settings.rb +5 -4
- data/lib/permission_forge/configuration/masked_attributes_settings.rb +73 -19
- data/lib/permission_forge/configuration/presets.rb +20 -11
- data/lib/permission_forge/configuration/query_scoping_settings.rb +55 -0
- data/lib/permission_forge/configuration/strategy_values.rb +2 -1
- data/lib/permission_forge/configuration.rb +41 -18
- data/lib/permission_forge/controller_actions/managed_index.rb +8 -7
- data/lib/permission_forge/database_reach.rb +93 -0
- data/lib/permission_forge/deprecation.rb +1 -1
- data/lib/permission_forge/docs/api_samples.rb +250 -0
- data/lib/permission_forge/docs/configuration_reference.rb +172 -0
- data/lib/permission_forge/encrypted_attributes.rb +150 -0
- data/lib/permission_forge/engine.rb +46 -14
- data/lib/permission_forge/execution_state/restore.rb +74 -0
- data/lib/permission_forge/execution_state.rb +8 -1
- data/lib/permission_forge/field_level.rb +123 -0
- data/lib/permission_forge/field_names.rb +145 -0
- data/lib/permission_forge/field_variants.rb +96 -0
- data/lib/permission_forge/generated_text.rb +95 -0
- data/lib/permission_forge/inspection_filters.rb +83 -0
- data/lib/permission_forge/managed_fields.rb +66 -0
- data/lib/permission_forge/masked_models.rb +25 -19
- data/lib/permission_forge/masking_sync.rb +100 -25
- data/lib/permission_forge/model_bindings.rb +30 -19
- data/lib/permission_forge/model_lookup.rb +42 -0
- data/lib/permission_forge/operators/encrypted_alias.rb +67 -0
- data/lib/permission_forge/operators/registry.rb +6 -0
- data/lib/permission_forge/operators/rule_column.rb +242 -27
- data/lib/permission_forge/operators/rule_path.rb +179 -0
- data/lib/permission_forge/operators/rule_problem.rb +309 -0
- data/lib/permission_forge/operators/rule_sources.rb +96 -0
- data/lib/permission_forge/operators/value_casters/postgresql_types.rb +237 -0
- data/lib/permission_forge/operators/value_casters/temporal.rb +9 -20
- data/lib/permission_forge/operators/value_casters.rb +61 -13
- data/lib/permission_forge/operators/value_typing.rb +83 -7
- data/lib/permission_forge/query_scoping_warnings.rb +22 -17
- data/lib/permission_forge/rake_audit.rb +5 -4
- data/lib/permission_forge/rake_dry_run.rb +32 -0
- data/lib/permission_forge/rake_output.rb +189 -0
- data/lib/permission_forge/record_read_access.rb +51 -0
- data/lib/permission_forge/resource_chain.rb +14 -2
- data/lib/permission_forge/schema_read/ordered_types.rb +72 -0
- data/lib/permission_forge/schema_read.rb +335 -0
- data/lib/permission_forge/secret_columns.rb +49 -0
- data/lib/permission_forge/sensitive_columns.rb +114 -0
- data/lib/permission_forge/services/attribute_access_guard.rb +22 -14
- data/lib/permission_forge/services/bulk_permission_set_assignment.rb +16 -8
- data/lib/permission_forge/services/concerns/clonable.rb +9 -0
- data/lib/permission_forge/services/concerns/settles_cache.rb +58 -0
- data/lib/permission_forge/services/consent_withdrawal_handler.rb +37 -19
- data/lib/permission_forge/services/data_subject_erasure.rb +83 -45
- data/lib/permission_forge/services/expired_assignment_cleaner.rb +62 -25
- data/lib/permission_forge/services/extra_field_defaults.rb +1 -1
- data/lib/permission_forge/services/field_permission_merger.rb +44 -17
- data/lib/permission_forge/services/filters_response_attributes.rb +20 -5
- data/lib/permission_forge/services/gdpr_field_auditor/text_report.rb +33 -5
- data/lib/permission_forge/services/gdpr_field_auditor.rb +68 -48
- data/lib/permission_forge/services/host_key_guard.rb +8 -5
- data/lib/permission_forge/services/mask_value.rb +72 -11
- data/lib/permission_forge/services/permission_cache_clearer.rb +36 -15
- data/lib/permission_forge/services/permission_hash_builder.rb +48 -19
- data/lib/permission_forge/services/permission_rule_applier.rb +131 -68
- data/lib/permission_forge/services/permission_set_cloner.rb +32 -8
- data/lib/permission_forge/services/permission_set_condition_evaluator.rb +38 -36
- data/lib/permission_forge/services/permission_set_group_cloner.rb +2 -1
- data/lib/permission_forge/services/permits_attributes/result.rb +31 -0
- data/lib/permission_forge/services/permits_attributes.rb +48 -27
- data/lib/permission_forge/services/resource_field_generator/row_builder.rb +96 -0
- data/lib/permission_forge/services/resource_field_generator.rb +71 -42
- data/lib/permission_forge/services/resource_with_fields_builder.rb +2 -1
- data/lib/permission_forge/services/rls_operator_sql.rb +83 -24
- data/lib/permission_forge/services/rls_policy_generator.rb +83 -39
- data/lib/permission_forge/services/rls_read_access_sql.rb +131 -0
- data/lib/permission_forge/services/security_audit/action_check.rb +49 -5
- data/lib/permission_forge/services/security_audit/assignment_check.rb +2 -1
- data/lib/permission_forge/services/security_audit/audit_trail_check.rb +1 -3
- data/lib/permission_forge/services/security_audit/conditions_check.rb +92 -0
- data/lib/permission_forge/services/security_audit/config_check.rb +41 -18
- data/lib/permission_forge/services/security_audit/integrity_check.rb +71 -4
- data/lib/permission_forge/services/security_audit/masking_without_user.rb +66 -0
- data/lib/permission_forge/services/security_audit/personal_data_check.rb +7 -11
- data/lib/permission_forge/services/security_audit/rule_check.rb +180 -50
- data/lib/permission_forge/services/security_audit/text_report.rb +14 -2
- data/lib/permission_forge/services/security_audit/unmanaged_actions.rb +145 -0
- data/lib/permission_forge/services/security_audit/user_model_check.rb +4 -3
- data/lib/permission_forge/services/security_audit.rb +16 -4
- data/lib/permission_forge/services/stale_assignment_auditor.rb +8 -5
- data/lib/permission_forge/timestamp_fields.rb +60 -0
- data/lib/permission_forge/unique_conflict.rb +117 -0
- data/lib/permission_forge/version.rb +1 -1
- data/lib/permission_forge/virtual_fields/discovery.rb +2 -1
- data/lib/permission_forge/virtual_fields.rb +15 -5
- data/lib/permission_forge/warnings.rb +263 -0
- data/lib/permission_forge/web_request.rb +248 -0
- data/lib/permission_forge.rb +54 -0
- data/lib/tasks/permission_forge.rake +26 -6
- data/lib/tasks/permission_forge_audit_events.rake +8 -4
- data/lib/tasks/permission_forge_cache.rake +12 -12
- data/lib/tasks/permission_forge_docs.rake +48 -0
- data/lib/tasks/permission_forge_expiration.rake +11 -5
- data/lib/tasks/permission_forge_gdpr.rake +36 -20
- metadata +84 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# For the "Review and save" step of a batched edit (Admin::ChangeSet): what
|
|
5
|
+
# to say when there is nothing to save, and once the edit is applied.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module ChangeReview
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# A plain notice, or — when the request turned on flags their
|
|
14
|
+
# dependencies keep off (ChangeSet#refused: Edit with Read off, ...) — an
|
|
15
|
+
# alert that says which and why, instead of a bare "No changes to save."
|
|
16
|
+
#
|
|
17
|
+
# @return [Hash] for redirect_to
|
|
18
|
+
def pf_nothing_to_save_flash(change_set)
|
|
19
|
+
refused = change_set.refused
|
|
20
|
+
return { notice: pf_no_changes_text } if refused.empty?
|
|
21
|
+
|
|
22
|
+
reasons = refused.map { |refusal| helpers.pf_refusal_description(refusal) }
|
|
23
|
+
{ alert: t('permission_forge.flash.permission_sets.nothing_applied', reasons: reasons.join('; ')) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The flash of an applied batched edit: how many changes, for whom, and
|
|
27
|
+
# what the cache store confirmed (CacheConfirmation) — or, when it
|
|
28
|
+
# applied none, what the review step says (#pf_nothing_to_save_flash:
|
|
29
|
+
# "No changes to save.", or why each requested change was refused).
|
|
30
|
+
#
|
|
31
|
+
# @param change_set [PermissionForge::Admin::ChangeSet] after #apply!
|
|
32
|
+
# @param outcome [PermissionForge::Cache::Outcome]
|
|
33
|
+
# @return [Hash] for redirect_to
|
|
34
|
+
def pf_applied_flash(change_set, outcome)
|
|
35
|
+
changes = change_set.changes
|
|
36
|
+
return pf_nothing_to_save_flash(change_set) if changes.empty?
|
|
37
|
+
|
|
38
|
+
people = helpers.pf_people_text(pf_people_count(@permission_set))
|
|
39
|
+
cache_flash_text(outcome, t('permission_forge.flash.permission_sets.saved', count: changes.size, people: people))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# What the admin says when a copy (Duplicate, "Start from") is refused:
|
|
5
|
+
# the row the validations refused and why, instead of a 500. A copy keeps
|
|
6
|
+
# its original's stored values (Concerns::KeepsCopiedValues), so this is
|
|
7
|
+
# left for a row no copy can hold (Modify All without View All, say); the
|
|
8
|
+
# copy is made whole or not at all (one transaction).
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
module CopyRefusal
|
|
12
|
+
# The record kinds a copy is made of, by class.
|
|
13
|
+
KINDS = {
|
|
14
|
+
'PermissionForge::PermissionSet' => :permission_set, 'PermissionForge::PermissionSetGroup' => :permission_set_group,
|
|
15
|
+
'PermissionForge::Resource' => :resource, 'PermissionForge::ResourceField' => :resource_field,
|
|
16
|
+
'PermissionForge::ResourceAction' => :resource_action, 'PermissionForge::PermissionSetRule' => :permission_set_rule
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# @param error [ActiveRecord::RecordInvalid]
|
|
22
|
+
# @param name [String] the name of what was being copied
|
|
23
|
+
# @return [String]
|
|
24
|
+
def pf_copy_refused_message(error, name)
|
|
25
|
+
record = error.record
|
|
26
|
+
t('permission_forge.flash.copy_refused.message', name: name, record: pf_copy_refused_record(record),
|
|
27
|
+
errors: pf_copy_refused_errors(record.errors))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# "Resource “Customer”"
|
|
31
|
+
def pf_copy_refused_record(record)
|
|
32
|
+
t("permission_forge.flash.copy_refused.kinds.#{KINDS.fetch(record.class.name, :record)}", name: record.try(:name).to_s)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def pf_copy_refused_errors(errors)
|
|
36
|
+
PermissionForge::Admin::ListSentence.call(PermissionForge::Admin::ErrorSentence.all(errors))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -18,6 +18,107 @@ module PermissionForge
|
|
|
18
18
|
value.is_a?(String) ? value.delete("\0").strip : ''
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
# A request value that cannot be read, naming the parameter: a 400 in
|
|
22
|
+
# the admin, a 422 in the API keyed by the parameter like a validation
|
|
23
|
+
# error (`{ "expires_at": ["is not an ISO 8601 date or time"] }`).
|
|
24
|
+
#
|
|
25
|
+
# @api private
|
|
26
|
+
class InvalidParam < ActionController::BadRequest
|
|
27
|
+
# @return [String] the parameter, e.g. "expires_at"
|
|
28
|
+
attr_reader :param
|
|
29
|
+
# @return [String] what is wrong with it, without its name
|
|
30
|
+
attr_reader :detail
|
|
31
|
+
|
|
32
|
+
def initialize(param, detail)
|
|
33
|
+
@param = param.to_s
|
|
34
|
+
@detail = detail
|
|
35
|
+
super("#{@param} #{detail}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The field-keyed messages of the API's 422 (`{ "expires_at" => [...] }`).
|
|
39
|
+
#
|
|
40
|
+
# @return [Hash{String => Array<String>}]
|
|
41
|
+
def messages = { param => [detail] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A flag that is not true or false (ParamShapes.boolean): the API
|
|
45
|
+
# answers it like any InvalidParam (422 keyed by it); the admin, whose
|
|
46
|
+
# forms post "0" and "1", goes back with a flash and changes nothing
|
|
47
|
+
# (ApplicationController).
|
|
48
|
+
#
|
|
49
|
+
# @api private
|
|
50
|
+
class InvalidFlag < InvalidParam
|
|
51
|
+
# @param param [String, Symbol] the flag, e.g. "is_active"
|
|
52
|
+
def initialize(param)
|
|
53
|
+
super(param, I18n.t('permission_forge.api.errors.boolean_invalid'))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# What a boolean parameter is read as: JSON's true and false, their
|
|
58
|
+
# strings, and 1 / 0 as a number or a string (see .boolean).
|
|
59
|
+
BOOLEANS = { true => true, 'true' => true, 1 => true, '1' => true,
|
|
60
|
+
false => false, 'false' => false, 0 => false, '0' => false }.freeze
|
|
61
|
+
|
|
62
|
+
# The words a boolean is also sent as, in any case: what a form posts
|
|
63
|
+
# (`on` for a checkbox without a value; `off`) and the letters
|
|
64
|
+
# PostgreSQL and ActiveModel write (`t`, `f`).
|
|
65
|
+
BOOLEAN_WORDS = { 'true' => true, 't' => true, 'on' => true, '1' => true,
|
|
66
|
+
'false' => false, 'f' => false, 'off' => false, '0' => false }.freeze
|
|
67
|
+
|
|
68
|
+
# The one reading of a boolean the admin and the API take: BOOLEANS,
|
|
69
|
+
# or a String that is one of BOOLEAN_WORDS in any case. Anything else
|
|
70
|
+
# ("yes", "nope", "", 2, 1.5, a list) is no boolean: a reader refuses
|
|
71
|
+
# it (InvalidParam), never takes it for true as ActiveModel's cast does.
|
|
72
|
+
#
|
|
73
|
+
# @return [Boolean, nil] nil when the value is no boolean
|
|
74
|
+
def self.boolean(value)
|
|
75
|
+
return BOOLEANS[value] if BOOLEANS.key?(value)
|
|
76
|
+
|
|
77
|
+
BOOLEAN_WORDS[value.downcase] if value.is_a?(String)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A flag `key` as true or false (.boolean).
|
|
81
|
+
#
|
|
82
|
+
# @raise [InvalidFlag] for anything else
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def self.flag(key, value)
|
|
85
|
+
read = boolean(value)
|
|
86
|
+
read.nil? ? raise(InvalidFlag, key) : read
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# `attrs` with each of `keys` it has read as a flag (.flag), in place.
|
|
90
|
+
#
|
|
91
|
+
# @raise [InvalidFlag] keyed by the first flag that is no boolean
|
|
92
|
+
# @return [Hash, ActionController::Parameters] attrs
|
|
93
|
+
def self.flags(attrs, keys)
|
|
94
|
+
keys.map(&:to_s).select { |key| attrs.key?(key) }.each { |key| attrs[key] = flag(key, attrs[key]) }
|
|
95
|
+
attrs
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The time an ISO 8601 String names, in Time.zone: see IsoTime.
|
|
99
|
+
#
|
|
100
|
+
# @return [ActiveSupport::TimeWithZone, nil] nil for anything else
|
|
101
|
+
def self.time(value) = IsoTime.parse(value)
|
|
102
|
+
|
|
103
|
+
# What a whole-number parameter is read from: digits with an optional
|
|
104
|
+
# minus sign, as a String or a JSON number. "2abc", "1.5", "+7" and ""
|
|
105
|
+
# are not one.
|
|
106
|
+
WHOLE_NUMBER = /\A-?\d+\z/
|
|
107
|
+
|
|
108
|
+
# The whole number `value` holds when it is inside `range`, else
|
|
109
|
+
# `default`: see #pf_integer_param.
|
|
110
|
+
#
|
|
111
|
+
# @param range [Range] of Integers, endless for an upper bound the
|
|
112
|
+
# caller clamps itself (`1..`)
|
|
113
|
+
# @return [Integer, Object] the number, or `default`
|
|
114
|
+
def self.integer(value, default:, range:)
|
|
115
|
+
text = value.to_s.strip if value.is_a?(String) || value.is_a?(Integer)
|
|
116
|
+
return default unless text&.match?(WHOLE_NUMBER)
|
|
117
|
+
|
|
118
|
+
number = Integer(text, 10)
|
|
119
|
+
range.cover?(number) ? number : default
|
|
120
|
+
end
|
|
121
|
+
|
|
21
122
|
# `value` with NUL bytes removed from every String in it, however deep.
|
|
22
123
|
#
|
|
23
124
|
# @return [Object] a new Parameters / Array, or the value itself
|
|
@@ -64,24 +165,123 @@ module PermissionForge
|
|
|
64
165
|
tab if tabs.include?(tab)
|
|
65
166
|
end
|
|
66
167
|
|
|
168
|
+
# params[key] when it is a whole number (digits, or a JSON number)
|
|
169
|
+
# inside `range`, else `default`. For a page, a page size, a count: a
|
|
170
|
+
# value that cannot be read is the default (`per_page=abc` lists the
|
|
171
|
+
# default page size), never 0, 1 or a 500 — a typo in a paging
|
|
172
|
+
# parameter must not refuse the page. A parameter whose misreading must
|
|
173
|
+
# refuse the request is read by a strict reader (pf_boolean_param,
|
|
174
|
+
# pf_time_param: InvalidParam).
|
|
175
|
+
#
|
|
176
|
+
# pf_integer_param(:per_page, default: 25, range: 1..)
|
|
177
|
+
# pf_integer_param(:hour, default: nil, range: 0..23)
|
|
178
|
+
def pf_integer_param(key, default:, range:)
|
|
179
|
+
ParamShapes.integer(params[key], default: default, range: range)
|
|
180
|
+
end
|
|
181
|
+
|
|
67
182
|
# The Integer ids in params[key] (one value or a list); anything that is
|
|
68
183
|
# not digits is dropped.
|
|
69
184
|
def pf_id_list(key)
|
|
70
185
|
Array.wrap(params[key]).filter_map { |value| PermissionForge::Admin::RecordId.cast(value) }
|
|
71
186
|
end
|
|
72
187
|
|
|
73
|
-
#
|
|
188
|
+
# params[key] as true or false (ParamShapes.boolean); false when it is
|
|
189
|
+
# absent or null.
|
|
190
|
+
#
|
|
191
|
+
# @raise [InvalidFlag] for anything else ("yes", "", 2, a list): a value
|
|
192
|
+
# that cannot be read must never be taken for false
|
|
193
|
+
def pf_boolean_param(key)
|
|
194
|
+
value = params[key]
|
|
195
|
+
return false if value.nil?
|
|
196
|
+
|
|
197
|
+
ParamShapes.flag(key, value)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# `attrs` (an attribute hash of a form) with each of `keys` it has read
|
|
201
|
+
# as true or false (ParamShapes.flags): the admin's flags. ActiveModel
|
|
202
|
+
# would read "nope" as true.
|
|
74
203
|
#
|
|
75
|
-
# @raise [
|
|
76
|
-
#
|
|
204
|
+
# @raise [InvalidFlag] keyed by the attribute for anything else (the
|
|
205
|
+
# admin answers it with a flash: nothing is changed)
|
|
206
|
+
def pf_boolean_attributes(attrs, *keys)
|
|
207
|
+
ParamShapes.flags(attrs, keys)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The time params[key] names (an ISO 8601 date or date-time, IsoTime),
|
|
211
|
+
# nil when it is blank.
|
|
212
|
+
#
|
|
213
|
+
# @raise [InvalidParam] for anything else ("tuesday", "2026-13-45"): a
|
|
214
|
+
# date that cannot be read must never become "no end date", nor a time
|
|
215
|
+
# somebody did not mean
|
|
77
216
|
def pf_time_param(key)
|
|
78
217
|
value = params[key]
|
|
79
218
|
return nil if value.blank?
|
|
80
219
|
|
|
81
|
-
time
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
220
|
+
ParamShapes.time(value) || raise(InvalidParam.new(key, I18n.t('permission_forge.api.errors.not_iso8601_time')))
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# The ISO 8601 forms a time parameter is read in, and nothing else
|
|
224
|
+
# (Time.zone.parse reads "tuesday" as today 00:00):
|
|
225
|
+
#
|
|
226
|
+
# - a date, `2026-10-22`: the end of that day in Time.zone
|
|
227
|
+
# (23:59:59.999999999), as the admin's end-date presets: an assignment
|
|
228
|
+
# that ends on a date lasts through it;
|
|
229
|
+
# - a date and a time of day, `2026-10-22T10:00`, with optional seconds
|
|
230
|
+
# and fraction (`10:00:00`, `10:00:00.5`), `T` or one space between
|
|
231
|
+
# them, and an optional offset, `Z` or `+02:00`; without one the time
|
|
232
|
+
# is read in Time.zone.
|
|
233
|
+
#
|
|
234
|
+
# Every field must exist on the calendar and the clock (no 2026-02-30,
|
|
235
|
+
# no 24:00, no leap second).
|
|
236
|
+
#
|
|
237
|
+
# @api private
|
|
238
|
+
module IsoTime
|
|
239
|
+
FORMAT = /\A(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})
|
|
240
|
+
(?:[T\x20](?<hour>\d{2}):(?<minute>\d{2})(?::(?<second>\d{2})(?:\.(?<fraction>\d{1,9}))?)?
|
|
241
|
+
(?<offset>Z|[+-]\d{2}:\d{2})?)?\z/x
|
|
242
|
+
|
|
243
|
+
# @return [ActiveSupport::TimeWithZone, nil] nil for a value that is not
|
|
244
|
+
# one of the forms above
|
|
245
|
+
def self.parse(value)
|
|
246
|
+
match = FORMAT.match(value.strip) if value.is_a?(String)
|
|
247
|
+
fields = match && fields_of(match)
|
|
248
|
+
offset = match && match[:offset]
|
|
249
|
+
return unless fields && valid_offset?(offset)
|
|
250
|
+
|
|
251
|
+
match[:hour] ? at(fields, offset) : Time.zone.local(*fields.first(3)).end_of_day
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# [year, month, day, hour, minute, seconds with fraction], nil when a
|
|
255
|
+
# field is off the calendar or the clock.
|
|
256
|
+
def self.fields_of(match)
|
|
257
|
+
date = match.values_at(:year, :month, :day).map(&:to_i)
|
|
258
|
+
clock = match.values_at(:hour, :minute, :second).map(&:to_i)
|
|
259
|
+
return unless Date.valid_date?(*date) && valid_clock?(*clock)
|
|
260
|
+
|
|
261
|
+
[*date, *clock.first(2), clock.last + fraction_of(match[:fraction])]
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def self.valid_clock?(hour, minute, second)
|
|
265
|
+
hour < 24 && minute < 60 && second < 60
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# nil (no offset) and Z are valid; ±HH:MM within a day.
|
|
269
|
+
def self.valid_offset?(offset)
|
|
270
|
+
return true if offset.nil? || offset == 'Z'
|
|
271
|
+
|
|
272
|
+
offset[1, 2].to_i < 24 && offset[4, 2].to_i < 60
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def self.fraction_of(digits)
|
|
276
|
+
digits ? Rational(digits.to_i, 10**digits.size) : 0
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def self.at(fields, offset)
|
|
280
|
+
return Time.zone.local(*fields) unless offset
|
|
281
|
+
|
|
282
|
+
Time.new(*fields, offset == 'Z' ? '+00:00' : offset).in_time_zone
|
|
283
|
+
end
|
|
284
|
+
private_class_method :fields_of, :valid_clock?, :valid_offset?, :fraction_of, :at
|
|
85
285
|
end
|
|
86
286
|
end
|
|
87
287
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# An admin save that changes nothing: not written, and said so. One
|
|
5
|
+
# definition for every form that saves a record (the field drawer, the
|
|
6
|
+
# action form, the rule builder, the group page, the Conditions tab, an
|
|
7
|
+
# end date, the set and Resource pages):
|
|
8
|
+
#
|
|
9
|
+
# - a record is saved only when it holds a change an audit event records
|
|
10
|
+
# (#pf_save_changed, RecordsAuditEvents#audited_changes_pending?: a blank
|
|
11
|
+
# posted over generated text is no change either, Admin::Prefill.posted;
|
|
12
|
+
# a stamp of updated_by alone is none) — an unchanged record is not
|
|
13
|
+
# saved, so no commit callback invalidates its holders' cached
|
|
14
|
+
# permissions. A batched edit with no change (Admin::ChangeSet) saves
|
|
15
|
+
# nothing either;
|
|
16
|
+
# - such a save says flash.no_changes (#pf_save_flash) — never the form's
|
|
17
|
+
# "Saved …", and with no cache note: no cache work was done.
|
|
18
|
+
#
|
|
19
|
+
# Field test round 8 (B#70).
|
|
20
|
+
#
|
|
21
|
+
# @api private
|
|
22
|
+
module SaveNotice
|
|
23
|
+
extend ActiveSupport::Concern
|
|
24
|
+
|
|
25
|
+
# @param saved [ActiveRecord::Base, Boolean] the record just saved (or
|
|
26
|
+
# left unsaved by #pf_save_changed), or whether a batched edit applied
|
|
27
|
+
# any change
|
|
28
|
+
# @return [Boolean] whether the save changed anything
|
|
29
|
+
def self.saved_anything?(saved)
|
|
30
|
+
return saved if [true, false].include?(saved)
|
|
31
|
+
|
|
32
|
+
saved.saved_audited_changes?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Assigns `attrs` to `record`, then runs the block (the save: save,
|
|
38
|
+
# save!) only when the record holds a change an audit event records.
|
|
39
|
+
#
|
|
40
|
+
# @param record [ActiveRecord::Base] one of the gem's models
|
|
41
|
+
# @param attrs [Hash] the attributes the form posted
|
|
42
|
+
# @yieldparam record [ActiveRecord::Base]
|
|
43
|
+
# @return the block's value, or true when there was nothing to save
|
|
44
|
+
def pf_save_changed(record, attrs = {})
|
|
45
|
+
record.assign_attributes(attrs)
|
|
46
|
+
return true unless record.audited_changes_pending?
|
|
47
|
+
|
|
48
|
+
yield record
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The flash of a save: { notice: "No changes to save." } when it changed
|
|
52
|
+
# nothing (SaveNotice.saved_anything?), else what the block answers — a
|
|
53
|
+
# flash Hash (CacheConfirmation#cache_flash_text), or a String, the notice.
|
|
54
|
+
#
|
|
55
|
+
# @param saved [ActiveRecord::Base, Boolean]
|
|
56
|
+
# @return [Hash] for redirect_to
|
|
57
|
+
def pf_save_flash(saved)
|
|
58
|
+
return { notice: pf_no_changes_text } unless SaveNotice.saved_anything?(saved)
|
|
59
|
+
|
|
60
|
+
flash = yield
|
|
61
|
+
flash.is_a?(Hash) ? flash : { notice: flash }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# "No changes to save." in the page's language.
|
|
65
|
+
def pf_no_changes_text
|
|
66
|
+
t('permission_forge.flash.no_changes')
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -9,7 +9,7 @@ module PermissionForge
|
|
|
9
9
|
before_action :authorize_audit_log!
|
|
10
10
|
|
|
11
11
|
rescue_from PermissionForge::Audit::EventQuery::Invalid do |error|
|
|
12
|
-
|
|
12
|
+
render_unprocessable(error.errors)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def index
|
|
@@ -30,6 +30,11 @@ module PermissionForge
|
|
|
30
30
|
AUDIT_SOURCE = 'api'
|
|
31
31
|
# Largest id a bigint column holds; past it PostgreSQL raises instead of matching nothing.
|
|
32
32
|
MAX_RECORD_ID = (2**63) - 1
|
|
33
|
+
# Body attributes that refer to another record (#with_reference_id),
|
|
34
|
+
# with the message of a reference that names none.
|
|
35
|
+
REFERENCES = {
|
|
36
|
+
permission_set_group_id: 'permission_forge.validations.permission_set.group_missing'
|
|
37
|
+
}.freeze
|
|
33
38
|
|
|
34
39
|
before_action :set_api_locale
|
|
35
40
|
before_action :pf_require_tenant
|
|
@@ -41,15 +46,68 @@ module PermissionForge
|
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
rescue_from ActiveRecord::RecordInvalid do |e|
|
|
44
|
-
|
|
49
|
+
render_validation_errors(e.record)
|
|
45
50
|
end
|
|
46
51
|
|
|
52
|
+
# The uniqueness race: two requests passed the uniqueness validation
|
|
53
|
+
# together and the unique index refused this one's INSERT. The
|
|
54
|
+
# validation's own 422, keyed by the attribute, from the index the
|
|
55
|
+
# error names (UniqueConflict); `base` when it names none. Through
|
|
56
|
+
# 0.14.1 a 500.
|
|
57
|
+
rescue_from ActiveRecord::RecordNotUnique do |e|
|
|
58
|
+
render_unprocessable(PermissionForge::UniqueConflict.messages(e) ||
|
|
59
|
+
{ 'base' => [I18n.t('permission_forge.api.errors.record_not_unique')] })
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A NOT NULL column no validation foresaw (ValidatesRequiredColumns
|
|
63
|
+
# refuses nil in the gem's own columns, keyed by the attribute): a
|
|
64
|
+
# host column, a trigger. The 422 envelope, never a 500.
|
|
65
|
+
rescue_from ActiveRecord::NotNullViolation do
|
|
66
|
+
render_unprocessable({ 'base' => [I18n.t('permission_forge.api.errors.not_null')] })
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Field-keyed like a validation error (ResponseRenderer#render_input_error).
|
|
47
70
|
rescue_from ActionController::ParameterMissing, ActionController::BadRequest do |e|
|
|
48
|
-
|
|
71
|
+
render_input_error(e)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A body Rails cannot parse (invalid JSON sent as application/json)
|
|
75
|
+
# raises when params are first read — in the forgery check, before
|
|
76
|
+
# api_auth — so the 400 is answered in the API's envelope instead of
|
|
77
|
+
# Rails' own. A disabled API stays a 404. params[:locale] cannot be
|
|
78
|
+
# read: the header decides.
|
|
79
|
+
rescue_from ActionDispatch::Http::Parameters::ParseError do
|
|
80
|
+
render_body_unreadable
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A form or multipart body Rack cannot parse (`a[=1&%%%`, a multipart
|
|
84
|
+
# body without its end) raises ActionController::BadRequest while
|
|
85
|
+
# Rails reads the parameters for its instrumentation, before
|
|
86
|
+
# rescue_from applies: answered here like an unreadable JSON body, the
|
|
87
|
+
# 400 envelope (through 0.15.0-rc Rails' own 400 page).
|
|
88
|
+
# AbstractController documents process_action as the method to
|
|
89
|
+
# override around an action.
|
|
90
|
+
def process_action(...)
|
|
91
|
+
super
|
|
92
|
+
rescue ActionController::BadRequest => e
|
|
93
|
+
raise if performed? || e.is_a?(ParamShapes::InvalidParam)
|
|
94
|
+
|
|
95
|
+
render_body_unreadable
|
|
49
96
|
end
|
|
50
97
|
|
|
51
98
|
private
|
|
52
99
|
|
|
100
|
+
# The 400 envelope of a body that cannot be read; a disabled API stays
|
|
101
|
+
# a 404. params[:locale] cannot be read: the header decides.
|
|
102
|
+
def render_body_unreadable
|
|
103
|
+
verify_api_enabled!
|
|
104
|
+
return if performed?
|
|
105
|
+
|
|
106
|
+
assign_api_locale(extract_locale_from_accept_language)
|
|
107
|
+
render json: { error: { status: 400, messages: I18n.t('permission_forge.api.errors.body_unreadable') } },
|
|
108
|
+
status: :bad_request
|
|
109
|
+
end
|
|
110
|
+
|
|
53
111
|
# Who acts (Audit::ActorResolver: audit_actor_resolver, the signed-in
|
|
54
112
|
# user, else an anonymous API client), request id, IP, the `reason`
|
|
55
113
|
# body key and one correlation id per mutating request.
|
|
@@ -63,9 +121,12 @@ module PermissionForge
|
|
|
63
121
|
end
|
|
64
122
|
|
|
65
123
|
def set_api_locale
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
124
|
+
assign_api_locale(params[:locale] || extract_locale_from_accept_language)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Always assign a locale (default when none/invalid) so a locale set by
|
|
128
|
+
# one request can never leak into the next on a reused thread.
|
|
129
|
+
def assign_api_locale(locale)
|
|
69
130
|
I18n.locale = if locale.present? && api_available_locale?(locale)
|
|
70
131
|
locale
|
|
71
132
|
else
|
|
@@ -104,6 +165,64 @@ module PermissionForge
|
|
|
104
165
|
scope.find_by!(identifier => value)
|
|
105
166
|
end
|
|
106
167
|
|
|
168
|
+
# The attributes of the body section `section` an endpoint writes to
|
|
169
|
+
# `model`: those of `keys` it has, each in its shape (Api::InputShapes,
|
|
170
|
+
# the one shape table). The other keys of the section go through
|
|
171
|
+
# strong parameters' own handling of keys an action does not take
|
|
172
|
+
# (logged, or raised under action_on_unpermitted_parameters = :raise).
|
|
173
|
+
#
|
|
174
|
+
# @param record [ActiveRecord::Base, nil] the record an update changes
|
|
175
|
+
# @return [ActiveSupport::HashWithIndifferentAccess]
|
|
176
|
+
# @raise [ActionController::ParameterMissing] the section is missing,
|
|
177
|
+
# empty or not an object (a 422 keyed by it)
|
|
178
|
+
# @raise [InputShapes::Refused] a value of the wrong shape (a 422 keyed
|
|
179
|
+
# by each such attribute)
|
|
180
|
+
def pf_record_attributes(section, model, *keys, record: nil)
|
|
181
|
+
attrs = pf_require_hash(section)
|
|
182
|
+
attrs.except(*keys.map(&:to_s)).permit
|
|
183
|
+
InputShapes.call(attrs, model, keys, record: record)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The `name` of a copy: text without NUL bytes and surrounding white
|
|
187
|
+
# space, '' when absent or null (the caller's default name).
|
|
188
|
+
#
|
|
189
|
+
# @raise [InputShapes::Refused] for anything else (true, a number, a
|
|
190
|
+
# list, an object), keyed by name: never silently the default name
|
|
191
|
+
def pf_copy_name
|
|
192
|
+
InputShapes.check!(:name, params[:name], :text)
|
|
193
|
+
pf_text_param(:name)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# `attrs` with the reference `key` (e.g. permission_set_group_id)
|
|
197
|
+
# replaced by the id of the record it names in `scope`, looked up like
|
|
198
|
+
# every identifier (tenant-scoped): its numeric id, or — under
|
|
199
|
+
# api_record_identifier = :uuid — its uuid, the identifier the API
|
|
200
|
+
# answers with. A blank value (null, "") is no reference (nil). Anything
|
|
201
|
+
# else that names no record of the scope is a 422 keyed by `key`; a
|
|
202
|
+
# uuid that starts with digits is never read as an id (the integer
|
|
203
|
+
# column would cast "12ab…" to 12).
|
|
204
|
+
#
|
|
205
|
+
# @raise [ParamShapes::InvalidParam]
|
|
206
|
+
def with_reference_id(attrs, key, scope)
|
|
207
|
+
return attrs unless attrs.key?(key)
|
|
208
|
+
|
|
209
|
+
value = attrs[key]
|
|
210
|
+
return attrs.merge(key => nil) if value.nil? || value == ''
|
|
211
|
+
|
|
212
|
+
attrs.merge(key => referenced_record(scope, value)&.id ||
|
|
213
|
+
raise(ParamShapes::InvalidParam.new(key, I18n.t(REFERENCES.fetch(key)))))
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def referenced_record(scope, value)
|
|
217
|
+
text = value.to_s if value.is_a?(String) || value.is_a?(Integer)
|
|
218
|
+
return unless text
|
|
219
|
+
|
|
220
|
+
if identifier_value?('id', text) then scope.find_by(id: text.to_i)
|
|
221
|
+
elsif PermissionForge.configuration.api_record_identifier.to_s == 'uuid' && identifier_value?('uuid', text)
|
|
222
|
+
scope.find_by(uuid: text)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
107
226
|
def identifier_value?(identifier, value)
|
|
108
227
|
return false if value.empty? || value.include?("\0")
|
|
109
228
|
return true unless identifier.to_s == 'id'
|
|
@@ -121,11 +240,43 @@ module PermissionForge
|
|
|
121
240
|
user
|
|
122
241
|
end
|
|
123
242
|
|
|
243
|
+
# The person params[key] names, for a new assignment or membership,
|
|
244
|
+
# within config.user_list_resolver (Admin::PeopleDirectory#find). Every
|
|
245
|
+
# refusal is a 422 keyed by the parameter, so it never reads as the
|
|
246
|
+
# Permission Set or the Group in the path being wrong (a 404):
|
|
247
|
+
#
|
|
248
|
+
# - missing, null or blank: "is missing";
|
|
249
|
+
# - not one id (a list, an object, a number with a fraction, text that
|
|
250
|
+
# is not a key of the user model: "abc" for an integer key): "is not
|
|
251
|
+
# a user id";
|
|
252
|
+
# - an id that names nobody the resolver lists: "names nobody who can
|
|
253
|
+
# be assigned". An unknown id and someone the resolver leaves out read
|
|
254
|
+
# the same, as in a bulk request's `skipped`.
|
|
255
|
+
#
|
|
256
|
+
# @raise [ParamShapes::InvalidParam]
|
|
257
|
+
def requested_user(key = :user_id)
|
|
258
|
+
value = params[key]
|
|
259
|
+
problem = user_id_problem(value)
|
|
260
|
+
user = PermissionForge::Admin::PeopleDirectory.new.find(value) unless problem
|
|
261
|
+
problem ||= I18n.t('permission_forge.api.errors.user_not_found') unless user
|
|
262
|
+
raise ParamShapes::InvalidParam.new(key, problem) if problem
|
|
263
|
+
|
|
264
|
+
user
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# What is wrong with a user_id before anybody is looked up, nil when
|
|
268
|
+
# it is one id of the user model's key type.
|
|
269
|
+
def user_id_problem(value)
|
|
270
|
+
return I18n.t('permission_forge.api.errors.user_id_missing') if value.blank?
|
|
271
|
+
return if (value.is_a?(String) || value.is_a?(Integer)) && PermissionForge::Admin::PeopleDirectory::Ids.cast(value).any?
|
|
272
|
+
|
|
273
|
+
I18n.t('permission_forge.api.errors.user_id_invalid')
|
|
274
|
+
end
|
|
275
|
+
|
|
124
276
|
def authorize_action!(action, record)
|
|
125
277
|
authorizer = PermissionForge.configuration.api_action_authorization
|
|
126
|
-
message = I18n.t('permission_forge.api.errors.action_not_authorized')
|
|
127
278
|
unless authorizer
|
|
128
|
-
return deny_when_authorization_required(
|
|
279
|
+
return deny_when_authorization_required(I18n.t('permission_forge.api.errors.action_authorization_missing')) do
|
|
129
280
|
audit_action_refused(action, record, hook: 'api_require_authorization')
|
|
130
281
|
end
|
|
131
282
|
end
|
|
@@ -133,7 +284,7 @@ module PermissionForge
|
|
|
133
284
|
return if authorizer.call(self, action, record)
|
|
134
285
|
|
|
135
286
|
audit_action_refused(action, record, hook: 'api_action_authorization')
|
|
136
|
-
render_not_authorized(
|
|
287
|
+
render_not_authorized(I18n.t('permission_forge.api.errors.action_not_authorized'))
|
|
137
288
|
end
|
|
138
289
|
|
|
139
290
|
# Runs the mutation authorization hook and the is_editable lock for a
|
|
@@ -174,9 +325,8 @@ module PermissionForge
|
|
|
174
325
|
# A refusal records the ids as requested (bounded, Audit::Refusals).
|
|
175
326
|
def authorize_assignment!(permission_set, user_ids)
|
|
176
327
|
authorizer = PermissionForge.configuration.api_assignment_authorization
|
|
177
|
-
message = I18n.t('permission_forge.api.errors.assignment_not_authorized')
|
|
178
328
|
unless authorizer
|
|
179
|
-
return deny_when_authorization_required(
|
|
329
|
+
return deny_when_authorization_required(I18n.t('permission_forge.api.errors.assignment_authorization_missing')) do
|
|
180
330
|
audit_assignment_refused(permission_set, user_ids, hook: 'api_require_authorization')
|
|
181
331
|
end
|
|
182
332
|
end
|
|
@@ -184,14 +334,15 @@ module PermissionForge
|
|
|
184
334
|
return if authorizer.call(self, permission_set, PermissionForge::Admin::PeopleDirectory::Ids.cast(user_ids))
|
|
185
335
|
|
|
186
336
|
audit_assignment_refused(permission_set, user_ids, hook: 'api_assignment_authorization')
|
|
187
|
-
render_not_authorized(
|
|
337
|
+
render_not_authorized(I18n.t('permission_forge.api.errors.assignment_not_authorized'))
|
|
188
338
|
end
|
|
189
339
|
|
|
190
340
|
# When config.api_require_authorization is on, a missing authorization lambda
|
|
191
341
|
# means DENY (403) rather than the backward-compatible silent allow. This
|
|
192
342
|
# prevents "authenticated == full admin" when the host wired api_auth but not
|
|
193
343
|
# the per-action / assignment authorization hooks. The block records the
|
|
194
|
-
# refusal.
|
|
344
|
+
# refusal. The message names the missing option (the integrator's fix),
|
|
345
|
+
# unlike a lambda's denial; it carries nothing from the request.
|
|
195
346
|
def deny_when_authorization_required(message)
|
|
196
347
|
return unless PermissionForge.configuration.api_require_authorization
|
|
197
348
|
|