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
|
@@ -4,18 +4,29 @@ module PermissionForge
|
|
|
4
4
|
module Services
|
|
5
5
|
module SecurityAuditChecks
|
|
6
6
|
# Record (row-level) rules: whether the app's query scoping applies them,
|
|
7
|
-
# rules
|
|
8
|
-
#
|
|
7
|
+
# rules that match nothing (Operators::RuleProblem, the one taxonomy —
|
|
8
|
+
# each kind lands in one finding), rules naming a table of another
|
|
9
|
+
# model, rules View All makes pointless, and likely misconfigurations.
|
|
9
10
|
class RuleCheck < BaseCheck
|
|
10
11
|
TEXT_OPERATORS = %w[contains starts_with ends_with].freeze
|
|
11
|
-
|
|
12
|
-
#
|
|
13
|
-
|
|
12
|
+
|
|
13
|
+
# The kinds of Operators::RuleProblem each finding reports: one
|
|
14
|
+
# finding per kind. A new kind goes in exactly one list (the
|
|
15
|
+
# taxonomy guard fails for a kind no finding names).
|
|
16
|
+
FINDING_KINDS = {
|
|
17
|
+
missing_rule_columns: %i[missing_column unknown_association unjoinable_association unknown_table],
|
|
18
|
+
unknown_rule_operators: %i[unknown_operator unknown_join_type],
|
|
19
|
+
unusable_rule_values: %i[unusable_value],
|
|
20
|
+
foreign_table_filter_rules: %i[foreign_table],
|
|
21
|
+
encrypted_rule_columns: %i[encrypted_column],
|
|
22
|
+
non_text_pattern_rules: %i[pattern_on_non_text]
|
|
23
|
+
}.freeze
|
|
14
24
|
|
|
15
25
|
def call
|
|
16
26
|
return [no_rules] unless active_rules.exists?
|
|
17
27
|
|
|
18
|
-
[unenforced, unusable_values,
|
|
28
|
+
[unenforced, missing_columns, unknown_operators, unusable_values, foreign_table_filters, encrypted_columns,
|
|
29
|
+
non_text_patterns, secret_columns, foreign_table_names, bypassed, text_operator_on_ids].compact
|
|
19
30
|
end
|
|
20
31
|
|
|
21
32
|
private
|
|
@@ -24,14 +35,26 @@ module PermissionForge
|
|
|
24
35
|
PermissionForge::PermissionSetRule.active
|
|
25
36
|
end
|
|
26
37
|
|
|
38
|
+
# The advice follows the setting: with auto_query_scoping on, rules
|
|
39
|
+
# are all that is missing.
|
|
27
40
|
def no_rules
|
|
41
|
+
advice = if configuration.auto_query_scoping_enabled?
|
|
42
|
+
'otherwise add rules (auto_query_scoping is on, so a filter rule applies to every query).'
|
|
43
|
+
else
|
|
44
|
+
'otherwise add rules and enable auto_query_scoping.'
|
|
45
|
+
end
|
|
28
46
|
finding(:info, :no_rules, 'No record rules are in use',
|
|
29
47
|
'Anyone with read access to a resource sees every record of it. That is fine if your app ' \
|
|
30
|
-
|
|
48
|
+
"limits records itself; #{advice}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# What the admin's Home shows: the same rules (AttentionItems).
|
|
52
|
+
def attention
|
|
53
|
+
@attention ||= PermissionForge::Admin::AttentionItems.new(configuration: configuration)
|
|
31
54
|
end
|
|
32
55
|
|
|
33
56
|
def unenforced
|
|
34
|
-
findings =
|
|
57
|
+
findings = attention.unenforced_rules
|
|
35
58
|
return if findings.empty?
|
|
36
59
|
|
|
37
60
|
held = findings.any? { |item| item.people_count.positive? }
|
|
@@ -55,65 +78,172 @@ module PermissionForge
|
|
|
55
78
|
"#{people_label(item.people_count)}"
|
|
56
79
|
end
|
|
57
80
|
|
|
81
|
+
# The rules in effect Home lists as matching nothing
|
|
82
|
+
# (AttentionItems#broken_rules), of the kinds one finding reports.
|
|
83
|
+
def broken(code)
|
|
84
|
+
attention.broken_rules.select { |item| FINDING_KINDS.fetch(code).include?(item.problem.kind) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The finding's severity: the first while people hold one of the rules, else the second.
|
|
88
|
+
def weight(findings, held, unheld)
|
|
89
|
+
findings.any? { |item| item.people_count.positive? } ? held : unheld
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Rules saved before PermissionSetRule checked the column, the table
|
|
93
|
+
# and the path, or written past the model (the tables are shared with
|
|
94
|
+
# other applications).
|
|
95
|
+
def missing_columns
|
|
96
|
+
findings = broken(:missing_rule_columns)
|
|
97
|
+
return if findings.empty?
|
|
98
|
+
|
|
99
|
+
finding(weight(findings, :high, :medium), :missing_rule_columns,
|
|
100
|
+
'Rules naming a column, table or association that does not exist',
|
|
101
|
+
'The field name of these rules is not a column of their table (or not a name: a trailing or doubled ' \
|
|
102
|
+
'dot), names a table this database does not have, or their association path names an association ' \
|
|
103
|
+
'their model does not have (or an empty segment) or one a join cannot be built through ' \
|
|
104
|
+
'(polymorphic, a scope that takes the record — also along a :through —, a :through, :source or ' \
|
|
105
|
+
'inverse_of naming nothing, a class that cannot be loaded) — a typo, or a column or association ' \
|
|
106
|
+
'since renamed or removed. Such a rule ' \
|
|
107
|
+
'matches nothing: everyone it applies to is denied every record of the resource. Correct the rule ' \
|
|
108
|
+
'or remove it (see "Using the wrong field name" in docs/row-level-rules.md).',
|
|
109
|
+
items: findings.map { |item| rule_item(item.rule) })
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Rules whose operator or join type this version does not have:
|
|
113
|
+
# written by a newer version (a rolling deploy), by another
|
|
114
|
+
# application on the shared tables, or past the model.
|
|
115
|
+
def unknown_operators
|
|
116
|
+
findings = broken(:unknown_rule_operators)
|
|
117
|
+
return if findings.empty?
|
|
118
|
+
|
|
119
|
+
finding(weight(findings, :high, :medium), :unknown_rule_operators,
|
|
120
|
+
'Rules with an operator or join type this version does not know',
|
|
121
|
+
'The operator or join type of these rules is none this version of PermissionForge has — written ' \
|
|
122
|
+
'by a newer version (a rolling deploy), by another application on the shared tables, or past the ' \
|
|
123
|
+
'model. Such a rule matches nothing: everyone it applies to is denied every record of the resource ' \
|
|
124
|
+
'(never an error). Finish the upgrade, or correct the rule.',
|
|
125
|
+
items: findings.map { |item| unknown_operator_item(item) })
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def unknown_operator_item(item)
|
|
129
|
+
rule = item.rule
|
|
130
|
+
note = " (join type #{rule.join_type.inspect})" if item.problem.kind == :unknown_join_type
|
|
131
|
+
rule_item(rule, note)
|
|
132
|
+
end
|
|
133
|
+
|
|
58
134
|
def unusable_values
|
|
59
|
-
|
|
60
|
-
return if
|
|
135
|
+
findings = broken(:unusable_rule_values)
|
|
136
|
+
return if findings.empty?
|
|
61
137
|
|
|
62
|
-
|
|
63
|
-
finding(held ? :medium : :low, :unusable_rule_values, 'Rules that match nothing',
|
|
138
|
+
finding(weight(findings, :medium, :low), :unusable_rule_values, 'Rules that match nothing',
|
|
64
139
|
'The value of these rules is not one their column can hold: text on a number column, a word that ' \
|
|
65
140
|
'is no boolean, an unknown enum label, a date that is not ISO 8601 (on MySQL and SQLite), a list ' \
|
|
66
|
-
'or range with the wrong number of values
|
|
67
|
-
'
|
|
68
|
-
'
|
|
69
|
-
|
|
70
|
-
items: rules.map { |rule| unusable_item(rule) })
|
|
141
|
+
'or range with the wrong number of values. Such a rule matches nothing: everyone it applies to is ' \
|
|
142
|
+
'denied every record of the resource. Correct the rule or remove it (see "How Values Are ' \
|
|
143
|
+
'Compared" in docs/row-level-rules.md).',
|
|
144
|
+
items: findings.map { |item| rule_item(item.rule) })
|
|
71
145
|
end
|
|
72
146
|
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
147
|
+
# Filter rules qualified with another model's table (RuleProblem
|
|
148
|
+
# :foreign_table): a filter rule compares its model's own table, so
|
|
149
|
+
# such a rule matches nothing (it raised in every query of its holders).
|
|
150
|
+
def foreign_table_filters
|
|
151
|
+
findings = broken(:foreign_table_filter_rules).presence or return
|
|
152
|
+
|
|
153
|
+
finding(weight(findings, :high, :medium), :foreign_table_filter_rules,
|
|
154
|
+
'Filter rules naming a table of another model',
|
|
155
|
+
'The field name of these filter rules is qualified with the table of another model. A filter rule ' \
|
|
156
|
+
'compares its own model\'s table, so such a rule matches nothing: everyone it applies to is denied ' \
|
|
157
|
+
'every record of the resource. For a column of another table, use a join rule (a join type and the ' \
|
|
158
|
+
'relationship path in field_accessor_path).', items: findings.map { |item| rule_item(item.rule) })
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Rules comparing a value with an encrypted attribute (RuleProblem
|
|
162
|
+
# :encrypted_column): the column holds ciphertext, so such a rule
|
|
163
|
+
# matches nothing. Saved before the host encrypted the attribute, or
|
|
164
|
+
# written past the model.
|
|
165
|
+
def encrypted_columns
|
|
166
|
+
findings = broken(:encrypted_rule_columns).presence or return
|
|
167
|
+
|
|
168
|
+
finding(weight(findings, :high, :medium), :encrypted_rule_columns,
|
|
169
|
+
'Rules comparing an encrypted attribute',
|
|
170
|
+
'These rules compare a value with an attribute the model declares with `encrypts` (Active Record ' \
|
|
171
|
+
'encryption): the column holds its ciphertext, so the comparison never matches and everyone the rule ' \
|
|
172
|
+
'applies to is denied every record of the resource. Only is_null / is_not_null can be asked of such a ' \
|
|
173
|
+
'column. Compare another column, or remove the rule (see "Encrypted attributes" in ' \
|
|
174
|
+
'docs/row-level-rules.md).', items: findings.map { |item| rule_item(item.rule) })
|
|
81
175
|
end
|
|
82
176
|
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
return false unless target
|
|
177
|
+
# LIKE rules on a column no pattern can be matched against (RuleProblem
|
|
178
|
+
# :pattern_on_non_text): a json column, or on PostgreSQL any column
|
|
179
|
+
# that is not text. Reported as unusable_rule_values before 0.15.
|
|
180
|
+
def non_text_patterns
|
|
181
|
+
findings = broken(:non_text_pattern_rules).presence or return
|
|
89
182
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
183
|
+
finding(weight(findings, :medium, :low), :non_text_pattern_rules,
|
|
184
|
+
'Pattern rules on a column that is not text',
|
|
185
|
+
'These rules use contains, not_contains, starts_with or ends_with on a column that holds no text ' \
|
|
186
|
+
'(a number, a date, a boolean, a uuid or a json document on PostgreSQL; a json column everywhere): such ' \
|
|
187
|
+
'a rule matches nothing, so everyone it applies to is denied every record of the resource. Use a ' \
|
|
188
|
+
'condition that compares values (equal, in, between), or remove the rule.',
|
|
189
|
+
items: findings.map { |item| rule_item(item.rule) })
|
|
96
190
|
end
|
|
97
191
|
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
model = rule.resource.name.safe_constantize
|
|
105
|
-
return unless model.is_a?(Class) && model < ActiveRecord::Base
|
|
192
|
+
# Rules in effect comparing a column that holds a secret
|
|
193
|
+
# (PermissionForge::SecretColumns) — what Home lists
|
|
194
|
+
# (AttentionItems#secret_rules). Stored before the model refused them;
|
|
195
|
+
# they still apply. The item leaves the value out: it is a guess at the secret.
|
|
196
|
+
def secret_columns
|
|
197
|
+
findings = attention.secret_rules.presence or return
|
|
106
198
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
199
|
+
finding(:medium, :secret_rule_columns, 'Rules comparing a column that holds a secret',
|
|
200
|
+
'These rules compare a password digest, a token, an `encrypted_*` column or a secret ' \
|
|
201
|
+
'(config.rule_secret_column_patterns; an attribute declared with `encrypts` is ' \
|
|
202
|
+
'encrypted_rule_columns). They still apply, but who can read which record tells the ' \
|
|
203
|
+
'secret apart, and the rule value is stored and shown in the admin and the API. Compare another ' \
|
|
204
|
+
'column, or remove the rule; a new rule on such a column is refused.',
|
|
205
|
+
items: findings.map { |item| secret_rule_item(item.rule) })
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def secret_rule_item(rule)
|
|
209
|
+
resource = rule.resource
|
|
210
|
+
path = rule.field_accessor_path.presence if rule.join_rule?
|
|
211
|
+
"#{resource.permission_set.name} › #{[resource.name, path, rule.field_name].compact.join('.')}: #{rule.name} · " \
|
|
212
|
+
"#{people(resource.permission_set_id)}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Join rules in effect whose field name is qualified with a real table
|
|
216
|
+
# that is none of the rule's (Operators::RuleColumn::FOREIGN): the
|
|
217
|
+
# query has to join that table itself (a default scope's join) — the
|
|
218
|
+
# value is compared untyped, and where the query does not join it the
|
|
219
|
+
# rule matches nothing. (A filter rule's is a RuleProblem.)
|
|
220
|
+
def foreign_table_names
|
|
221
|
+
rules = rules_in_effect.select { |rule| foreign_table?(rule) }
|
|
222
|
+
return if rules.empty?
|
|
223
|
+
|
|
224
|
+
finding(:info, :foreign_table_names, 'Join rules naming a table of another model',
|
|
225
|
+
'The field name of these rules is qualified with a table that is none of the rule\'s — not the ' \
|
|
226
|
+
'model\'s, nor one along its relationship path — so it is left to the database: the query has to ' \
|
|
227
|
+
'join that table itself (where it does not, the rule matches nothing), and the value is compared as ' \
|
|
228
|
+
'text. Name the relationship in field_accessor_path instead, so the column is checked and the value ' \
|
|
229
|
+
'typed.', items: rules.map { |rule| rule_item(rule) })
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def foreign_table?(rule)
|
|
233
|
+
rule.join_rule? && Operators::RuleColumn.for_rule(rule)&.foreign?
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# The rules applied right now (AttentionItems#rules_in_effect).
|
|
237
|
+
def rules_in_effect
|
|
238
|
+
attention.rules_in_effect
|
|
110
239
|
end
|
|
111
240
|
|
|
112
|
-
|
|
241
|
+
# "Sales › Customer.supplier.owner_id equal "no": Off · 1 person"
|
|
242
|
+
def rule_item(rule, note = nil)
|
|
113
243
|
resource = rule.resource
|
|
114
244
|
path = rule.field_accessor_path.presence if rule.join_rule?
|
|
115
245
|
field = [resource.name, path, rule.field_name].compact.join('.')
|
|
116
|
-
"#{resource.permission_set.name} › #{field} #{rule.operator} #{rule.field_value.inspect}: #{rule.name} · " \
|
|
246
|
+
"#{resource.permission_set.name} › #{field} #{rule.operator} #{rule.field_value.inspect}: #{rule.name}#{note} · " \
|
|
117
247
|
"#{people(resource.permission_set_id)}"
|
|
118
248
|
end
|
|
119
249
|
|
|
@@ -5,8 +5,11 @@ module PermissionForge
|
|
|
5
5
|
module SecurityAuditChecks
|
|
6
6
|
# Plain-text rendering of a SecurityAudit::Report for terminals and logs.
|
|
7
7
|
class TextReport
|
|
8
|
-
|
|
8
|
+
# @param logged [Boolean, nil] whether the audit log records this run
|
|
9
|
+
# (report.generated); nil asks Audit.enabled?
|
|
10
|
+
def initialize(report, logged: nil)
|
|
9
11
|
@report = report
|
|
12
|
+
@logged = logged
|
|
10
13
|
end
|
|
11
14
|
|
|
12
15
|
def to_s
|
|
@@ -38,7 +41,16 @@ module PermissionForge
|
|
|
38
41
|
def footer
|
|
39
42
|
return 'No findings.' if @report.findings.empty?
|
|
40
43
|
|
|
41
|
-
|
|
44
|
+
"#{unchanged} FORMAT=json for machine output; FAIL_ON=high to fail CI."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The report changes no permission data, but its run is an audit
|
|
48
|
+
# event (RakeAudit.reporting) whenever the log records.
|
|
49
|
+
def unchanged
|
|
50
|
+
logged = @logged.nil? ? PermissionForge::Audit.enabled? : @logged
|
|
51
|
+
return 'No permission data was changed.' unless logged
|
|
52
|
+
|
|
53
|
+
'No permission data was changed; this run is recorded in the audit log (report.generated).'
|
|
42
54
|
end
|
|
43
55
|
end
|
|
44
56
|
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
module SecurityAuditChecks
|
|
6
|
+
# The guarded controller actions no Permission Set manages, as the
|
|
7
|
+
# ActionCheck lists them: the ones the guard hands to
|
|
8
|
+
# unconfigured_action_strategy (ControllerActions::Authorizer rows 4 and 8).
|
|
9
|
+
#
|
|
10
|
+
# "Managed" is per tenant (ManagedIndex): with tenancy, an action is
|
|
11
|
+
# listed with the tenants that do not manage it, among those that have
|
|
12
|
+
# an active Permission Set.
|
|
13
|
+
#
|
|
14
|
+
# UnmanagedActions.new(entries: catalog.mapped_entries, managed: { nil => { 'Customer' => Set['export'] } }).items
|
|
15
|
+
# # => ["Invoice (resource not managed): invoices#index, invoices#show",
|
|
16
|
+
# # "Customer › archive: customers#archive"]
|
|
17
|
+
# # with tenants: [3, 4, 9]
|
|
18
|
+
# # => ["Customer (resource not managed in 2 of 3 tenants: 3, 9): customers#index, customers#archive",
|
|
19
|
+
# # "Customer › archive (not managed in 1 of 3 tenants: 4): customers#archive"]
|
|
20
|
+
class UnmanagedActions
|
|
21
|
+
# Tenant ids named in one item; the count says how many there are.
|
|
22
|
+
TENANT_LIMIT = 10
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Reads what is managed in each tenant from the rows in effect
|
|
26
|
+
# (Resource.in_effect, ResourceAction.in_effect: what ManagedIndex
|
|
27
|
+
# reads, for every tenant at once). The tenants judged are those
|
|
28
|
+
# with an active Permission Set; with none, every action is listed
|
|
29
|
+
# without a tenant.
|
|
30
|
+
#
|
|
31
|
+
# @return [UnmanagedActions]
|
|
32
|
+
def build(entries:, configuration:)
|
|
33
|
+
tenant = configuration.resolved_tenant_column
|
|
34
|
+
new(entries: entries, managed: managed_index(tenant), tenants: tenant && tenants_with_sets(tenant))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Who the guard refuses them to: signed-out requests follow
|
|
38
|
+
# action_guard_nil_user_strategy (else nil_user_strategy) before the
|
|
39
|
+
# strategy is asked.
|
|
40
|
+
#
|
|
41
|
+
# @return [String]
|
|
42
|
+
def refused_to(configuration)
|
|
43
|
+
signed_out = if configuration.action_guard_nil_user_bypass?
|
|
44
|
+
' (signed-out requests run them: action_guard_nil_user_strategy, else nil_user_strategy, is :bypass)'
|
|
45
|
+
else
|
|
46
|
+
', and to signed-out visitors'
|
|
47
|
+
end
|
|
48
|
+
"to every signed-in user action_guard_bypass does not let through#{signed_out}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# A resource row is [tenant, name], an action row [tenant, name, key].
|
|
54
|
+
def managed_index(tenant)
|
|
55
|
+
(resource_rows(tenant) + action_rows(tenant)).each_with_object({}) do |(id, name, key), index|
|
|
56
|
+
keys = ((index[id] ||= {})[name] ||= Set.new)
|
|
57
|
+
keys << key if key
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def resource_rows(tenant)
|
|
62
|
+
PermissionForge::Resource.in_effect.distinct.pluck(tenant_of(PermissionForge::Resource, tenant), resource_name)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def action_rows(tenant)
|
|
66
|
+
PermissionForge::ResourceAction.in_effect.distinct
|
|
67
|
+
.pluck(tenant_of(PermissionForge::Resource, tenant), resource_name,
|
|
68
|
+
PermissionForge::ResourceAction.arel_table[:name])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def resource_name = PermissionForge::Resource.arel_table[:name]
|
|
72
|
+
|
|
73
|
+
def tenants_with_sets(tenant)
|
|
74
|
+
ids = PermissionForge::PermissionSet.active.distinct.pluck(tenant_of(PermissionForge::PermissionSet, tenant))
|
|
75
|
+
(ids.compact.sort + ids.select(&:nil?)).presence
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def tenant_of(model, tenant)
|
|
79
|
+
tenant ? model.arel_table[tenant] : Arel.sql('NULL')
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @param entries [Array<PermissionForge::Admin::ControllerCatalog::Entry>]
|
|
84
|
+
# every mapped controller action (ControllerCatalog#mapped_entries)
|
|
85
|
+
# @param managed [Hash{Object => Hash{String => Set<String>}}] tenant id
|
|
86
|
+
# (nil without tenancy) => the managed action keys of each resource
|
|
87
|
+
# some Permission Set of that tenant manages (ManagedIndex's shape)
|
|
88
|
+
# @param tenants [Array, nil] the tenants to judge, nil without tenancy
|
|
89
|
+
def initialize(entries:, managed:, tenants: nil)
|
|
90
|
+
@entries = entries.select(&:guarded?)
|
|
91
|
+
@managed = managed
|
|
92
|
+
@tenants = tenants
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [Array<String>] one item per resource some tenant does not
|
|
96
|
+
# manage, then one per custom action of a resource a tenant manages
|
|
97
|
+
# without managing the action
|
|
98
|
+
def items
|
|
99
|
+
resource_items + action_items
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def judged = @tenants || [nil]
|
|
105
|
+
|
|
106
|
+
def index(tenant) = @managed.fetch(tenant, {})
|
|
107
|
+
|
|
108
|
+
def resource_items
|
|
109
|
+
@entries.group_by(&:resource).filter_map do |resource, group|
|
|
110
|
+
missing = judged.reject { |tenant| index(tenant).key?(resource) }
|
|
111
|
+
"#{resource} (resource not managed#{where(missing)}): #{endpoints(group)}" unless missing.empty?
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# An action whose name is not a valid key can never get a row: it says so.
|
|
116
|
+
def action_items
|
|
117
|
+
@entries.reject(&:crud?).group_by(&:pair).filter_map do |(resource, key), group|
|
|
118
|
+
missing = judged.select { |tenant| index(tenant).fetch(resource, nil)&.exclude?(key) }
|
|
119
|
+
action_item(group, missing) unless missing.empty?
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def action_item(group, missing)
|
|
124
|
+
first = group.first
|
|
125
|
+
note = ' (not a valid action key)' unless first.manageable?
|
|
126
|
+
"#{first.resource} › #{first.key}#{" (not managed#{where(missing)})" if @tenants}: #{endpoints(group)}#{note}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# " in 2 of 5 tenants: 3, 9", or nothing without tenancy.
|
|
130
|
+
def where(missing)
|
|
131
|
+
return '' unless @tenants
|
|
132
|
+
|
|
133
|
+
shown = missing.first(TENANT_LIMIT).map { |tenant| tenant_label(tenant) }
|
|
134
|
+
" in #{missing.size} of #{@tenants.size} tenants: #{shown.join(', ')}#{', …' if missing.size > TENANT_LIMIT}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def tenant_label(tenant) = tenant.nil? ? 'none' : tenant.to_s
|
|
138
|
+
|
|
139
|
+
def endpoints(group)
|
|
140
|
+
group.map(&:endpoint).uniq.join(', ')
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -37,15 +37,16 @@ module PermissionForge
|
|
|
37
37
|
'PermissionForge.without_permission_scope.')
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
# :hide
|
|
40
|
+
# :hide and :hide_in_requests exempt the user model so sign-in can read it.
|
|
41
41
|
def user_model_unmasked_without_user
|
|
42
|
-
return unless configuration.
|
|
42
|
+
return unless configuration.hide_masked_attributes_without_user_in_requests?
|
|
43
43
|
|
|
44
44
|
names = user_model_resource_names
|
|
45
45
|
return if names.empty?
|
|
46
46
|
|
|
47
47
|
finding(:low, :user_model_unmasked_without_user, 'The user model is not masked with nobody signed in',
|
|
48
|
-
|
|
48
|
+
"masked_attributes_nil_user_strategy is :#{configuration.masked_attributes_nil_user_strategy}, " \
|
|
49
|
+
'but the user model is exempt so that sign-in ' \
|
|
49
50
|
"can read it: with nobody signed in, #{user_model.name}'s columns — password digests and " \
|
|
50
51
|
'reset or confirmation tokens included — read as stored through its getters and as_json. ' \
|
|
51
52
|
'Do not render it with no current user. Another authenticatable model (a second Devise ' \
|
|
@@ -9,8 +9,8 @@ module PermissionForge
|
|
|
9
9
|
#
|
|
10
10
|
# Each check is a class answering `#call` with an array of Finding,
|
|
11
11
|
# listed by `SecurityAudit.checks` (config, privilege, assignment, rule,
|
|
12
|
-
# integrity, personal data, controller actions,
|
|
13
|
-
# user model);
|
|
12
|
+
# integrity, activation conditions, personal data, controller actions,
|
|
13
|
+
# the audit trail and the user model);
|
|
14
14
|
# add one by passing `checks:` to `call` or by overriding `.checks`
|
|
15
15
|
# (OCP). Nothing is ever changed.
|
|
16
16
|
#
|
|
@@ -35,20 +35,32 @@ module PermissionForge
|
|
|
35
35
|
Report = Struct.new(:findings, :generated_at, :preset, keyword_init: true) do
|
|
36
36
|
def counts = SEVERITIES.index_with { |severity| findings.count { |finding| finding.severity == severity } }
|
|
37
37
|
|
|
38
|
+
# Findings of `severity` or worse. The name is read in any case
|
|
39
|
+
# (:high, 'HIGH'); one that is not a severity counts as :info.
|
|
38
40
|
def at_least(severity)
|
|
39
|
-
limit = SEVERITIES.index(severity
|
|
41
|
+
limit = SEVERITIES.index(SecurityAudit.severity_named(severity)) || SEVERITIES.size
|
|
40
42
|
findings.select { |finding| finding.rank <= limit }
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
def to_h = { generated_at: generated_at.iso8601, preset: preset, counts: counts, findings: findings.map(&:to_h) }
|
|
44
46
|
end
|
|
45
47
|
|
|
48
|
+
# The severity a name stands for, in any case and with surrounding
|
|
49
|
+
# blanks ignored (`FAIL_ON=HIGH`, `' medium'`).
|
|
50
|
+
#
|
|
51
|
+
# @param name [String, Symbol, nil]
|
|
52
|
+
# @return [Symbol, nil] one of SEVERITIES, nil for anything else
|
|
53
|
+
def self.severity_named(name)
|
|
54
|
+
wanted = name.to_s.strip.downcase
|
|
55
|
+
SEVERITIES.find { |severity| severity.to_s == wanted }
|
|
56
|
+
end
|
|
57
|
+
|
|
46
58
|
# @return [Array<Class>] the check classes run by default
|
|
47
59
|
def self.checks
|
|
48
60
|
[
|
|
49
61
|
SecurityAuditChecks::ConfigCheck, SecurityAuditChecks::PrivilegeCheck,
|
|
50
62
|
SecurityAuditChecks::AssignmentCheck, SecurityAuditChecks::RuleCheck,
|
|
51
|
-
SecurityAuditChecks::IntegrityCheck, SecurityAuditChecks::PersonalDataCheck,
|
|
63
|
+
SecurityAuditChecks::IntegrityCheck, SecurityAuditChecks::ConditionsCheck, SecurityAuditChecks::PersonalDataCheck,
|
|
52
64
|
SecurityAuditChecks::ActionCheck, SecurityAuditChecks::AuditTrailCheck,
|
|
53
65
|
SecurityAuditChecks::UserModelCheck
|
|
54
66
|
]
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
module PermissionForge
|
|
2
2
|
module Services
|
|
3
3
|
# Audits permanent permission set assignments that haven't been reviewed
|
|
4
|
-
# within a
|
|
5
|
-
#
|
|
4
|
+
# within a threshold (default: config.admin_stale_assignment_days, the
|
|
5
|
+
# threshold of the admin's Stale list; when that is nil,
|
|
6
|
+
# Configuration::AdminUiSettings::DEFAULT_STALE_ASSIGNMENT_DAYS, 90).
|
|
7
|
+
# Reviewed means the last access review "Keep" (reviewed_at), else the grant (created_at) —
|
|
6
8
|
# PermissionSetUser.stale_before; editing an assignment is not a review.
|
|
7
9
|
# Supports GDPR Art. 5(1)(e) — Storage Limitation.
|
|
8
10
|
#
|
|
@@ -21,12 +23,13 @@ module PermissionForge
|
|
|
21
23
|
class StaleAssignmentAuditor
|
|
22
24
|
include Concerns::AcrossTenants
|
|
23
25
|
|
|
24
|
-
# @param threshold_days [Integer] how long a permanent assignment may go
|
|
26
|
+
# @param threshold_days [Integer, nil] how long a permanent assignment may go
|
|
27
|
+
# unreviewed; nil: config.admin_stale_assignment_days
|
|
25
28
|
# @return [Hash] see {#call}
|
|
26
29
|
def self.call(...) = new(...).call
|
|
27
30
|
|
|
28
|
-
def initialize(threshold_days:
|
|
29
|
-
@threshold_days = threshold_days
|
|
31
|
+
def initialize(threshold_days: nil, configuration: PermissionForge.configuration)
|
|
32
|
+
@threshold_days = threshold_days || PermissionForge::Admin::AttentionItems.stale_days(configuration)
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
# @return [Hash] `{ stale_count:, affected_user_ids:, assignments: }`
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# Rails' own timestamps (created_at / updated_at, created_on / updated_on)
|
|
5
|
+
# in the field rows the gem generates: ResourceFieldGenerator (also
|
|
6
|
+
# Resource.grant_full_access! and the API's generate_fields) and the
|
|
7
|
+
# admin's presets and "Sync fields" (Admin::ColumnFields).
|
|
8
|
+
#
|
|
9
|
+
# They are metadata a reader of the record may see and nobody edits, so on
|
|
10
|
+
# a granting Permission Set a generated timestamp row is at most Read-only:
|
|
11
|
+
# a level that grants read (Masked, Read-only, Editable) gives it read on,
|
|
12
|
+
# edit off and no mask; a level that grants nothing (Hidden, "No access",
|
|
13
|
+
# the generator without grant_all) leaves it hidden like every other column
|
|
14
|
+
# (a granting row adds access, so a readable timestamp there would widen
|
|
15
|
+
# what the set's holders see). On a Muting Permission Set read on means
|
|
16
|
+
# "take read away": its rows are left as the level says.
|
|
17
|
+
#
|
|
18
|
+
# Rows written one by one (the admin's field editor, the API, the console)
|
|
19
|
+
# are not changed.
|
|
20
|
+
#
|
|
21
|
+
# @api private
|
|
22
|
+
class TimestampFields
|
|
23
|
+
# Rails' default names (ActiveRecord::Timestamp); only those the model has
|
|
24
|
+
# as columns count (PermissionForge::SchemaRead: a timestamp a migration
|
|
25
|
+
# just added is read-only too).
|
|
26
|
+
NAMES = %w[created_at created_on updated_at updated_on].freeze
|
|
27
|
+
|
|
28
|
+
READ_ONLY = { read_access: true, edit_access: false, mask_access: false }.freeze
|
|
29
|
+
|
|
30
|
+
# When the columns cannot be read (no table, a database that cannot be
|
|
31
|
+
# asked) every name counts: a generated row of one is then at most
|
|
32
|
+
# Read-only, never Editable (fail closed — the names that are not
|
|
33
|
+
# columns get no row anyway).
|
|
34
|
+
#
|
|
35
|
+
# @param model_class [Class, nil]
|
|
36
|
+
# @return [Array<String>] the timestamp columns of the model
|
|
37
|
+
def self.names(model_class)
|
|
38
|
+
return [] unless model_class.respond_to?(:column_names)
|
|
39
|
+
|
|
40
|
+
SchemaRead.columns_among(model_class, NAMES)
|
|
41
|
+
rescue ActiveRecord::ActiveRecordError
|
|
42
|
+
NAMES
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param model_class [Class, nil] the Resource's model
|
|
46
|
+
# @param muting [Boolean] the rows are a Muting Permission Set's
|
|
47
|
+
def initialize(model_class, muting:)
|
|
48
|
+
@names = muting ? [] : self.class.names(model_class)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# What a generated row of `name` gets over the flags of its level.
|
|
52
|
+
#
|
|
53
|
+
# @param name [String] the field name
|
|
54
|
+
# @param read [Boolean] the level gives the row read_access
|
|
55
|
+
# @return [Hash] Read-only for a readable timestamp of a granting set, else nothing
|
|
56
|
+
def overrides(name, read:)
|
|
57
|
+
read && @names.include?(name.to_s) ? READ_ONLY : {}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|