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
|
@@ -20,6 +20,23 @@ module PermissionForge
|
|
|
20
20
|
module RlsOperatorSql
|
|
21
21
|
# `current_user` in a policy: the SQL the session user's id is read with.
|
|
22
22
|
SessionUser = Struct.new(:sql)
|
|
23
|
+
# A value the policy computes when it runs (a relative date): its SQL.
|
|
24
|
+
Dynamic = Struct.new(:sql)
|
|
25
|
+
|
|
26
|
+
# PostgreSQL's relative date and time words, as the expression each
|
|
27
|
+
# stands for when a statement runs. Quoted into a policy, PostgreSQL
|
|
28
|
+
# resolves the word once, when it parses CREATE POLICY: the policy kept
|
|
29
|
+
# the sync's date while the app layer, which sends the word with every
|
|
30
|
+
# query, reads it anew. So the policy computes it per statement instead.
|
|
31
|
+
RELATIVE_VALUES = {
|
|
32
|
+
'now' => 'now()', 'today' => 'CURRENT_DATE', 'tomorrow' => '(CURRENT_DATE + 1)', 'yesterday' => '(CURRENT_DATE - 1)'
|
|
33
|
+
}.freeze
|
|
34
|
+
# A relative word anywhere in the text (`today 10:00`, `tomorrow UTC`):
|
|
35
|
+
# no exact per-statement form is built for those — the rule is FALSE in
|
|
36
|
+
# the policy (narrower than the app layer).
|
|
37
|
+
RELATIVE_WORD = /\b(?:#{RELATIVE_VALUES.keys.join('|')})\b/i
|
|
38
|
+
# The column types PostgreSQL reads those words for.
|
|
39
|
+
TEMPORAL_TYPES = %i[date time datetime timestamp timestamptz].freeze
|
|
23
40
|
|
|
24
41
|
UNUSABLE = Operators::ValueTyping::UNUSABLE
|
|
25
42
|
|
|
@@ -44,48 +61,51 @@ module PermissionForge
|
|
|
44
61
|
}.freeze
|
|
45
62
|
|
|
46
63
|
# The operators whose app-layer strategy adds the NULL rows under
|
|
47
|
-
# include_nulls
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
Operators::Constants::IN, Operators::Constants::NOT_IN
|
|
51
|
-
].freeze
|
|
52
|
-
|
|
53
|
-
# The column types PostgreSQL has ILIKE for. On a column of any other
|
|
54
|
-
# type the app layer's LIKE query raises, so the rule compiles to FALSE.
|
|
55
|
-
LIKE_TYPES = %i[string text citext].freeze
|
|
64
|
+
# include_nulls (Operators::Registry::NULL_INCLUDING, the one list). The
|
|
65
|
+
# others ignore the flag, and so does the policy.
|
|
66
|
+
NULL_INCLUDING = Operators::Registry::NULL_INCLUDING
|
|
56
67
|
|
|
57
68
|
# How the session user's id is read for a comparison, by the caster the
|
|
58
69
|
# column's type has in the app layer: the id itself where the column is
|
|
59
|
-
# a number or
|
|
60
|
-
# column. A column of any other type (a
|
|
61
|
-
#
|
|
70
|
+
# a number (or a uuid) the id is one of, or of a type nothing is known
|
|
71
|
+
# about, its text on a string column. A column of any other type (a
|
|
72
|
+
# boolean, a date, an address, a json or array column, a native enum)
|
|
73
|
+
# cannot equal an id — the rule is UNUSABLE there, as it is in the app
|
|
74
|
+
# layer.
|
|
75
|
+
# The database a policy is written for.
|
|
76
|
+
POLICY_ADAPTER = 'PostgreSQL'
|
|
77
|
+
|
|
62
78
|
SESSION_USER_SQL = {
|
|
63
79
|
Operators::ValueCasters::UNTYPED => :uid_sql,
|
|
64
80
|
Operators::ValueCasters::NUMBER => :numeric_uid_sql,
|
|
65
81
|
Operators::ValueCasters::Number::SINGLE_PRECISION => :numeric_uid_sql,
|
|
82
|
+
Operators::ValueCasters::PostgresqlTypes::UUID => :uuid_uid_sql,
|
|
66
83
|
Operators::ValueCasters::TEXT => :uid_text_sql
|
|
67
84
|
}.freeze
|
|
68
85
|
|
|
69
|
-
# A rule's value as the app layer compares it with the column.
|
|
86
|
+
# A rule's value as the app layer compares it with the column. A column
|
|
87
|
+
# that holds ciphertext (ValueTyping.encrypted?) takes no value, the
|
|
88
|
+
# user's id included.
|
|
70
89
|
#
|
|
71
90
|
# @param target [Operators::RuleColumn] the column the rule names
|
|
72
91
|
# @return [Object] a typed literal, an Array of them, a SessionUser, or UNUSABLE
|
|
73
92
|
def typed_value(operator, value, target)
|
|
74
|
-
return UNUSABLE if
|
|
93
|
+
return UNUSABLE if Operators::ValueTyping.no_column?(operator, target) || unmatchable?(operator, target) ||
|
|
94
|
+
Operators::ValueTyping.encrypted?(operator, target)
|
|
75
95
|
return session_user(operator, target) if Operators::ValueTyping.current_user?(operator, value)
|
|
76
96
|
|
|
77
|
-
Operators::ValueTyping.call(operator, value, target)
|
|
97
|
+
relative(Operators::ValueTyping.call(operator, value, target), temporal_sql_type(target.column))
|
|
78
98
|
end
|
|
79
99
|
|
|
80
100
|
def value_sql(value)
|
|
81
101
|
case value
|
|
82
|
-
when SessionUser then value.sql
|
|
102
|
+
when SessionUser, Dynamic then value.sql
|
|
83
103
|
else connection.quote(value)
|
|
84
104
|
end
|
|
85
105
|
end
|
|
86
106
|
|
|
87
107
|
def list_sql(values, separator = ', ')
|
|
88
|
-
values.map { |value|
|
|
108
|
+
values.map { |value| value_sql(value) }.join(separator)
|
|
89
109
|
end
|
|
90
110
|
|
|
91
111
|
def like_sql(col, value, prefix:, suffix:)
|
|
@@ -99,12 +119,39 @@ module PermissionForge
|
|
|
99
119
|
|
|
100
120
|
private
|
|
101
121
|
|
|
102
|
-
# A
|
|
103
|
-
#
|
|
104
|
-
|
|
105
|
-
|
|
122
|
+
# A typed value (or each of a list's) with PostgreSQL's relative words
|
|
123
|
+
# (RELATIVE_VALUES) as the expression PostgreSQL computes per statement,
|
|
124
|
+
# cast to the column's type; text with such a word that is not the
|
|
125
|
+
# word alone is UNUSABLE (FALSE). Only on a date or time column: on
|
|
126
|
+
# any other a word is text, or was refused by the read back.
|
|
127
|
+
def relative(value, sql_type)
|
|
128
|
+
return value unless sql_type
|
|
129
|
+
return relative_one(value, sql_type) unless value.is_a?(Array)
|
|
130
|
+
|
|
131
|
+
typed = value.map { |element| relative_one(element, sql_type) }
|
|
132
|
+
typed.any? { |element| element.equal?(UNUSABLE) } ? UNUSABLE : typed
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The column's SQL type when it is a date or time column, else nil.
|
|
136
|
+
def temporal_sql_type(column)
|
|
137
|
+
column.sql_type if column && TEMPORAL_TYPES.include?(column.type)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def relative_one(value, sql_type)
|
|
141
|
+
return value unless value.is_a?(String) && value.match?(RELATIVE_WORD)
|
|
106
142
|
|
|
107
|
-
|
|
143
|
+
expression = RELATIVE_VALUES[value.strip.downcase]
|
|
144
|
+
expression ? Dynamic.new("CAST(#{expression} AS #{sql_type})") : UNUSABLE
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A LIKE operator on a column PostgreSQL cannot match a pattern with
|
|
148
|
+
# (ValueCasters.pattern? for PostgreSQL, the database every policy is
|
|
149
|
+
# for — as the app layer decides on PostgreSQL). A column the model
|
|
150
|
+
# does not have is UNUSABLE, as in the app layer.
|
|
151
|
+
def unmatchable?(operator, target)
|
|
152
|
+
return false unless target.column && Operators::ValueTyping::SHAPES[operator] == :pattern
|
|
153
|
+
|
|
154
|
+
!Operators::ValueCasters.pattern?(target.caster, POLICY_ADAPTER)
|
|
108
155
|
end
|
|
109
156
|
|
|
110
157
|
# A comparison takes the id as the column's type lets it. A LIKE pattern
|
|
@@ -123,8 +170,20 @@ module PermissionForge
|
|
|
123
170
|
|
|
124
171
|
# An id that is not a number (a uuid) is never the value of a number column.
|
|
125
172
|
def numeric_uid_sql
|
|
126
|
-
|
|
127
|
-
|
|
173
|
+
uid_sql if user_id_caster.nil? || user_id_caster.equal?(Operators::ValueCasters::NUMBER)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# A uuid column equals the id only where user ids are uuids: compared
|
|
177
|
+
# with the text of any other id, PostgreSQL raises (or the app layer,
|
|
178
|
+
# with an id that is no uuid, matches nothing). A user_id column of
|
|
179
|
+
# text that holds uuids is narrower here than in the app layer: FALSE.
|
|
180
|
+
def uuid_uid_sql
|
|
181
|
+
uid_sql if user_id_caster.equal?(Operators::ValueCasters::PostgresqlTypes::UUID)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def user_id_caster
|
|
185
|
+
column = PermissionForge::SchemaRead.column(PermissionForge::PermissionSetUser, 'user_id')
|
|
186
|
+
column && Operators::ValueCasters.for(column)
|
|
128
187
|
end
|
|
129
188
|
end
|
|
130
189
|
end
|
|
@@ -32,14 +32,19 @@ module PermissionForge
|
|
|
32
32
|
# resource row's is_active; the membership EXISTS reads both states
|
|
33
33
|
# per statement, so switching a set or a resource off or on needs no
|
|
34
34
|
# re-sync and the policy is never wider than the app layer in between.
|
|
35
|
-
# - A user in no rule-bearing set sees all rows (rules only restrict)
|
|
35
|
+
# - A user in no rule-bearing set sees all rows (rules only restrict),
|
|
36
|
+
# under no_read_access_strategy :rules_only (the default). Under :deny
|
|
37
|
+
# a signed-in user sees a row only with read access to the model's
|
|
38
|
+
# Resource chain, as RecordReadAccess decides it (RlsReadAccessSql,
|
|
39
|
+
# read per statement); the strategy is part of the digest.
|
|
36
40
|
# - Muting sets contribute no rules; conditional sets' rules are included
|
|
37
41
|
# (more restrictive than the app, never less — documented).
|
|
38
42
|
# - Missing session variable follows nil_user_strategy: :bypass allows,
|
|
39
43
|
# :deny/:raise hides every row.
|
|
40
44
|
# - A rule's value is typed for its column by the app layer's own
|
|
41
|
-
# Operators::ValueTyping; a value the column cannot hold
|
|
42
|
-
# FALSE, as the app layer matches
|
|
45
|
+
# Operators::ValueTyping; a value the column cannot hold, or a column
|
|
46
|
+
# the table does not have, compiles to FALSE, as the app layer matches
|
|
47
|
+
# nothing for it (see RlsOperatorSql).
|
|
43
48
|
# - Join-type rules and dotted field names are NOT translatable — they are
|
|
44
49
|
# reported in :skipped_rules, never silently dropped. So are the rules
|
|
45
50
|
# of a Resource named after an STI subclass of the model: a policy is
|
|
@@ -70,9 +75,22 @@ module PermissionForge
|
|
|
70
75
|
# 5: a rule set's membership EXISTS joins its resource row and needs it
|
|
71
76
|
# active; the rules are compiled whatever the row's — or the set's —
|
|
72
77
|
# state (the set's is_active was already read live).
|
|
73
|
-
|
|
78
|
+
# 6: a rule naming a column the table does not have compiles to FALSE
|
|
79
|
+
# (the sync failed on it), as the app layer matches nothing for it.
|
|
80
|
+
# 7: every Operators::RuleProblem kind compiles to FALSE (an unknown
|
|
81
|
+
# operator, join type or table, a path no join is built through); a
|
|
82
|
+
# value is typed for a uuid, inet, cidr, json/jsonb, array or native
|
|
83
|
+
# enum column (one it cannot hold is FALSE); an assignment counts until
|
|
84
|
+
# its expires_at, that instant included (`>= now()`); and only a set
|
|
85
|
+
# without an activation condition as PermissionSet.unconditional reads
|
|
86
|
+
# it (a text of blanks is none, one hour of the two is one) grants the
|
|
87
|
+
# view_all bypass. Under no_read_access_strategy :deny, a session user
|
|
88
|
+
# without read access to the model's Resource chain sees no row
|
|
89
|
+
# (RlsReadAccessSql; the strategy is part of the digest).
|
|
90
|
+
GENERATOR_VERSION = 7
|
|
74
91
|
|
|
75
92
|
include RlsOperatorSql
|
|
93
|
+
include RlsReadAccessSql
|
|
76
94
|
|
|
77
95
|
def self.call(model_class:, configuration: PermissionForge.configuration)
|
|
78
96
|
new(model_class: model_class, configuration: configuration).call
|
|
@@ -81,7 +99,10 @@ module PermissionForge
|
|
|
81
99
|
def initialize(model_class:, configuration: PermissionForge.configuration)
|
|
82
100
|
@model_class = model_class
|
|
83
101
|
@configuration = configuration
|
|
84
|
-
|
|
102
|
+
# The pool's lease, never the model's `connection` (it raises under
|
|
103
|
+
# Rails 7.2+ `permanent_connection_checkout = :disallowed`).
|
|
104
|
+
pool = model_class.connection_pool
|
|
105
|
+
@conn = pool.respond_to?(:lease_connection) ? pool.lease_connection : pool.connection
|
|
85
106
|
@skipped_rules = []
|
|
86
107
|
end
|
|
87
108
|
|
|
@@ -105,8 +126,8 @@ module PermissionForge
|
|
|
105
126
|
def digest
|
|
106
127
|
parts = [
|
|
107
128
|
GENERATOR_VERSION,
|
|
108
|
-
|
|
109
|
-
|
|
129
|
+
*strategy_fingerprint,
|
|
130
|
+
read_access_sql,
|
|
110
131
|
table_name,
|
|
111
132
|
tenant_predicate_sql,
|
|
112
133
|
rule_sets.map { |set| [set[:id], set[:resource_id], set[:rules].map { |r| rule_fingerprint(r) }.sort] }.sort
|
|
@@ -116,6 +137,11 @@ module PermissionForge
|
|
|
116
137
|
|
|
117
138
|
private
|
|
118
139
|
|
|
140
|
+
# The settings the policy is generated from.
|
|
141
|
+
def strategy_fingerprint
|
|
142
|
+
[@configuration.nil_user_strategy, @configuration.no_read_access_strategy, @configuration.rls_session_variable]
|
|
143
|
+
end
|
|
144
|
+
|
|
119
145
|
# Adapter connection used for quoting by RlsOperatorSql helpers too.
|
|
120
146
|
def connection
|
|
121
147
|
@conn
|
|
@@ -150,11 +176,17 @@ module PermissionForge
|
|
|
150
176
|
|
|
151
177
|
# :bypass -> uid IS NULL OR (view_all OR rules)
|
|
152
178
|
# :deny/:raise -> uid IS NOT NULL AND (view_all OR rules)
|
|
179
|
+
# Under no_read_access_strategy :deny the signed-in branch is
|
|
180
|
+
# (read_access AND (view_all OR rules)) — RlsReadAccessSql, checked
|
|
181
|
+
# before the rules as RecordReadAccess is: View All never stands in
|
|
182
|
+
# for read.
|
|
153
183
|
# The explicit uid guard matters: with a NULL uid every membership EXISTS
|
|
154
184
|
# is false, which would make the rules conjunction vacuously TRUE — the
|
|
155
185
|
# deny strategies must therefore hide rows before that branch is reached.
|
|
156
186
|
def using_expression
|
|
157
|
-
|
|
187
|
+
rows = "(#{view_all_bypass_sql} OR #{rules_conjunction_sql})"
|
|
188
|
+
read = read_access_sql
|
|
189
|
+
guarded = read ? "(#{read} AND #{rows})" : rows
|
|
158
190
|
if @configuration.nil_user_strategy == :bypass
|
|
159
191
|
"((#{uid_sql}) IS NULL OR #{guarded})"
|
|
160
192
|
else
|
|
@@ -171,11 +203,12 @@ module PermissionForge
|
|
|
171
203
|
end
|
|
172
204
|
|
|
173
205
|
def user_id_cast_type
|
|
174
|
-
PermissionForge::PermissionSetUser
|
|
206
|
+
PermissionForge::SchemaRead.column(PermissionForge::PermissionSetUser, 'user_id')&.sql_type || 'bigint'
|
|
175
207
|
end
|
|
176
208
|
|
|
177
|
-
#
|
|
178
|
-
#
|
|
209
|
+
# A granting set's View All bypasses the rules (bypass_flags_sql); a
|
|
210
|
+
# muting set with View All or Modify All cancels the bypass. Both
|
|
211
|
+
# checked dynamically.
|
|
179
212
|
#
|
|
180
213
|
# GDPR/consent guard: activation conditions (session flag / IP / login
|
|
181
214
|
# hours) cannot be evaluated inside a database policy, so only
|
|
@@ -187,41 +220,41 @@ module PermissionForge
|
|
|
187
220
|
"(#{view_all_exists_sql(muting: false)} AND NOT #{view_all_exists_sql(muting: true)})"
|
|
188
221
|
end
|
|
189
222
|
|
|
223
|
+
# Built without squish: the condition SQL quotes a newline, a carriage
|
|
224
|
+
# return and a tab (PermissionSet.conditions_present_sql), which squish
|
|
225
|
+
# would turn into spaces.
|
|
190
226
|
def view_all_exists_sql(muting:)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
AND (r.view_all = TRUE OR r.modify_all = TRUE)
|
|
204
|
-
)
|
|
205
|
-
SQL
|
|
227
|
+
resource_row_exists_sql(muting: muting, names: [resource_name],
|
|
228
|
+
extra: [(unconditional_set_sql unless muting), bypass_flags_sql(muting)])
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# A granting row bypasses the rules with View All alone: the app layer
|
|
232
|
+
# grants Modify All only together with View All
|
|
233
|
+
# (PermissionHashBuilder.granted_flags), so Modify All without it — a
|
|
234
|
+
# row written past the validation — bypasses nothing. A muting row
|
|
235
|
+
# cancels the bypass with either flag (narrower than the app layer,
|
|
236
|
+
# whose muting Modify All leaves View All standing).
|
|
237
|
+
def bypass_flags_sql(muting)
|
|
238
|
+
muting ? '(r.view_all = TRUE OR r.modify_all = TRUE)' : 'r.view_all = TRUE'
|
|
206
239
|
end
|
|
207
240
|
|
|
208
241
|
# Ties the resource row to the protected row's tenant, when tenancy is
|
|
209
|
-
# on and both tables have the tenant column:
|
|
242
|
+
# on and both tables have the tenant column (SchemaRead: read again once
|
|
243
|
+
# before "no"): `AND r.account_id =
|
|
210
244
|
# "customers"."account_id"`. A NULL tenant on either side matches
|
|
211
245
|
# nothing. Empty otherwise (no tenant to compare).
|
|
212
246
|
def tenant_predicate_sql
|
|
213
247
|
column = @configuration.tenant_enabled? && @configuration.resolved_tenant_column.to_s
|
|
214
|
-
return '' unless column && [@model_class, PermissionForge::Resource].all? { |klass|
|
|
248
|
+
return '' unless column && [@model_class, PermissionForge::Resource].all? { |klass| SchemaRead.column?(klass, column, ignored: false) }
|
|
215
249
|
|
|
216
250
|
quoted = @conn.quote_column_name(column)
|
|
217
251
|
"AND r.#{quoted} = #{quoted_table}.#{quoted}"
|
|
218
252
|
end
|
|
219
253
|
|
|
220
|
-
#
|
|
254
|
+
# PermissionSet.unconditional, on the `ps` alias: the one definition of
|
|
255
|
+
# "no activation condition".
|
|
221
256
|
def unconditional_set_sql
|
|
222
|
-
"
|
|
223
|
-
"AND (ps.allowed_ips IS NULL OR ps.allowed_ips = '') " \
|
|
224
|
-
'AND ps.allowed_hours_start IS NULL'
|
|
257
|
+
"NOT #{PermissionForge::PermissionSet.conditions_present_sql(table: 'ps')}"
|
|
225
258
|
end
|
|
226
259
|
|
|
227
260
|
# One conjunct per rule-bearing granting set:
|
|
@@ -255,9 +288,11 @@ module PermissionForge
|
|
|
255
288
|
SQL
|
|
256
289
|
end
|
|
257
290
|
|
|
291
|
+
# PermissionSetUser.active_assignments: an assignment counts until its
|
|
292
|
+
# expires_at, that instant included.
|
|
258
293
|
def assignment_active_sql
|
|
259
|
-
clauses = ['(psu.expires_at IS NULL OR psu.expires_at
|
|
260
|
-
clauses << 'psu.is_active = TRUE' if PermissionForge::
|
|
294
|
+
clauses = ['(psu.expires_at IS NULL OR psu.expires_at >= now())']
|
|
295
|
+
clauses << 'psu.is_active = TRUE' if PermissionForge::SchemaRead.column?(PermissionForge::PermissionSetUser, 'is_active', ignored: false)
|
|
261
296
|
clauses.join(' AND ')
|
|
262
297
|
end
|
|
263
298
|
|
|
@@ -273,9 +308,14 @@ module PermissionForge
|
|
|
273
308
|
@conn.quote_table_name(PermissionForge::Resource.table_name)
|
|
274
309
|
end
|
|
275
310
|
|
|
276
|
-
# A
|
|
277
|
-
#
|
|
311
|
+
# A rule the app layer matches nothing for (Operators::RuleProblem: the
|
|
312
|
+
# one taxonomy — a join type or operator this version does not know, a
|
|
313
|
+
# path no join is built through, a qualifier naming no table, a column
|
|
314
|
+
# the table does not have, a value it cannot hold) makes the rule FALSE
|
|
315
|
+
# — NULL rows included.
|
|
278
316
|
def rule_sql(rule)
|
|
317
|
+
return 'FALSE' if Operators::RuleProblem.for(rule)
|
|
318
|
+
|
|
279
319
|
operator, field_name = rule.values_at(:operator, :field_name)
|
|
280
320
|
value = typed_value(operator, rule.field_value, Operators::RuleColumn.find(field_name, [@model_class]))
|
|
281
321
|
return 'FALSE' if value.equal?(UNUSABLE)
|
|
@@ -343,10 +383,14 @@ module PermissionForge
|
|
|
343
383
|
PermissionForge::PermissionSetRule.active.where(resource_id: resources.select(:id)).includes(:permission_set)
|
|
344
384
|
end
|
|
345
385
|
|
|
346
|
-
#
|
|
347
|
-
#
|
|
386
|
+
# nil for a rule that is compiled, else why it is skipped (reported in
|
|
387
|
+
# :skipped_rules). A rule the app layer matches nothing for
|
|
388
|
+
# (Operators::RuleProblem) is compiled whatever it is — to FALSE
|
|
389
|
+
# (rule_sql) — never skipped, which would leave the policy wider than
|
|
390
|
+
# the app layer. An operator the app layer has (a custom one registered
|
|
391
|
+
# in Operators::Registry) but this module has no SQL for is skipped.
|
|
348
392
|
def untranslatable_reason(rule)
|
|
349
|
-
return
|
|
393
|
+
return if Operators::RuleProblem.for(rule)
|
|
350
394
|
return :join_rule if rule.join_rule?
|
|
351
395
|
return :dotted_field_name if rule.field_name.to_s.include?('.')
|
|
352
396
|
return :unknown_operator unless OPERATOR_SQL.key?(rule.operator)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
# The read-access gate of an RLS policy under
|
|
6
|
+
# `config.no_read_access_strategy = :deny`: the session user sees a row
|
|
7
|
+
# only when RecordReadAccess would let them read the model's records at
|
|
8
|
+
# all (ResourceChain.readable?) — an entry along the model's Resource
|
|
9
|
+
# chain, and every entry there grants read_access. Nothing under
|
|
10
|
+
# :rules_only (the policy applies the rules alone, as the app layer
|
|
11
|
+
# does). Extracted from {RlsPolicyGenerator} for SRP; it calls back into
|
|
12
|
+
# the generator's quoting, session-user and table helpers.
|
|
13
|
+
#
|
|
14
|
+
# Read per statement, as the app layer's hash is built from the rows:
|
|
15
|
+
# - an entry for a name: an active, unexpired assignment to an active
|
|
16
|
+
# granting set with an active Resource row of that name (with tenancy
|
|
17
|
+
# on, of the protected row's tenant, as the view_all bypass);
|
|
18
|
+
# - it grants read: such a row with read_access, in a set without an
|
|
19
|
+
# activation condition (PermissionSet.unconditional: a condition
|
|
20
|
+
# cannot be judged in a policy, so a conditional set never grants
|
|
21
|
+
# read there — its row counts as an entry without read);
|
|
22
|
+
# - read taken: an active Resource row with read_access in an active
|
|
23
|
+
# muting set the user holds (conditional or not) naming the entry's
|
|
24
|
+
# name or an ancestor's — a muting row reaches every entry whose
|
|
25
|
+
# chain names it (PermissionHashBuilder's muting phase).
|
|
26
|
+
# Each difference is narrower than the app layer, never wider.
|
|
27
|
+
#
|
|
28
|
+
# Single-table inheritance: the policy is per table and compiled for the
|
|
29
|
+
# class that includes the concern, so every row needs that class's chain
|
|
30
|
+
# readable; a row of a loaded STI subclass (its inheritance column holds
|
|
31
|
+
# the subclass's sti_name) also needs each entry of the subclass's own
|
|
32
|
+
# names to grant read when the user has one. That is the subclass's
|
|
33
|
+
# chain rule, never wider than a read through the base class or through
|
|
34
|
+
# the subclass (narrower than the subclass's when only its own Resource
|
|
35
|
+
# grants read: the base class's reads see nothing then).
|
|
36
|
+
#
|
|
37
|
+
# @api private
|
|
38
|
+
module RlsReadAccessSql
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# The gate, or nil under :rules_only. Part of the digest: switching the
|
|
42
|
+
# strategy makes the policy stale.
|
|
43
|
+
def read_access_sql
|
|
44
|
+
return unless @configuration.deny_record_reads_without_read_access?
|
|
45
|
+
|
|
46
|
+
own = ResourceChain.names(@model_class)
|
|
47
|
+
gates = [chain_readable_sql(own), *sti_subclasses.map { |klass| subclass_readable_sql(klass, own) }]
|
|
48
|
+
"(#{gates.join(' AND ')})"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ResourceChain.readable?: an entry along the chain, none without read.
|
|
52
|
+
# One name: its entry grants read (an entry that grants read exists).
|
|
53
|
+
def chain_readable_sql(names)
|
|
54
|
+
return readable_entry_sql(names.first, names) if names.one?
|
|
55
|
+
|
|
56
|
+
any_entry = names.map { |name| entry_exists_sql(name) }.join(' OR ')
|
|
57
|
+
"((#{any_entry}) AND #{entries_readable_sql(names, names)})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A row of the subclass: each of its own names (those the compiled
|
|
61
|
+
# class does not answer to) grants read when the user has an entry.
|
|
62
|
+
def subclass_readable_sql(klass, own)
|
|
63
|
+
chain = ResourceChain.names(klass)
|
|
64
|
+
"(#{inheritance_column_sql} IS DISTINCT FROM #{connection.quote(klass.sti_name)} " \
|
|
65
|
+
"OR #{entries_readable_sql(chain - own, chain)})"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# For each name: no entry, or an entry that grants read.
|
|
69
|
+
def entries_readable_sql(names, chain)
|
|
70
|
+
checks = names.map do |name|
|
|
71
|
+
"(NOT #{entry_exists_sql(name)} OR #{readable_entry_sql(name, chain)})"
|
|
72
|
+
end
|
|
73
|
+
"(#{checks.join(' AND ')})"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The entry of the name grants read and no muting row takes it: one
|
|
77
|
+
# naming it or a name after it along the chain (an ancestor's).
|
|
78
|
+
def readable_entry_sql(name, chain)
|
|
79
|
+
"(#{entry_exists_sql(name, read: true)} AND NOT #{read_muted_sql(chain.drop(chain.index(name)))})"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def entry_exists_sql(name, read: false)
|
|
83
|
+
resource_row_exists_sql(
|
|
84
|
+
muting: false, names: [name],
|
|
85
|
+
extra: read ? [unconditional_set_sql, 'r.read_access = TRUE'] : []
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def read_muted_sql(names)
|
|
90
|
+
resource_row_exists_sql(muting: true, names: names, extra: ['r.read_access = TRUE'])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The loaded STI subclasses on the table (a subclass with a table of
|
|
94
|
+
# its own is another policy's business), each with a name the compiled
|
|
95
|
+
# class does not answer to, in name order (the digest is stable).
|
|
96
|
+
# None without an inheritance column.
|
|
97
|
+
def sti_subclasses
|
|
98
|
+
return [] unless SchemaRead.column?(@model_class, @model_class.inheritance_column, ignored: false)
|
|
99
|
+
|
|
100
|
+
own = ResourceChain.names(@model_class).map(&:to_s)
|
|
101
|
+
@model_class.descendants.select { |klass| klass.table_name == table_name && klass.name && own.exclude?(klass.name) }
|
|
102
|
+
.sort_by(&:name)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Whether the session user holds, through an active, unexpired
|
|
106
|
+
# assignment, an active granting (or muting) set with an active
|
|
107
|
+
# Resource row of one of the names — of the protected row's tenant
|
|
108
|
+
# when tenancy is on — meeting the extra conditions. The read-access
|
|
109
|
+
# gate reads it, and so does the generator's view_all bypass.
|
|
110
|
+
def resource_row_exists_sql(muting:, names:, extra: [])
|
|
111
|
+
conditions = [
|
|
112
|
+
"psu.user_id = (#{uid_sql})", assignment_active_sql,
|
|
113
|
+
"ps.is_active = TRUE AND ps.is_muting = #{muting ? 'TRUE' : 'FALSE'}",
|
|
114
|
+
resource_names_sql(names), 'r.is_active = TRUE',
|
|
115
|
+
tenant_predicate_sql.delete_prefix('AND ').presence,
|
|
116
|
+
*extra
|
|
117
|
+
]
|
|
118
|
+
"EXISTS ( SELECT 1 FROM #{psu_table} psu JOIN #{ps_table} ps ON ps.id = psu.permission_set_id " \
|
|
119
|
+
"JOIN #{resources_table} r ON r.permission_set_id = ps.id WHERE #{conditions.compact.join(' AND ')} )"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def resource_names_sql(names)
|
|
123
|
+
"r.name IN (#{names.map { |name| connection.quote(name.to_s) }.join(', ')})"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def inheritance_column_sql
|
|
127
|
+
"#{quoted_table}.#{connection.quote_column_name(@model_class.inheritance_column)}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
module PermissionForge
|
|
4
4
|
module Services
|
|
5
5
|
module SecurityAuditChecks
|
|
6
|
-
# Controller actions as permissions:
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
6
|
+
# Controller actions as permissions: guarded controller actions no
|
|
7
|
+
# Permission Set manages (refused under the default strategy, open under
|
|
8
|
+
# :allow), managed actions nobody is permitted to run, managed actions
|
|
9
|
+
# whose controller does not run the guard, rows whose name can never be
|
|
10
|
+
# an action key, rows that match no controller action, and discovery
|
|
11
|
+
# switched on while no controller includes the guard.
|
|
10
12
|
class ActionCheck < BaseCheck
|
|
11
13
|
# One action row in effect (PermissionForge::ResourceAction.in_effect).
|
|
12
14
|
LiveRow = Struct.new(:tenant, :resource, :action, :permitted, :muting, :set) do
|
|
@@ -28,7 +30,8 @@ module PermissionForge
|
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
def call
|
|
31
|
-
[ungranted_actions, unguarded_actions, invalid_keys, unlinked_actions,
|
|
33
|
+
[unmanaged_actions, actions_not_checked, ungranted_actions, unguarded_actions, invalid_keys, unlinked_actions,
|
|
34
|
+
guard_missing].compact
|
|
32
35
|
end
|
|
33
36
|
|
|
34
37
|
private
|
|
@@ -70,6 +73,47 @@ module PermissionForge
|
|
|
70
73
|
"#{resource} › #{action}: #{unguarded}" unless unguarded.empty?
|
|
71
74
|
end
|
|
72
75
|
|
|
76
|
+
# Mapped controller actions, in controllers that run the guard, that
|
|
77
|
+
# some tenant's Permission Sets do not manage: the guard hands them to
|
|
78
|
+
# unconfigured_action_strategy. Read from the routes whether or not
|
|
79
|
+
# discovery is on, as the guard runs either way, so an app upgrading
|
|
80
|
+
# past the :allow default sees what now answers 403 before it deploys.
|
|
81
|
+
def unmanaged_actions
|
|
82
|
+
items = UnmanagedActions.build(entries: catalog.mapped_entries, configuration: configuration).items
|
|
83
|
+
return if items.empty?
|
|
84
|
+
|
|
85
|
+
if configuration.allow_unconfigured_actions?
|
|
86
|
+
finding(:info, :unmanaged_actions_allowed, 'Guarded actions no Permission Set manages run for everyone',
|
|
87
|
+
'unconfigured_action_strategy is :allow, so every signed-in user can run these controller ' \
|
|
88
|
+
'actions: no Permission Set manages them (unconfigured_actions_allowed rates the setting). ' \
|
|
89
|
+
'Manage each one in a Permission Set before you switch the strategy back to :block, the default.',
|
|
90
|
+
items: items)
|
|
91
|
+
else
|
|
92
|
+
finding(:low, :unmanaged_actions_refused, 'Guarded actions no Permission Set manages are refused',
|
|
93
|
+
'These controller actions map to a Resource and run the guard, but no Permission Set manages ' \
|
|
94
|
+
"them (with tenancy: in the tenants named), so the guard refuses them (403) #{UnmanagedActions.refused_to(configuration)} " \
|
|
95
|
+
'(unconfigured_action_strategy :block, the default since 0.15) — unless the controller skips ' \
|
|
96
|
+
'the guard for that action (skip_before_action :pf_authorize_action!, only: …), which the audit ' \
|
|
97
|
+
'does not see. Give each one a row in the Permission Sets that should run it; an action whose ' \
|
|
98
|
+
'name is not a valid action key needs a key first (permission_forge_actions actions: { ... }). ' \
|
|
99
|
+
'Or set unconfigured_action_strategy = :allow to let every signed-in user run them, as before 0.15.',
|
|
100
|
+
items: items)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The routes the catalog could not judge: the lists above may miss them.
|
|
105
|
+
def actions_not_checked
|
|
106
|
+
skipped = catalog.skipped
|
|
107
|
+
count = skipped.size
|
|
108
|
+
return if count.zero?
|
|
109
|
+
|
|
110
|
+
finding(:low, :actions_not_checked, "#{count} #{'controller action'.pluralize(count)} could not be checked (see the log)",
|
|
111
|
+
'A controller failed to load, or the controller_action_resolver or the mapping raised, so the ' \
|
|
112
|
+
'audit could not tell whether these run the guard or what they map to. They are missing from ' \
|
|
113
|
+
'unmanaged_actions_refused (and the other action findings); at run time the same error fails the ' \
|
|
114
|
+
'request. Fix the error the log names and run the audit again.', items: skipped)
|
|
115
|
+
end
|
|
116
|
+
|
|
73
117
|
# Managed (a row is in effect) but permitted by no granting set: the
|
|
74
118
|
# guard and can? refuse everyone.
|
|
75
119
|
def ungranted_actions
|
|
@@ -19,8 +19,9 @@ module PermissionForge
|
|
|
19
19
|
PermissionForge::PermissionSetUser.active_assignments
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# The admin's threshold (Admin::AttentionItems.stale_days, the one definition).
|
|
22
23
|
def stale_days
|
|
23
|
-
|
|
24
|
+
PermissionForge::Admin::AttentionItems.stale_days(configuration)
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
def stale
|
|
@@ -29,9 +29,7 @@ module PermissionForge
|
|
|
29
29
|
def table?
|
|
30
30
|
return @table if defined?(@table)
|
|
31
31
|
|
|
32
|
-
@table =
|
|
33
|
-
rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
|
|
34
|
-
@table = nil
|
|
32
|
+
@table = PermissionForge::DatabaseReach.table_exists?(audit_events) { return @table = nil }
|
|
35
33
|
end
|
|
36
34
|
|
|
37
35
|
def table_missing
|