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
data/README.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
<img alt="PermissionForge" src="https://raw.githubusercontent.com/fetijashari/permission_forge/main/docs/assets/logo-horizontal-light.svg" width="420">
|
|
5
|
-
</picture>
|
|
2
|
+
<img alt="PermissionForge" src="https://raw.githubusercontent.com/fetijashari/permission_forge/main/docs/assets/logo-horizontal-light.svg#gh-light-mode-only" width="420">
|
|
3
|
+
<img alt="PermissionForge" src="https://raw.githubusercontent.com/fetijashari/permission_forge/main/docs/assets/logo-horizontal-dark.svg#gh-dark-mode-only" width="420">
|
|
6
4
|
</p>
|
|
7
5
|
|
|
8
6
|
<p align="center">
|
|
9
7
|
<a href="https://github.com/fetijashari/permission_forge/actions/workflows/ci.yml"><img src="https://github.com/fetijashari/permission_forge/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
10
8
|
<a href="https://rubygems.org/gems/permission_forge"><img src="https://img.shields.io/gem/v/permission_forge" alt="Gem Version"></a>
|
|
9
|
+
<a href="https://scorecard.dev/viewer/?uri=github.com/fetijashari/permission_forge"><img src="https://api.scorecard.dev/projects/github.com/fetijashari/permission_forge/badge" alt="OpenSSF Scorecard"></a>
|
|
10
|
+
<a href="https://www.bestpractices.dev/projects/15499"><img src="https://www.bestpractices.dev/projects/15499/badge" alt="OpenSSF Best Practices"></a>
|
|
11
|
+
<a href="https://rubydoc.info/gems/permission_forge"><img src="https://img.shields.io/badge/docs-rubydoc.info-blue.svg" alt="API documentation"></a>
|
|
11
12
|
<a href="https://www.ruby-lang.org"><img src="https://img.shields.io/badge/ruby-%3E%3D%203.2-ruby.svg" alt="Ruby"></a>
|
|
12
13
|
<a href="https://rubyonrails.org"><img src="https://img.shields.io/badge/rails-7.1%20to%208.1-red.svg" alt="Rails"></a>
|
|
13
14
|
<a href="https://github.com/fetijashari/permission_forge/blob/main/LICENSE.txt"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
|
|
@@ -69,13 +70,13 @@ sit next to one (for example Pundit for controller decisions, PermissionForge fo
|
|
|
69
70
|
|
|
70
71
|
Add to your `Gemfile`:
|
|
71
72
|
|
|
72
|
-
```ruby
|
|
73
|
+
```ruby fragment
|
|
73
74
|
gem 'permission_forge'
|
|
74
75
|
```
|
|
75
76
|
|
|
76
77
|
Then run:
|
|
77
78
|
|
|
78
|
-
```bash
|
|
79
|
+
```bash run
|
|
79
80
|
bundle install
|
|
80
81
|
bin/rails generate permission_forge:install
|
|
81
82
|
bin/rails db:migrate
|
|
@@ -84,6 +85,23 @@ bin/rails db:migrate
|
|
|
84
85
|
This creates `config/initializers/permission_forge.rb` and a migration with 9 tables: the 8 permission tables and the
|
|
85
86
|
audit log, prefixed with `permission_forge_` by default.
|
|
86
87
|
|
|
88
|
+
Then mount the admin UI and give it credentials. It is behind HTTP Basic auth and answers 401 until both are set:
|
|
89
|
+
|
|
90
|
+
```ruby fragment
|
|
91
|
+
# config/routes.rb
|
|
92
|
+
mount PermissionForge::Engine, at: "/permissions"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```ruby fragment
|
|
96
|
+
# config/initializers/permission_forge.rb: uncomment Option A ("Admin screens")
|
|
97
|
+
config.http_basic_auth_user = ENV['PF_ADMIN_USER']
|
|
98
|
+
config.http_basic_auth_password = ENV['PF_ADMIN_PASSWORD']
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Set `PF_ADMIN_USER` and `PF_ADMIN_PASSWORD` in the environment and open `/permissions`. To sign admins in with your
|
|
102
|
+
own authentication instead, see [Admin UI](https://github.com/fetijashari/permission_forge/blob/main/docs/admin-ui.md#disabling-http-basic-auth)
|
|
103
|
+
(`base_controller_class`). `bin/rails permission_forge:audit` lists what is still open before production.
|
|
104
|
+
|
|
87
105
|
Generator options:
|
|
88
106
|
- `--skip-migration`: no migration (for apps with existing tables)
|
|
89
107
|
- `--table-prefix=PREFIX`: table name prefix (default `permission_forge_`; `''` for none)
|
|
@@ -106,7 +124,7 @@ Upgrading? Read the [CHANGELOG](https://github.com/fetijashari/permission_forge/
|
|
|
106
124
|
The generated initializer reads `Current.user` when your app has a `Current` class that answers `user` (as Rails 8's
|
|
107
125
|
authentication generator creates). Otherwise point it at your own lookup:
|
|
108
126
|
|
|
109
|
-
```ruby
|
|
127
|
+
```ruby fragment
|
|
110
128
|
# config/initializers/permission_forge.rb
|
|
111
129
|
PermissionForge.configure do |config|
|
|
112
130
|
config.user_class = 'User'
|
|
@@ -130,7 +148,7 @@ module, add a callback), use `Rails.application.config.to_prepare` or
|
|
|
130
148
|
|
|
131
149
|
### 2. Include the concern in your User model
|
|
132
150
|
|
|
133
|
-
```ruby
|
|
151
|
+
```ruby fragment
|
|
134
152
|
class User < ApplicationRecord
|
|
135
153
|
include PermissionForge::Concerns::PermissionedUser
|
|
136
154
|
end
|
|
@@ -144,7 +162,7 @@ those, not the private helpers.
|
|
|
144
162
|
|
|
145
163
|
### 3. Protect models with row-level rules (optional)
|
|
146
164
|
|
|
147
|
-
```ruby
|
|
165
|
+
```ruby fragment
|
|
148
166
|
class Customer < ApplicationRecord
|
|
149
167
|
include PermissionForge::Concerns::PermissionScopedFindable
|
|
150
168
|
end
|
|
@@ -158,7 +176,11 @@ rules for a model gets every row.
|
|
|
158
176
|
|
|
159
177
|
### 4. Create a Permission Set and assign it
|
|
160
178
|
|
|
161
|
-
|
|
179
|
+
Run these lines in order, in one console. If a `create!` raises (a Permission Set name is unique: one made in the
|
|
180
|
+
admin already holds "Sales Team"), fix it and run it again before going on: the lines after it use its record and,
|
|
181
|
+
without it, fail with a database `NOT NULL` error that hides the first one.
|
|
182
|
+
|
|
183
|
+
```ruby run
|
|
162
184
|
pset = PermissionForge::PermissionSet.create!(name: 'Sales Team', is_active: true)
|
|
163
185
|
|
|
164
186
|
resource = PermissionForge::Resource.create!(
|
|
@@ -173,16 +195,31 @@ PermissionForge::ResourceField.create!(
|
|
|
173
195
|
read_access: true, edit_access: true
|
|
174
196
|
)
|
|
175
197
|
|
|
198
|
+
user = User.find_by!(email_address: 'bob@example.com') # the person who gets the set (`email:` on a Devise app)
|
|
176
199
|
PermissionForge::PermissionSetUser.create!(permission_set: pset, user: user)
|
|
177
200
|
```
|
|
178
201
|
|
|
202
|
+
Only `name` has a field row here. A column with no row is refused by the default
|
|
203
|
+
`unconfigured_field_strategy = :block`: its getter returns `nil` and `as_json` keeps the key as `null` (a Hidden row,
|
|
204
|
+
read off, leaves the key out). `PermissionForge::Services::ResourceFieldGenerator.call(resource: resource)` gives every
|
|
205
|
+
column (and declared virtual field) without a row one: Hidden, `created_at` / `updated_at` included — also the columns
|
|
206
|
+
that look like personal data, which get their recommended mask strategy on the row. With `grant_all: true` every row is
|
|
207
|
+
readable and editable (Read-only on a Resource without Edit), unmasked, and the timestamps read-only. The admin's "Sync fields with model" adds the rows at a level you pick (under
|
|
208
|
+
the default `gdpr_defaults`, "… as Read-only" starts the personal-data columns Masked). Then open the ones the set should read.
|
|
209
|
+
|
|
179
210
|
Each change invalidates the cached permissions of the users it affects; the next read rebuilds them. The admin UI
|
|
180
|
-
(mounted
|
|
181
|
-
|
|
211
|
+
(mounted at `/permissions` in [Installation](#installation)) does the same through forms.
|
|
212
|
+
|
|
213
|
+
Every change is also an audit event. Made in a console, a seed or a script, it is recorded with actor `unknown`;
|
|
214
|
+
wrap it to say who and why: `PermissionForge.with_audit_actor(user: admin, reason: 'onboarding') { ... }` (see
|
|
215
|
+
[Audit Log](https://github.com/fetijashari/permission_forge/blob/main/docs/audit-log.md)).
|
|
182
216
|
|
|
183
217
|
### 5. Check permissions at runtime
|
|
184
218
|
|
|
185
|
-
|
|
219
|
+
Look up the same person step 4 assigned (`User.first` may be someone else, who holds no set: `false` and `{}`):
|
|
220
|
+
|
|
221
|
+
```ruby run
|
|
222
|
+
user = User.find_by!(email_address: 'bob@example.com') # the person you assigned in step 4
|
|
186
223
|
user.can_read?('Customer') # => true
|
|
187
224
|
user.can_delete?('Customer') # => false
|
|
188
225
|
user.permissions_for(:Customer)
|
|
@@ -200,10 +237,11 @@ where you call `can_*?`, or with `ControllerActionGuard`. Field access is decide
|
|
|
200
237
|
Several defaults fail open so that installing the gem changes nothing until you configure it. Before production, go
|
|
201
238
|
through this list. `bin/rails permission_forge:audit` reports most of these settings.
|
|
202
239
|
|
|
203
|
-
`config.preset = :strict` closes every row below that a setting can close
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
240
|
+
`config.preset = :strict` closes every row below that a setting can close (masking with nobody signed in as
|
|
241
|
+
`:hide_in_requests`: hidden in web requests, stored values in jobs and the console); a setting you make yourself always
|
|
242
|
+
wins over the preset. New installs get the safe part (`action_guard_nil_user_strategy = :deny`,
|
|
243
|
+
`conditional_sets_strict_mode = true`, `cache_backend = :rails_cache`, `masked_attributes_nil_user_strategy =
|
|
244
|
+
:hide_in_requests`) and the preset as a commented line with what it costs. The rows that need your code (`base_controller_class`,
|
|
207
245
|
`admin_assignment_authorization`, `audit_log_authorization`, `user_active_check`) are not in a preset.
|
|
208
246
|
`bin/rails permission_forge:audit` prints the preset and, under `:strict`, every option set looser. See
|
|
209
247
|
[Presets](https://github.com/fetijashari/permission_forge/blob/main/docs/configuration.md#presets).
|
|
@@ -211,10 +249,10 @@ and the preset as a commented line with what it costs. The rows that need your c
|
|
|
211
249
|
| Default | What it means | To close it |
|
|
212
250
|
|---|---|---|
|
|
213
251
|
| `nil_user_strategy = :bypass` | With no current user (jobs, console, a resolver that returns nil), `find_by`, `permission_scoped`, auto-scoping and the RLS policy apply no rules; so does the controller guard unless `action_guard_nil_user_strategy` is set | `:deny` (not found) or `:raise`. Do not include `PermissionScopedFindable` in the user model: under `:deny` sign-in lookups return nil (`permission_forge:audit` reports `user_model_scoped`) |
|
|
214
|
-
| `masked_attributes_nil_user_strategy = :bypass` | Attribute readers, `attributes` and `as_json` of a masked model return the stored values when nobody is signed in, whatever `nil_user_strategy` says | `:
|
|
252
|
+
| `masked_attributes_nil_user_strategy = :bypass` | Attribute readers, `attributes` and `as_json` of a masked model return the stored values when nobody is signed in, whatever `nil_user_strategy` says: in jobs and the console, and in a web request nobody is signed in to (a public page, a webhook, a controller that forgot authentication). Such a read in a web request is logged (once per model and process) and published as `field.unmasked_without_user` | `:hide_in_requests` (every guarded attribute reads as hidden in a web request with nobody signed in; jobs, rake and the console keep the stored values; new installs get it from the generator), or `:hide` (hidden everywhere: set the current user in the jobs and mailers that read masked models). The primary key and the raw paths are untouched; the user model (`user_class` and its subclasses) stays readable for sign-in; validations read stored values |
|
|
215
253
|
| `response_filter_nil_user_strategy = :bypass` | `FiltersResponseAttributes` returns every attribute when there is no user | `:block` |
|
|
216
254
|
| `auto_query_scoping = :off` | Only class-level `find_by` / `find_by!` and `permission_scoped` apply row rules | `:filter_only`, and use `permission_scoped` for join rules; consider RLS on PostgreSQL |
|
|
217
|
-
| `
|
|
255
|
+
| `no_read_access_strategy = :rules_only` | A signed-in user whose Permission Sets do not grant Read on a model (no `Resource` for it at all included) has no rules there: `find_by`, `permission_scoped` and auto-scoping return every row. Only `can_read?` (or the controller guard) refuses them | `:deny` (no rows; `find_by` raises `NotAuthorizedError` for an existing record) |
|
|
218
256
|
| `action_guard_nil_user_strategy = nil` | The guard follows `nil_user_strategy` (`:bypass`) for signed-out requests to mapped actions | `:deny` (new installs get it from the generator) |
|
|
219
257
|
| `conditional_sets_strict_mode = false` | With an empty request context (no resolver configured), every conditional Permission Set applies, MFA- or IP-gated grants included | A `request_context_resolver` that returns the request's IP and session flags, and `true`: without a request context, conditional granting sets are then dropped and conditional muting sets still apply |
|
|
220
258
|
| `mask_virtual_attributes = false` | Virtual readers (methods, store accessors, attribute-API attributes) are not masked on the model; only `PermitsAttributes` and `FiltersResponseAttributes` apply their field rows | `:auto` (or a list of models), after checking that the models' own callbacks do not need the raw values |
|
|
@@ -226,12 +264,22 @@ and the preset as a commented line with what it costs. The rows that need your c
|
|
|
226
264
|
| `base_controller_class` / `api_base_controller_class` | With tenancy on, a request with no current tenant is refused (403) | Controllers that authenticate and set the tenant |
|
|
227
265
|
| `user_active_check` | Only reported by the security audit; an inactive user keeps every grant | Revoke the assignments of users you deactivate |
|
|
228
266
|
|
|
229
|
-
Upgrading
|
|
230
|
-
|
|
267
|
+
Upgrading to 0.15: a controller action that maps to a Resource (in a controller that includes
|
|
268
|
+
`ControllerActionGuard`) and that no Permission Set manages is now refused with 403; before 0.15 it ran.
|
|
269
|
+
`bin/rails permission_forge:audit` lists those actions (`unmanaged_actions_refused`; with tenancy, each with the
|
|
270
|
+
tenants that do not manage it) and the controller actions it could not check (`actions_not_checked`). Manage each one
|
|
271
|
+
in a Permission Set, or opt out with `config.unconfigured_action_strategy = :allow`, which lets every signed-in user
|
|
272
|
+
run them (the audit reports the opt-out as `unconfigured_actions_allowed`, medium). Actions that map to no Resource are
|
|
273
|
+
never checked.
|
|
274
|
+
Run `bin/rails permission_forge:audit` before you deploy (see the
|
|
275
|
+
[Migration Guide](https://github.com/fetijashari/permission_forge/blob/main/docs/migration-guide.md#upgrading-to-015)).
|
|
276
|
+
From 0.13: follow [From 0.13](https://github.com/fetijashari/permission_forge/blob/main/docs/migration-guide.md#from-013) — a revocation made on 0.15 does not reach what 0.13 processes have cached.
|
|
231
277
|
|
|
232
278
|
Already closed by default, keep them so: `api_require_authorization = true` (API changes need
|
|
233
279
|
`api_action_authorization` and `api_assignment_authorization`), `admin_tenantless_access = :deny`,
|
|
234
|
-
`nested_attributes_parent_fallback = false`, `unconfigured_field_strategy = :block`,
|
|
280
|
+
`nested_attributes_parent_fallback = false`, `unconfigured_field_strategy = :block`,
|
|
281
|
+
`unconfigured_action_strategy = :block` (since 0.15: a mapped controller action no Permission Set manages is refused
|
|
282
|
+
with 403; `:allow`, the explicit opt-out, lets every signed-in user run it), and the API is off
|
|
235
283
|
(`api_enabled = false`). If you use PostgreSQL RLS, run `bin/rails permission_forge:rls:sync` after each upgrade.
|
|
236
284
|
|
|
237
285
|
See [Configuration](https://github.com/fetijashari/permission_forge/blob/main/docs/configuration.md) for every option.
|
|
@@ -265,11 +313,14 @@ Every create, update and destroy, made through the models, of a Permission Set,
|
|
|
265
313
|
assignment or membership is written to the `audit_events` table inside the change's own transaction. Under the default
|
|
266
314
|
`audit_change_failure_strategy = :raise`, a change whose event cannot be written rolls back. Each event stores the
|
|
267
315
|
actor, source, request id, IP, an optional reason, a correlation id, and the changes or a snapshot of the deleted row.
|
|
268
|
-
|
|
316
|
+
Behind your own admin controller (`base_controller_class`) the actor is the user `current_user_resolver` returns.
|
|
317
|
+
Under HTTP Basic auth your app's sign-in does not run in the admin, so admins share one login name unless
|
|
318
|
+
`audit_actor_resolver` names them:
|
|
269
319
|
|
|
270
|
-
```ruby
|
|
320
|
+
```ruby fragment
|
|
271
321
|
PermissionForge.configure do |config|
|
|
272
|
-
|
|
322
|
+
# Rails 8's authentication generator (with Devise: controller.request.env['warden']&.user(:user))
|
|
323
|
+
config.audit_actor_resolver = ->(controller) { Session.find_by(id: controller.request.cookie_jar.signed[:session_id])&.user }
|
|
273
324
|
config.audit_retention_period = 2.years # purge: bin/rails permission_forge:audit_events:purge
|
|
274
325
|
end
|
|
275
326
|
```
|
|
@@ -16,7 +16,7 @@ module PermissionForge
|
|
|
16
16
|
# def collection_scope = @resource.resource_fields
|
|
17
17
|
# def current_record = @field
|
|
18
18
|
# def serializer_type = :resource_field
|
|
19
|
-
# def record_params =
|
|
19
|
+
# def record_params = pf_record_attributes(:resource_field, PermissionForge::ResourceField, :name, :label)
|
|
20
20
|
# end
|
|
21
21
|
module CrudActions
|
|
22
22
|
extend ActiveSupport::Concern
|
|
@@ -71,7 +71,8 @@ module PermissionForge
|
|
|
71
71
|
# @return [Symbol] key for serializer_for()
|
|
72
72
|
def serializer_type = raise(NotImplementedError, "#{self.class}#serializer_type")
|
|
73
73
|
|
|
74
|
-
# @return [
|
|
74
|
+
# @return [ActiveSupport::HashWithIndifferentAccess] the attributes, in
|
|
75
|
+
# their shapes (BaseController#pf_record_attributes)
|
|
75
76
|
def record_params = raise(NotImplementedError, "#{self.class}#record_params")
|
|
76
77
|
|
|
77
78
|
# @return [ActiveRecord::Base] new record built from collection_scope and params
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Api
|
|
5
|
+
# The one shape table of the JSON API's attribute sections
|
|
6
|
+
# (`{ "permission_set": { ... } }`): what each attribute is taken in,
|
|
7
|
+
# read through BaseController#pf_record_attributes by every endpoint
|
|
8
|
+
# that creates or updates a record. A value of another shape is a 422
|
|
9
|
+
# keyed by the attribute, every wrong attribute of the request named
|
|
10
|
+
# (`{ "is_active": ["must be true or false"] }`): never dropped, never
|
|
11
|
+
# cast into something else, never a 500. Through 0.15.0-rc strong
|
|
12
|
+
# parameters dropped a list or an object sent for a scalar with a log
|
|
13
|
+
# line, and ActiveModel cast what remained: "nope" was true, a JSON
|
|
14
|
+
# `true` for a name stored "t" (field test round 7, d-api #54 / #59).
|
|
15
|
+
#
|
|
16
|
+
# An attribute's shape is its column type's (BY_TYPE: what the model
|
|
17
|
+
# casts it to, `type_for_attribute`), unless DECLARED names one:
|
|
18
|
+
#
|
|
19
|
+
# - boolean: ParamShapes.boolean (true, false, 1, 0, and the words
|
|
20
|
+
# true/false/t/f/on/off/1/0 in any case), stored as true or false;
|
|
21
|
+
# - text: a String;
|
|
22
|
+
# - integer: an Integer or a String of digits;
|
|
23
|
+
# - the activation conditions: `allowed_ips` a String or a list of
|
|
24
|
+
# Strings (stored with commas; a list with no entry is refused),
|
|
25
|
+
# an hour an integer or digits, a session flag a String — `""` clears
|
|
26
|
+
# each;
|
|
27
|
+
# - a rule's `field_value`: a String, or a JSON number or boolean (stored
|
|
28
|
+
# as its text: `5` → "5", `true` → "true"); a list of them, none with a
|
|
29
|
+
# comma, only for an operator that takes several values (`in`,
|
|
30
|
+
# `not_in`, `between`: Operators::ValueTyping::SEVERAL), stored joined
|
|
31
|
+
# with commas;
|
|
32
|
+
# - a reference (`permission_set_group_id`): taken as sent, then read by
|
|
33
|
+
# BaseController#with_reference_id (the id or uuid of a record; null or
|
|
34
|
+
# "" clears it; anything else a 422 keyed by it).
|
|
35
|
+
#
|
|
36
|
+
# `null` is passed to the model for every attribute: a nullable column
|
|
37
|
+
# is cleared, a NOT NULL one answers its own validation (422 keyed by
|
|
38
|
+
# the attribute). An attribute whose type has no shape here (none of the
|
|
39
|
+
# gem's: the input_shapes test checks) is passed on as sent.
|
|
40
|
+
#
|
|
41
|
+
# @api private
|
|
42
|
+
module InputShapes
|
|
43
|
+
# How one shape reads a value: the value to store, or a Refusal.
|
|
44
|
+
Refusal = Data.define(:message)
|
|
45
|
+
|
|
46
|
+
# The 422 of a section with values of the wrong shape: every attribute
|
|
47
|
+
# and its message.
|
|
48
|
+
#
|
|
49
|
+
# @api private
|
|
50
|
+
class Refused < ParamShapes::InvalidParam
|
|
51
|
+
# @return [Hash{String => Array<String>}]
|
|
52
|
+
attr_reader :messages
|
|
53
|
+
|
|
54
|
+
# @param messages [Hash{String => Array<String>}] at least one
|
|
55
|
+
def initialize(messages)
|
|
56
|
+
@messages = messages
|
|
57
|
+
param, (detail, *) = messages.first
|
|
58
|
+
super(param, detail)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# What a rule needs to read its value: the operator it is compared
|
|
63
|
+
# with (the request's, else the stored rule's, else the column default).
|
|
64
|
+
Context = Data.define(:operator)
|
|
65
|
+
|
|
66
|
+
# The shape of each column type the API writes.
|
|
67
|
+
BY_TYPE = { boolean: :boolean, string: :text, text: :text, integer: :integer }.freeze
|
|
68
|
+
|
|
69
|
+
# The attributes whose shape is not their column type's.
|
|
70
|
+
DECLARED = {
|
|
71
|
+
'allowed_ips' => :ip_list, 'requires_session_flag' => :flag,
|
|
72
|
+
'allowed_hours_start' => :hour, 'allowed_hours_end' => :hour,
|
|
73
|
+
'field_value' => :rule_value, 'permission_set_group_id' => :reference
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
76
|
+
# The shapes whose blank String ("") is no value (null): a condition
|
|
77
|
+
# or a reference is cleared with it.
|
|
78
|
+
BLANK_CLEARS = %i[ip_list flag hour reference].freeze
|
|
79
|
+
|
|
80
|
+
# Each shape's reader, (value, Context) → the value to store, or a
|
|
81
|
+
# Refusal (its message key under permission_forge.api.errors).
|
|
82
|
+
SHAPES = {
|
|
83
|
+
boolean: ->(value, _) { ParamShapes.boolean(value).then { |read| read.nil? ? Refusal.new('boolean_invalid') : read } },
|
|
84
|
+
text: ->(value, _) { value.is_a?(String) ? value : Refusal.new('text_shape') },
|
|
85
|
+
integer: ->(value, _) { whole_number?(value) ? value : Refusal.new('integer_shape') },
|
|
86
|
+
hour: ->(value, _) { whole_number?(value) ? value : Refusal.new('hour_shape') },
|
|
87
|
+
flag: ->(value, _) { value.is_a?(String) ? value : Refusal.new('flag_shape') },
|
|
88
|
+
ip_list: ->(value, _) { ip_list(value) },
|
|
89
|
+
rule_value: ->(value, context) { rule_value(value, context) },
|
|
90
|
+
reference: ->(value, _) { value },
|
|
91
|
+
as_is: ->(value, _) { value }
|
|
92
|
+
}.freeze
|
|
93
|
+
|
|
94
|
+
# A whole number as an hour or a count is sent: an Integer, or digits
|
|
95
|
+
# with an optional minus sign and surrounding white space.
|
|
96
|
+
WHOLE_NUMBER = /\A\s*-?\d+\s*\z/
|
|
97
|
+
|
|
98
|
+
module_function
|
|
99
|
+
|
|
100
|
+
# The `keys` of `attrs` it has, each read in its shape.
|
|
101
|
+
#
|
|
102
|
+
# @param attrs [ActionController::Parameters] the section (NUL bytes dropped)
|
|
103
|
+
# @param model [Class] the model the section writes
|
|
104
|
+
# @param keys [Array<Symbol, String>] the attributes the endpoint takes
|
|
105
|
+
# @param record [ActiveRecord::Base, nil] the record an update changes
|
|
106
|
+
# (a rule's stored operator reads its value)
|
|
107
|
+
# @return [ActiveSupport::HashWithIndifferentAccess]
|
|
108
|
+
# @raise [Refused] naming every attribute of the wrong shape
|
|
109
|
+
def call(attrs, model, keys, record: nil)
|
|
110
|
+
context = Context.new(operator: operator_of(attrs, model, record))
|
|
111
|
+
read = keys.map(&:to_s).select { |key| attrs.key?(key) }.to_h { |key| [key, read_value(attrs[key], shape_of(model, key), context)] }
|
|
112
|
+
refused = read.select { |_, value| value.is_a?(Refusal) }
|
|
113
|
+
raise Refused, messages(refused) if refused.any?
|
|
114
|
+
|
|
115
|
+
read.with_indifferent_access
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# One parameter outside a section (a copy's `name`) in `shape`; nil
|
|
119
|
+
# is no value.
|
|
120
|
+
#
|
|
121
|
+
# @raise [Refused] keyed by `key` for another shape
|
|
122
|
+
# @return [void]
|
|
123
|
+
def check!(key, value, shape)
|
|
124
|
+
read = value.nil? ? nil : SHAPES.fetch(shape).call(value, nil)
|
|
125
|
+
raise Refused, messages(key.to_s => read) if read.is_a?(Refusal)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The shape `key` of `model` is read in: DECLARED, else its type's
|
|
129
|
+
# (BY_TYPE), else :as_is.
|
|
130
|
+
#
|
|
131
|
+
# @return [Symbol] a SHAPES key
|
|
132
|
+
def shape_of(model, key)
|
|
133
|
+
DECLARED.fetch(key.to_s) { BY_TYPE.fetch(model.type_for_attribute(key.to_s).type, :as_is) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The field-keyed messages of the Refusals.
|
|
137
|
+
def messages(refusals)
|
|
138
|
+
refusals.transform_values { |refusal| [I18n.t("permission_forge.api.errors.#{refusal.message}")] }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def read_value(value, shape, context)
|
|
142
|
+
value = nil if value == '' && BLANK_CLEARS.include?(shape)
|
|
143
|
+
return value if value.nil?
|
|
144
|
+
|
|
145
|
+
SHAPES.fetch(shape).call(value, context)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def operator_of(attrs, model, record)
|
|
149
|
+
operator = attrs['operator'] if attrs.key?('operator')
|
|
150
|
+
return operator if operator.is_a?(String)
|
|
151
|
+
return record.operator if record.respond_to?(:operator)
|
|
152
|
+
|
|
153
|
+
model.column_defaults['operator']
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def whole_number?(value)
|
|
157
|
+
value.is_a?(Integer) || (value.is_a?(String) && value.match?(WHOLE_NUMBER))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# A String (commas, newlines or spaces between the entries) or a list
|
|
161
|
+
# of Strings, the entries joined with commas by the one parser
|
|
162
|
+
# (ActivationConditions). A list with no entry (`[]`, `[""]`, `" , "`)
|
|
163
|
+
# is refused rather than read as "no condition": null (or "") clears.
|
|
164
|
+
def ip_list(value)
|
|
165
|
+
return Refusal.new('ip_list_shape') unless value.is_a?(String) || (value.is_a?(Array) && value.all?(String))
|
|
166
|
+
|
|
167
|
+
PermissionForge::ActivationConditions.entries(value).join(',').presence || Refusal.new('ip_list_empty')
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# A rule's value as the text the rule stores (see the module).
|
|
171
|
+
def rule_value(value, context)
|
|
172
|
+
return list_text(value, context.operator) if value.is_a?(Array)
|
|
173
|
+
|
|
174
|
+
scalar_text(value) || Refusal.new('field_value_shape')
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# A list joined with commas, for an operator that takes several values;
|
|
178
|
+
# each element a scalar without a comma (the stored text is split at
|
|
179
|
+
# them: Operators::ValueTyping.split).
|
|
180
|
+
def list_text(value, operator)
|
|
181
|
+
typing = PermissionForge::Operators::ValueTyping
|
|
182
|
+
texts = value.map { |element| scalar_text(element) }
|
|
183
|
+
joinable = typing::SEVERAL.include?(typing::SHAPES[operator]) && texts.any? && texts.none? { |text| text.nil? || text.include?(',') }
|
|
184
|
+
joinable ? texts.join(',') : Refusal.new('field_value_shape')
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The text of a JSON scalar: a String as sent, a number as written
|
|
188
|
+
# (a fraction without an exponent: 1.5 → "1.5"), true / false as
|
|
189
|
+
# "true" / "false"; nil for anything else.
|
|
190
|
+
def scalar_text(value)
|
|
191
|
+
case value
|
|
192
|
+
when String then value
|
|
193
|
+
when Integer, true, false then value.to_s
|
|
194
|
+
when Float then BigDecimal(value.to_s).to_s('F') if value.finite?
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
private_class_method :messages, :read_value, :operator_of, :whole_number?, :ip_list, :rule_value, :list_text, :scalar_text
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -6,6 +6,10 @@ module PermissionForge
|
|
|
6
6
|
# Beyond this the offset overflows the database's integer; the page is
|
|
7
7
|
# empty long before.
|
|
8
8
|
MAX_PAGE = (2**31) - 1
|
|
9
|
+
# What a page or a page size is: a positive whole number
|
|
10
|
+
# (ParamShapes#pf_integer_param reads it; "abc", "0", "-1", "2abc",
|
|
11
|
+
# 1.5 and a list are not one).
|
|
12
|
+
POSITIVE = (1..)
|
|
9
13
|
|
|
10
14
|
private
|
|
11
15
|
|
|
@@ -34,16 +38,19 @@ module PermissionForge
|
|
|
34
38
|
}
|
|
35
39
|
end
|
|
36
40
|
|
|
37
|
-
#
|
|
41
|
+
# params[:page] when it is a positive whole number (at most MAX_PAGE),
|
|
42
|
+
# else the first page.
|
|
38
43
|
def current_page
|
|
39
|
-
|
|
44
|
+
pf_integer_param(:page, default: 1, range: POSITIVE).clamp(1, MAX_PAGE)
|
|
40
45
|
end
|
|
41
46
|
|
|
47
|
+
# params[:per_page] when it is a positive whole number, else
|
|
48
|
+
# config.api_default_per_page; never above config.api_max_per_page.
|
|
49
|
+
# A typo (`per_page=abc`), 0 or a negative number is the default page
|
|
50
|
+
# size, not a page of one row (through 0.14.1 they read as 1).
|
|
42
51
|
def clamped_per_page
|
|
43
52
|
config = PermissionForge.configuration
|
|
44
|
-
|
|
45
|
-
requested = requested.nil? ? config.api_default_per_page : requested.to_s.to_i
|
|
46
|
-
requested.clamp(1, config.api_max_per_page)
|
|
53
|
+
pf_integer_param(:per_page, default: config.api_default_per_page, range: POSITIVE).clamp(1, config.api_max_per_page)
|
|
47
54
|
end
|
|
48
55
|
end
|
|
49
56
|
end
|
|
@@ -21,6 +21,20 @@ module PermissionForge
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# One record and the cache outcome of the change that made or removed
|
|
25
|
+
# it (Cache::Outcome, as a bulk assignment answers it):
|
|
26
|
+
# `{ "data": { ... }, "meta": { "cache": { ... } } }`, the record by
|
|
27
|
+
# the serializer of its type (`permission_set_user`).
|
|
28
|
+
def render_record_with_cache(record, outcome, status:)
|
|
29
|
+
serializer_class = serializer_for(record.model_name.element.to_sym)
|
|
30
|
+
meta = { cache: outcome.to_h }
|
|
31
|
+
if ams_serializer?(serializer_class)
|
|
32
|
+
render json: record, serializer: serializer_class, root: :data, meta: meta, status: status
|
|
33
|
+
else
|
|
34
|
+
render json: { data: serializer_class.new(record).as_json, meta: meta }, status: status
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
24
38
|
def render_collection(scope, serializer_class)
|
|
25
39
|
paginator = PermissionForge.configuration.api_pagination_handler
|
|
26
40
|
records = paginator ? paginate_scope(scope, paginator) : default_paginate_scope(scope)
|
|
@@ -35,7 +49,29 @@ module PermissionForge
|
|
|
35
49
|
end
|
|
36
50
|
|
|
37
51
|
def render_validation_errors(record)
|
|
38
|
-
|
|
52
|
+
render_unprocessable(record.errors.messages)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The one 422 shape: `messages` maps a field (a parameter, an attribute,
|
|
56
|
+
# or "base" when the error names none) to its messages, for validation
|
|
57
|
+
# errors and unreadable input alike.
|
|
58
|
+
def render_unprocessable(messages)
|
|
59
|
+
render json: { error: { status: 422, messages: messages } }, status: :unprocessable_entity
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# An input error (ParamShapes::InvalidParam, a missing or malformed
|
|
63
|
+
# body section, Rails' own BadRequest) as field-keyed messages.
|
|
64
|
+
def render_input_error(error)
|
|
65
|
+
render_unprocessable(input_error_messages(error))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def input_error_messages(error)
|
|
69
|
+
case error
|
|
70
|
+
when PermissionForge::ParamShapes::InvalidParam then error.messages
|
|
71
|
+
when ActionController::ParameterMissing
|
|
72
|
+
{ error.param.to_s => [I18n.t('permission_forge.api.errors.section_missing')] }
|
|
73
|
+
else { 'base' => [error.message] }
|
|
74
|
+
end
|
|
39
75
|
end
|
|
40
76
|
|
|
41
77
|
# Saves a record and renders the created resource or its validation errors.
|
|
@@ -20,6 +20,13 @@ module PermissionForge
|
|
|
20
20
|
|
|
21
21
|
# A refused request records api.auth_failed: who the host knows (the
|
|
22
22
|
# audit actor resolver, the signed-in user), else unknown.
|
|
23
|
+
#
|
|
24
|
+
# api_auth answers whether the caller is authenticated: a falsy answer
|
|
25
|
+
# is a 401, so a client that refreshes its credentials on 401 does so.
|
|
26
|
+
# What an authenticated caller may change is the authorization lambdas'
|
|
27
|
+
# decision (403). The gem cannot know the host's scheme, so it sends no
|
|
28
|
+
# WWW-Authenticate of its own: one the lambda set on
|
|
29
|
+
# `controller.response.headers` is kept.
|
|
23
30
|
def authenticate_api_request!
|
|
24
31
|
auth_check = PermissionForge.configuration.api_auth
|
|
25
32
|
if auth_check.nil?
|
|
@@ -28,8 +35,12 @@ module PermissionForge
|
|
|
28
35
|
end
|
|
29
36
|
return if auth_check.call(self)
|
|
30
37
|
|
|
31
|
-
audit_api_auth_failure('api_auth_denied',
|
|
32
|
-
|
|
38
|
+
audit_api_auth_failure('api_auth_denied', 401)
|
|
39
|
+
# Runs before the request's locale is assigned (set_api_locale is a
|
|
40
|
+
# later callback): the 401 says it in the request's language too
|
|
41
|
+
# (through 0.14.1 always in the default locale).
|
|
42
|
+
set_api_locale
|
|
43
|
+
render json: { error: { status: 401, messages: I18n.t('permission_forge.api.errors.unauthorized') } }, status: :unauthorized
|
|
33
44
|
end
|
|
34
45
|
|
|
35
46
|
def audit_api_auth_failure(reason, status)
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module PermissionForge
|
|
4
4
|
# For admin actions that clear somebody's cached permissions: says what the
|
|
5
|
-
# cache store confirmed,
|
|
5
|
+
# cache store confirmed, and when the store lives in this process only,
|
|
6
|
+
# instead of announcing success whatever happened. One Cache::Outcome
|
|
7
|
+
# (Cache::Report) is the datum, as in the rake tasks, the API and the
|
|
8
|
+
# audit events.
|
|
6
9
|
#
|
|
7
10
|
# @api private
|
|
8
11
|
module CacheConfirmation
|
|
@@ -10,23 +13,51 @@ module PermissionForge
|
|
|
10
13
|
|
|
11
14
|
private
|
|
12
15
|
|
|
13
|
-
#
|
|
14
|
-
#
|
|
16
|
+
# Measures the invalidations made in the block (Cache::Report.of). The
|
|
17
|
+
# gem's own failure bookkeeping is asked, not the value of
|
|
15
18
|
# invalidate_permissions: host apps override that method.
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
#
|
|
20
|
+
# @return [PermissionForge::Cache::Outcome]
|
|
21
|
+
def cache_outcome(&)
|
|
22
|
+
PermissionForge::Cache::Report.of(&)
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
# The notice — or, when the cache store did not confirm that the cached
|
|
21
26
|
# permissions are gone, the alert, which says the old ones may still be
|
|
22
|
-
# served
|
|
23
|
-
# `
|
|
27
|
+
# served (a confirmation not known yet, inside a transaction of the
|
|
28
|
+
# host's, is not a failure). `keys` names both, under
|
|
29
|
+
# permission_forge.flash.<controller_name>: `{ notice: :erased, alert: :erased_cache_kept }`.
|
|
30
|
+
# When the store lives in this process only (Outcome#process_local?) the
|
|
31
|
+
# text ends with flash.cache_process_local: other processes kept theirs.
|
|
24
32
|
#
|
|
33
|
+
# @param outcome [PermissionForge::Cache::Outcome, Hash] an Outcome, or a service's `cache:`
|
|
25
34
|
# @return [Hash] for redirect_to
|
|
26
|
-
def cache_flash(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
def cache_flash(outcome, keys, **)
|
|
36
|
+
outcome = PermissionForge::Cache::Outcome.wrap(outcome)
|
|
37
|
+
kind = outcome.flash_kind
|
|
38
|
+
cache_scoped(outcome, kind => t("permission_forge.flash.#{controller_name}.#{keys.fetch(kind)}", **))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The flash for a change whose message has no alert of its own (one
|
|
42
|
+
# assignment, a batch of edits): `text` as the notice, or — when the
|
|
43
|
+
# store did not confirm — as the alert, followed by
|
|
44
|
+
# flash.cache_not_confirmed; flash.cache_process_local as in #cache_flash.
|
|
45
|
+
#
|
|
46
|
+
# @param outcome [PermissionForge::Cache::Outcome]
|
|
47
|
+
# @param text [String] the translated notice
|
|
48
|
+
# @return [Hash] for redirect_to / pf_redirect
|
|
49
|
+
def cache_flash_text(outcome, text)
|
|
50
|
+
kind = outcome.flash_kind
|
|
51
|
+
text = "#{text} #{t('permission_forge.flash.cache_not_confirmed')}" if kind == :alert
|
|
52
|
+
cache_scoped(outcome, kind => text)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# `flash` with flash.cache_process_local appended when the store lives
|
|
56
|
+
# in this process only.
|
|
57
|
+
def cache_scoped(outcome, flash)
|
|
58
|
+
return flash unless outcome.process_local?
|
|
59
|
+
|
|
60
|
+
flash.transform_values { |text| "#{text} #{t('permission_forge.flash.cache_process_local')}" }
|
|
30
61
|
end
|
|
31
62
|
end
|
|
32
63
|
end
|