permission_forge 0.14.1
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 +7 -0
- data/CHANGELOG.md +1138 -0
- data/LICENSE.txt +21 -0
- data/README.md +356 -0
- data/app/controllers/concerns/permission_forge/acting_admin.rb +17 -0
- data/app/controllers/concerns/permission_forge/api/crud_actions.rb +84 -0
- data/app/controllers/concerns/permission_forge/api/nested_resource_lookup.rb +18 -0
- data/app/controllers/concerns/permission_forge/api/pagination.rb +50 -0
- data/app/controllers/concerns/permission_forge/api/response_renderer.rb +65 -0
- data/app/controllers/concerns/permission_forge/api/serializer_resolver.rb +30 -0
- data/app/controllers/concerns/permission_forge/api_authentication.rb +40 -0
- data/app/controllers/concerns/permission_forge/assignment_authorization.rb +97 -0
- data/app/controllers/concerns/permission_forge/audit_log_gate.rb +55 -0
- data/app/controllers/concerns/permission_forge/basic_authentication.rb +77 -0
- data/app/controllers/concerns/permission_forge/cache_confirmation.rb +32 -0
- data/app/controllers/concerns/permission_forge/forgery_protection.rb +29 -0
- data/app/controllers/concerns/permission_forge/html_format.rb +40 -0
- data/app/controllers/concerns/permission_forge/param_shapes.rb +87 -0
- data/app/controllers/concerns/permission_forge/tenant_required.rb +42 -0
- data/app/controllers/permission_forge/api/audit_events_controller.rb +33 -0
- data/app/controllers/permission_forge/api/base_controller.rb +220 -0
- data/app/controllers/permission_forge/api/dashboard_controller.rb +30 -0
- data/app/controllers/permission_forge/api/permission_set_group_users_controller.rb +51 -0
- data/app/controllers/permission_forge/api/permission_set_groups_controller.rb +71 -0
- data/app/controllers/permission_forge/api/permission_set_rules_controller.rb +37 -0
- data/app/controllers/permission_forge/api/permission_set_users_controller.rb +105 -0
- data/app/controllers/permission_forge/api/permission_sets_controller.rb +87 -0
- data/app/controllers/permission_forge/api/resource_actions_controller.rb +58 -0
- data/app/controllers/permission_forge/api/resource_fields_controller.rb +30 -0
- data/app/controllers/permission_forge/api/resources_controller.rb +56 -0
- data/app/controllers/permission_forge/application_controller.rb +198 -0
- data/app/controllers/permission_forge/assets_controller.rb +97 -0
- data/app/controllers/permission_forge/assignments_controller.rb +266 -0
- data/app/controllers/permission_forge/audit_events_controller.rb +94 -0
- data/app/controllers/permission_forge/bulk_assignments_controller.rb +108 -0
- data/app/controllers/permission_forge/field_lookups_controller.rb +38 -0
- data/app/controllers/permission_forge/home_controller.rb +24 -0
- data/app/controllers/permission_forge/maintenance_controller.rb +13 -0
- data/app/controllers/permission_forge/people_controller.rb +190 -0
- data/app/controllers/permission_forge/permission_set_editor.rb +71 -0
- data/app/controllers/permission_forge/permission_set_group_users_controller.rb +57 -0
- data/app/controllers/permission_forge/permission_set_groups_controller.rb +111 -0
- data/app/controllers/permission_forge/permission_set_rules_controller.rb +97 -0
- data/app/controllers/permission_forge/permission_sets_controller.rb +227 -0
- data/app/controllers/permission_forge/preferences_controller.rb +26 -0
- data/app/controllers/permission_forge/resource_actions_controller.rb +106 -0
- data/app/controllers/permission_forge/resource_fields_controller.rb +129 -0
- data/app/controllers/permission_forge/resources_controller.rb +189 -0
- data/app/controllers/permission_forge/settings_controller.rb +33 -0
- data/app/frontend/permission_forge/brand/apple-touch-icon.png +0 -0
- data/app/frontend/permission_forge/brand/chevron.svg +1 -0
- data/app/frontend/permission_forge/brand/favicon.ico +0 -0
- data/app/frontend/permission_forge/brand/favicon.svg +1 -0
- data/app/frontend/permission_forge/brand/logo-horizontal-dark.svg +1 -0
- data/app/frontend/permission_forge/brand/logo-horizontal-light.svg +1 -0
- data/app/frontend/permission_forge/brand/mark-small.svg +1 -0
- data/app/frontend/permission_forge/brand/mark.svg +1 -0
- data/app/frontend/permission_forge/pf_admin.css +766 -0
- data/app/frontend/permission_forge/pf_admin.js +482 -0
- data/app/helpers/permission_forge/application_helper.rb +167 -0
- data/app/helpers/permission_forge/audit_helper.rb +230 -0
- data/app/helpers/permission_forge/audit_sentences_helper.rb +84 -0
- data/app/helpers/permission_forge/badges_helper.rb +134 -0
- data/app/helpers/permission_forge/changes_helper.rb +117 -0
- data/app/helpers/permission_forge/people_helper.rb +118 -0
- data/app/helpers/permission_forge/sentences_helper.rb +175 -0
- data/app/helpers/permission_forge/settings_helper.rb +152 -0
- data/app/helpers/permission_forge/ui_helper.rb +184 -0
- data/app/models/permission_forge/audit_event.rb +159 -0
- data/app/models/permission_forge/permission_set.rb +100 -0
- data/app/models/permission_forge/permission_set_group.rb +83 -0
- data/app/models/permission_forge/permission_set_group_user.rb +43 -0
- data/app/models/permission_forge/permission_set_rule.rb +99 -0
- data/app/models/permission_forge/permission_set_user.rb +158 -0
- data/app/models/permission_forge/resource.rb +100 -0
- data/app/models/permission_forge/resource_action.rb +58 -0
- data/app/models/permission_forge/resource_field.rb +69 -0
- data/app/views/layouts/permission_forge/application.html.erb +30 -0
- data/app/views/layouts/permission_forge/fragment.html.erb +2 -0
- data/app/views/permission_forge/assignments/_person_options.html.erb +26 -0
- data/app/views/permission_forge/assignments/confirm_destroy.html.erb +24 -0
- data/app/views/permission_forge/assignments/new.html.erb +99 -0
- data/app/views/permission_forge/audit_events/index.html.erb +161 -0
- data/app/views/permission_forge/audit_events/show.html.erb +146 -0
- data/app/views/permission_forge/bulk_assignments/confirm_revoke.html.erb +21 -0
- data/app/views/permission_forge/bulk_assignments/new.html.erb +69 -0
- data/app/views/permission_forge/field_lookups/show.html.erb +32 -0
- data/app/views/permission_forge/home/_audit_card.html.erb +19 -0
- data/app/views/permission_forge/home/_ending_card.html.erb +32 -0
- data/app/views/permission_forge/home/_expired_card.html.erb +34 -0
- data/app/views/permission_forge/home/_privacy_card.html.erb +67 -0
- data/app/views/permission_forge/home/_rules_card.html.erb +23 -0
- data/app/views/permission_forge/home/_stale_card.html.erb +39 -0
- data/app/views/permission_forge/home/_tidy_card.html.erb +18 -0
- data/app/views/permission_forge/home/index.html.erb +69 -0
- data/app/views/permission_forge/people/_access_tab.html.erb +58 -0
- data/app/views/permission_forge/people/_audit_tab.html.erb +93 -0
- data/app/views/permission_forge/people/_effective_field_source.html.erb +11 -0
- data/app/views/permission_forge/people/_effective_tab.html.erb +118 -0
- data/app/views/permission_forge/people/_effective_why.html.erb +48 -0
- data/app/views/permission_forge/people/_why_item.html.erb +19 -0
- data/app/views/permission_forge/people/confirm_remove_all.html.erb +27 -0
- data/app/views/permission_forge/people/consent_preview.html.erb +39 -0
- data/app/views/permission_forge/people/erasure_preview.html.erb +28 -0
- data/app/views/permission_forge/people/index.html.erb +78 -0
- data/app/views/permission_forge/people/show.html.erb +54 -0
- data/app/views/permission_forge/permission_set_groups/confirm_destroy.html.erb +12 -0
- data/app/views/permission_forge/permission_set_groups/index.html.erb +55 -0
- data/app/views/permission_forge/permission_set_groups/show.html.erb +100 -0
- data/app/views/permission_forge/permission_set_rules/_builder.html.erb +145 -0
- data/app/views/permission_forge/permission_set_rules/_value_input.html.erb +42 -0
- data/app/views/permission_forge/permission_set_rules/confirm_destroy.html.erb +13 -0
- data/app/views/permission_forge/permission_set_rules/edit.html.erb +3 -0
- data/app/views/permission_forge/permission_set_rules/new.html.erb +3 -0
- data/app/views/permission_forge/permission_sets/_assignments_tab.html.erb +74 -0
- data/app/views/permission_forge/permission_sets/_audit_tab.html.erb +80 -0
- data/app/views/permission_forge/permission_sets/_conditions_tab.html.erb +83 -0
- data/app/views/permission_forge/permission_sets/_header.html.erb +36 -0
- data/app/views/permission_forge/permission_sets/_records_summary.html.erb +16 -0
- data/app/views/permission_forge/permission_sets/_resource_summary.html.erb +11 -0
- data/app/views/permission_forge/permission_sets/_resources_tab.html.erb +49 -0
- data/app/views/permission_forge/permission_sets/confirm_destroy.html.erb +23 -0
- data/app/views/permission_forge/permission_sets/confirm_switch_mode.html.erb +15 -0
- data/app/views/permission_forge/permission_sets/index.html.erb +83 -0
- data/app/views/permission_forge/permission_sets/new.html.erb +63 -0
- data/app/views/permission_forge/permission_sets/review.html.erb +26 -0
- data/app/views/permission_forge/permission_sets/show.html.erb +50 -0
- data/app/views/permission_forge/resource_actions/_across_sets.html.erb +24 -0
- data/app/views/permission_forge/resource_actions/_add_select.html.erb +12 -0
- data/app/views/permission_forge/resource_actions/_effect.html.erb +25 -0
- data/app/views/permission_forge/resource_actions/_endpoints.html.erb +16 -0
- data/app/views/permission_forge/resource_actions/_form.html.erb +44 -0
- data/app/views/permission_forge/resource_actions/_managed_note.html.erb +11 -0
- data/app/views/permission_forge/resource_actions/confirm_destroy.html.erb +13 -0
- data/app/views/permission_forge/resource_actions/new.html.erb +31 -0
- data/app/views/permission_forge/resource_actions/show.html.erb +39 -0
- data/app/views/permission_forge/resource_fields/_explain.html.erb +24 -0
- data/app/views/permission_forge/resource_fields/show.html.erb +186 -0
- data/app/views/permission_forge/resources/_actions_tab.html.erb +88 -0
- data/app/views/permission_forge/resources/_fields_tab.html.erb +89 -0
- data/app/views/permission_forge/resources/_rules_tab.html.erb +55 -0
- data/app/views/permission_forge/resources/_sync.html.erb +57 -0
- data/app/views/permission_forge/resources/confirm_destroy.html.erb +17 -0
- data/app/views/permission_forge/resources/new.html.erb +57 -0
- data/app/views/permission_forge/resources/show.html.erb +117 -0
- data/app/views/permission_forge/settings/_ladder.html.erb +15 -0
- data/app/views/permission_forge/settings/_row.html.erb +38 -0
- data/app/views/permission_forge/settings/show.html.erb +67 -0
- data/app/views/permission_forge/shared/_across_sets.html.erb +31 -0
- data/app/views/permission_forge/shared/_audit_events.html.erb +36 -0
- data/app/views/permission_forge/shared/_audit_footer.html.erb +16 -0
- data/app/views/permission_forge/shared/_audit_off_note.html.erb +15 -0
- data/app/views/permission_forge/shared/_audit_pagination.html.erb +19 -0
- data/app/views/permission_forge/shared/_audit_refused_note.html.erb +7 -0
- data/app/views/permission_forge/shared/_audit_timeline.html.erb +29 -0
- data/app/views/permission_forge/shared/_crud_cell.html.erb +18 -0
- data/app/views/permission_forge/shared/_dialog_frame.html.erb +11 -0
- data/app/views/permission_forge/shared/_expiry_picker.html.erb +27 -0
- data/app/views/permission_forge/shared/_head_extra.html.erb +3 -0
- data/app/views/permission_forge/shared/_help_banner.html.erb +10 -0
- data/app/views/permission_forge/shared/_level_control.html.erb +18 -0
- data/app/views/permission_forge/shared/_mode_banners.html.erb +22 -0
- data/app/views/permission_forge/shared/_nav.html.erb +12 -0
- data/app/views/permission_forge/shared/_preferences.html.erb +24 -0
- data/app/views/permission_forge/shared/_reason_field.html.erb +20 -0
- data/app/views/permission_forge/shared/_savebar.html.erb +13 -0
- data/app/views/permission_forge/shared/_sheet_frame.html.erb +22 -0
- data/app/views/permission_forge/shared/_sidebar.html.erb +18 -0
- data/app/views/permission_forge/shared/_tech_details.html.erb +20 -0
- data/app/views/permission_forge/shared/_toasts.html.erb +27 -0
- data/app/views/permission_forge/shared/_topbar.html.erb +15 -0
- data/app/views/permission_forge/shared/_user_select.html.erb +16 -0
- data/app/views/permission_forge/shared/audit_refused.html.erb +7 -0
- data/config/locales/permission_forge.en.yml +2115 -0
- data/config/locales/permission_forge.mk.yml +2166 -0
- data/config/locales/permission_forge.sq.yml +2164 -0
- data/config/routes.rb +143 -0
- data/lib/generators/permission_forge/install/install_generator.rb +105 -0
- data/lib/generators/permission_forge/install/templates/initializer.rb.tt +148 -0
- data/lib/generators/permission_forge/install/templates/migration.rb.tt +198 -0
- data/lib/generators/permission_forge/rls/rls_generator.rb +57 -0
- data/lib/generators/permission_forge/rls/templates/enable_permission_forge_rls.rb.tt +15 -0
- data/lib/generators/permission_forge/upgrade/templates/add_admin_ui_columns.rb.tt +8 -0
- data/lib/generators/permission_forge/upgrade/templates/add_audit_events.rb.tt +98 -0
- data/lib/generators/permission_forge/upgrade/templates/add_resource_fields_unique_index.rb.tt +69 -0
- data/lib/generators/permission_forge/upgrade/upgrade_generator.rb +66 -0
- data/lib/permission_forge/admin/action_across_sets.rb +76 -0
- data/lib/permission_forge/admin/action_rows.rb +156 -0
- data/lib/permission_forge/admin/attention_items.rb +207 -0
- data/lib/permission_forge/admin/audit_event_detail.rb +75 -0
- data/lib/permission_forge/admin/audit_log.rb +193 -0
- data/lib/permission_forge/admin/audit_log_days.rb +41 -0
- data/lib/permission_forge/admin/audit_lookups.rb +105 -0
- data/lib/permission_forge/admin/audit_name_history.rb +85 -0
- data/lib/permission_forge/admin/audit_trail.rb +169 -0
- data/lib/permission_forge/admin/bulk_people_matcher.rb +109 -0
- data/lib/permission_forge/admin/change_set.rb +123 -0
- data/lib/permission_forge/admin/change_set_parts/actions.rb +27 -0
- data/lib/permission_forge/admin/change_set_parts/base.rb +58 -0
- data/lib/permission_forge/admin/change_set_parts/context.rb +50 -0
- data/lib/permission_forge/admin/change_set_parts/field_edit.rb +67 -0
- data/lib/permission_forge/admin/change_set_parts/field_levels.rb +71 -0
- data/lib/permission_forge/admin/change_set_parts/new_actions.rb +91 -0
- data/lib/permission_forge/admin/change_set_parts/new_fields.rb +63 -0
- data/lib/permission_forge/admin/change_set_parts/resource_edit.rb +55 -0
- data/lib/permission_forge/admin/change_set_parts/resource_flags.rb +19 -0
- data/lib/permission_forge/admin/change_set_parts/set_attrs.rb +45 -0
- data/lib/permission_forge/admin/change_set_parts/values.rb +20 -0
- data/lib/permission_forge/admin/column_fields.rb +48 -0
- data/lib/permission_forge/admin/conditions_form.rb +78 -0
- data/lib/permission_forge/admin/controller_catalog.rb +166 -0
- data/lib/permission_forge/admin/csv.rb +41 -0
- data/lib/permission_forge/admin/editable_lock.rb +157 -0
- data/lib/permission_forge/admin/effective_access.rb +158 -0
- data/lib/permission_forge/admin/effective_fields.rb +176 -0
- data/lib/permission_forge/admin/expiry_presets.rb +51 -0
- data/lib/permission_forge/admin/exports/document.rb +89 -0
- data/lib/permission_forge/admin/exports/events.rb +51 -0
- data/lib/permission_forge/admin/exports/holdings.rb +118 -0
- data/lib/permission_forge/admin/exports.rb +37 -0
- data/lib/permission_forge/admin/field_across_sets.rb +51 -0
- data/lib/permission_forge/admin/field_detail.rb +112 -0
- data/lib/permission_forge/admin/field_level.rb +74 -0
- data/lib/permission_forge/admin/field_rows.rb +137 -0
- data/lib/permission_forge/admin/last_changes.rb +94 -0
- data/lib/permission_forge/admin/model_catalog.rb +216 -0
- data/lib/permission_forge/admin/people_candidates.rb +108 -0
- data/lib/permission_forge/admin/people_directory.rb +158 -0
- data/lib/permission_forge/admin/permission_set_factory.rb +48 -0
- data/lib/permission_forge/admin/person_label.rb +95 -0
- data/lib/permission_forge/admin/record_id.rb +19 -0
- data/lib/permission_forge/admin/rule_builder.rb +58 -0
- data/lib/permission_forge/admin/rule_form.rb +175 -0
- data/lib/permission_forge/admin/rule_form_input.rb +74 -0
- data/lib/permission_forge/admin/sensitive_columns.rb +68 -0
- data/lib/permission_forge/admin/settings_catalog.rb +261 -0
- data/lib/permission_forge/admin/settings_values.rb +130 -0
- data/lib/permission_forge/api/serializers/audit_event_serializer.rb +27 -0
- data/lib/permission_forge/api/serializers/base_serializer.rb +36 -0
- data/lib/permission_forge/api/serializers/permission_set_group_serializer.rb +12 -0
- data/lib/permission_forge/api/serializers/permission_set_group_user_serializer.rb +11 -0
- data/lib/permission_forge/api/serializers/permission_set_rule_serializer.rb +25 -0
- data/lib/permission_forge/api/serializers/permission_set_serializer.rb +13 -0
- data/lib/permission_forge/api/serializers/permission_set_user_serializer.rb +11 -0
- data/lib/permission_forge/api/serializers/resource_action_serializer.rb +13 -0
- data/lib/permission_forge/api/serializers/resource_field_serializer.rb +14 -0
- data/lib/permission_forge/api/serializers/resource_serializer.rb +15 -0
- data/lib/permission_forge/audit/actor.rb +144 -0
- data/lib/permission_forge/audit/actor_resolver.rb +103 -0
- data/lib/permission_forge/audit/audited_views.rb +208 -0
- data/lib/permission_forge/audit/boot_warnings.rb +58 -0
- data/lib/permission_forge/audit/column_values.rb +56 -0
- data/lib/permission_forge/audit/context.rb +98 -0
- data/lib/permission_forge/audit/detached.rb +62 -0
- data/lib/permission_forge/audit/event_catalog.rb +110 -0
- data/lib/permission_forge/audit/event_query.rb +192 -0
- data/lib/permission_forge/audit/failure.rb +47 -0
- data/lib/permission_forge/audit/field_access.rb +186 -0
- data/lib/permission_forge/audit/gate.rb +167 -0
- data/lib/permission_forge/audit/job_context.rb +32 -0
- data/lib/permission_forge/audit/log_access.rb +36 -0
- data/lib/permission_forge/audit/recorder.rb +168 -0
- data/lib/permission_forge/audit/refusals.rb +61 -0
- data/lib/permission_forge/audit/request_context.rb +152 -0
- data/lib/permission_forge/audit/runtime.rb +178 -0
- data/lib/permission_forge/audit/runtime_emission.rb +135 -0
- data/lib/permission_forge/audit/settings_observer.rb +70 -0
- data/lib/permission_forge/audit/source.rb +54 -0
- data/lib/permission_forge/audit/subscribers/base.rb +69 -0
- data/lib/permission_forge/audit/subscribers/legacy_hooks.rb +97 -0
- data/lib/permission_forge/audit/subscribers/log.rb +45 -0
- data/lib/permission_forge/audit/subscribers.rb +58 -0
- data/lib/permission_forge/audit.rb +302 -0
- data/lib/permission_forge/boot_checks.rb +184 -0
- data/lib/permission_forge/cache/backlog.rb +142 -0
- data/lib/permission_forge/cache/base.rb +200 -0
- data/lib/permission_forge/cache/bulk_invalidation.rb +131 -0
- data/lib/permission_forge/cache/failure.rb +171 -0
- data/lib/permission_forge/cache/handled_errors.rb +82 -0
- data/lib/permission_forge/cache/holder_invalidation.rb +85 -0
- data/lib/permission_forge/cache/invalidation.rb +180 -0
- data/lib/permission_forge/cache/memory_cache.rb +77 -0
- data/lib/permission_forge/cache/null_cache.rb +40 -0
- data/lib/permission_forge/cache/rails_cache.rb +231 -0
- data/lib/permission_forge/cache/rebuild.rb +149 -0
- data/lib/permission_forge/cache/settlement.rb +63 -0
- data/lib/permission_forge/cache/user_keys.rb +206 -0
- data/lib/permission_forge/cache/variant_removal.rb +55 -0
- data/lib/permission_forge/cache/withheld_notice.rb +60 -0
- data/lib/permission_forge/concerns/audited_collection.rb +34 -0
- data/lib/permission_forge/concerns/auto_permission_scoping.rb +158 -0
- data/lib/permission_forge/concerns/controller_action_guard.rb +106 -0
- data/lib/permission_forge/concerns/field_mask_checker.rb +45 -0
- data/lib/permission_forge/concerns/has_active_scope.rb +12 -0
- data/lib/permission_forge/concerns/has_audit_trail.rb +59 -0
- data/lib/permission_forge/concerns/has_permission_set_users.rb +115 -0
- data/lib/permission_forge/concerns/has_tenant_scoping.rb +86 -0
- data/lib/permission_forge/concerns/has_uuid.rb +24 -0
- data/lib/permission_forge/concerns/invalidates_in_batch.rb +207 -0
- data/lib/permission_forge/concerns/invalidates_managed_actions.rb +80 -0
- data/lib/permission_forge/concerns/masked_attributes/alias_access.rb +280 -0
- data/lib/permission_forge/concerns/masked_attributes/store_access.rb +100 -0
- data/lib/permission_forge/concerns/masked_attributes/validation_reads.rb +153 -0
- data/lib/permission_forge/concerns/masked_attributes/virtual_readers.rb +242 -0
- data/lib/permission_forge/concerns/masked_attributes.rb +576 -0
- data/lib/permission_forge/concerns/permission_cache_accessor.rb +416 -0
- data/lib/permission_forge/concerns/permission_scoped_findable.rb +337 -0
- data/lib/permission_forge/concerns/permissioned_user.rb +78 -0
- data/lib/permission_forge/concerns/permissions_memo.rb +160 -0
- data/lib/permission_forge/concerns/permissions_per_context.rb +288 -0
- data/lib/permission_forge/concerns/permissions_regenerator.rb +43 -0
- data/lib/permission_forge/concerns/records_audit_events.rb +286 -0
- data/lib/permission_forge/concerns/resource_permission_checker.rb +116 -0
- data/lib/permission_forge/concerns/validates_column_lengths.rb +69 -0
- data/lib/permission_forge/concerns/validates_dependency.rb +54 -0
- data/lib/permission_forge/conditional_sets.rb +361 -0
- data/lib/permission_forge/configuration/admin_ui_settings.rb +108 -0
- data/lib/permission_forge/configuration/audit_settings.rb +184 -0
- data/lib/permission_forge/configuration/cache_settings.rb +76 -0
- data/lib/permission_forge/configuration/controller_action_settings.rb +112 -0
- data/lib/permission_forge/configuration/gdpr_settings.rb +70 -0
- data/lib/permission_forge/configuration/masked_attributes_settings.rb +83 -0
- data/lib/permission_forge/configuration/permits_attributes_settings.rb +26 -0
- data/lib/permission_forge/configuration/presets.rb +139 -0
- data/lib/permission_forge/configuration/query_scoping_settings.rb +107 -0
- data/lib/permission_forge/configuration/strategy_values.rb +57 -0
- data/lib/permission_forge/configuration/virtual_attributes_settings.rb +65 -0
- data/lib/permission_forge/configuration.rb +624 -0
- data/lib/permission_forge/controller_actions/authorizer.rb +171 -0
- data/lib/permission_forge/controller_actions/managed_index.rb +213 -0
- data/lib/permission_forge/controller_actions/mapping.rb +167 -0
- data/lib/permission_forge/controller_actions.rb +122 -0
- data/lib/permission_forge/deprecation.rb +86 -0
- data/lib/permission_forge/engine.rb +113 -0
- data/lib/permission_forge/execution_state.rb +318 -0
- data/lib/permission_forge/invalidation_batch.rb +206 -0
- data/lib/permission_forge/masked_models.rb +226 -0
- data/lib/permission_forge/masking_sync.rb +271 -0
- data/lib/permission_forge/model_bindings.rb +90 -0
- data/lib/permission_forge/open_transaction.rb +303 -0
- data/lib/permission_forge/operators/constants.rb +41 -0
- data/lib/permission_forge/operators/registry.rb +300 -0
- data/lib/permission_forge/operators/rule_column.rb +120 -0
- data/lib/permission_forge/operators/validations.rb +111 -0
- data/lib/permission_forge/operators/value_casters/number.rb +68 -0
- data/lib/permission_forge/operators/value_casters/temporal.rb +69 -0
- data/lib/permission_forge/operators/value_casters.rb +80 -0
- data/lib/permission_forge/operators/value_typing.rb +115 -0
- data/lib/permission_forge/query_scoping_warnings.rb +68 -0
- data/lib/permission_forge/rake_audit.rb +65 -0
- data/lib/permission_forge/resource_chain.rb +190 -0
- data/lib/permission_forge/rls.rb +209 -0
- data/lib/permission_forge/services/attribute_access_guard.rb +138 -0
- data/lib/permission_forge/services/audit_event_purger.rb +150 -0
- data/lib/permission_forge/services/bulk_permission_set_assignment.rb +108 -0
- data/lib/permission_forge/services/concerns/across_tenants.rb +69 -0
- data/lib/permission_forge/services/concerns/audited_operation.rb +43 -0
- data/lib/permission_forge/services/concerns/clonable.rb +29 -0
- data/lib/permission_forge/services/consent_withdrawal_handler.rb +135 -0
- data/lib/permission_forge/services/dashboard_stats.rb +43 -0
- data/lib/permission_forge/services/data_subject_erasure.rb +306 -0
- data/lib/permission_forge/services/expired_assignment_cleaner.rb +152 -0
- data/lib/permission_forge/services/extra_field_defaults.rb +71 -0
- data/lib/permission_forge/services/field_permission_merger.rb +83 -0
- data/lib/permission_forge/services/filters_response_attributes.rb +144 -0
- data/lib/permission_forge/services/gdpr_field_auditor/text_report.rb +63 -0
- data/lib/permission_forge/services/gdpr_field_auditor.rb +223 -0
- data/lib/permission_forge/services/host_key_guard.rb +110 -0
- data/lib/permission_forge/services/mask_value.rb +93 -0
- data/lib/permission_forge/services/permission_cache_clearer.rb +174 -0
- data/lib/permission_forge/services/permission_hash_builder.rb +305 -0
- data/lib/permission_forge/services/permission_rule_applier.rb +192 -0
- data/lib/permission_forge/services/permission_set_cloner.rb +89 -0
- data/lib/permission_forge/services/permission_set_condition_evaluator.rb +102 -0
- data/lib/permission_forge/services/permission_set_group_cloner.rb +52 -0
- data/lib/permission_forge/services/permits_attributes/nested_records.rb +106 -0
- data/lib/permission_forge/services/permits_attributes.rb +270 -0
- data/lib/permission_forge/services/resource_field_generator.rb +93 -0
- data/lib/permission_forge/services/resource_with_fields_builder.rb +47 -0
- data/lib/permission_forge/services/rls_operator_sql.rb +131 -0
- data/lib/permission_forge/services/rls_policy_generator.rb +365 -0
- data/lib/permission_forge/services/security_audit/action_check.rb +157 -0
- data/lib/permission_forge/services/security_audit/assignment_check.rb +120 -0
- data/lib/permission_forge/services/security_audit/audit_trail_check.rb +166 -0
- data/lib/permission_forge/services/security_audit/base_check.rb +57 -0
- data/lib/permission_forge/services/security_audit/config_check.rb +212 -0
- data/lib/permission_forge/services/security_audit/integrity_check.rb +105 -0
- data/lib/permission_forge/services/security_audit/personal_data_check.rb +79 -0
- data/lib/permission_forge/services/security_audit/privilege_check.rb +67 -0
- data/lib/permission_forge/services/security_audit/rule_check.rb +141 -0
- data/lib/permission_forge/services/security_audit/text_report.rb +46 -0
- data/lib/permission_forge/services/security_audit/user_model_check.rb +67 -0
- data/lib/permission_forge/services/security_audit.rb +75 -0
- data/lib/permission_forge/services/stale_assignment_auditor.rb +68 -0
- data/lib/permission_forge/services/unscoped_query_detector.rb +132 -0
- data/lib/permission_forge/version.rb +3 -0
- data/lib/permission_forge/virtual_fields/discovery.rb +65 -0
- data/lib/permission_forge/virtual_fields.rb +102 -0
- data/lib/permission_forge.rb +464 -0
- data/lib/tasks/permission_forge.rake +31 -0
- data/lib/tasks/permission_forge_audit_events.rake +28 -0
- data/lib/tasks/permission_forge_cache.rake +26 -0
- data/lib/tasks/permission_forge_expiration.rake +30 -0
- data/lib/tasks/permission_forge_gdpr.rake +69 -0
- data/lib/tasks/permission_forge_rls.rake +38 -0
- metadata +515 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# @api private
|
|
5
|
+
module Operators
|
|
6
|
+
# Strategy Pattern registry mapping operator names to query-building callables.
|
|
7
|
+
#
|
|
8
|
+
# Each operator receives (query, sanitized_column, rule, value) and returns
|
|
9
|
+
# an ActiveRecord::Relation. The value arrives typed for the column it is
|
|
10
|
+
# compared with (see {ValueTyping}): one value for the comparisons and the
|
|
11
|
+
# text of a LIKE pattern, an Array for `in` / `not_in` (the list) and
|
|
12
|
+
# `between` (the two bounds). To add a new operator, register it in
|
|
13
|
+
# {.strategies} and define the corresponding class method.
|
|
14
|
+
#
|
|
15
|
+
# @example Fetching and calling an operator
|
|
16
|
+
# strategy = PermissionForge::Operators::Registry.fetch('equal')
|
|
17
|
+
# strategy.call(query, '"customers"."status"', rule, 'active')
|
|
18
|
+
#
|
|
19
|
+
# @api public
|
|
20
|
+
module Registry
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# @param name [String] operator constant from PermissionSetRule (e.g., 'equal')
|
|
24
|
+
# @return [#call] callable strategy
|
|
25
|
+
# @raise [ArgumentError] if operator is unknown
|
|
26
|
+
def fetch(name)
|
|
27
|
+
strategies.fetch(name) { raise ArgumentError, "Unknown operator: #{name}" }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Hash<String, #call>] frozen operator lookup table
|
|
31
|
+
def strategies
|
|
32
|
+
@strategies ||= build_strategies.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Resets the memoized strategies hash. Useful in tests after defining custom operators.
|
|
36
|
+
def reset!
|
|
37
|
+
@strategies = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Wraps a pre-quoted column reference in Arel.sql so that downstream
|
|
41
|
+
# .where calls treat it as a trusted SQL literal. The column has already
|
|
42
|
+
# been quoted with the connection's real identifier quoting
|
|
43
|
+
# (quote_table_name / quote_column_name) in
|
|
44
|
+
# PermissionForge::Services::PermissionRuleApplier before reaching the
|
|
45
|
+
# Registry — values are still bound as parameters, never interpolated.
|
|
46
|
+
#
|
|
47
|
+
# @param col [String] already-quoted column (e.g. '"table"."column"')
|
|
48
|
+
# @return [Arel::Nodes::SqlLiteral]
|
|
49
|
+
def arel_col(col)
|
|
50
|
+
Arel.sql(col)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Wraps a bare Equality predicate in Grouping. Required for default_scope
|
|
54
|
+
# compatibility: Model.new/create runs scope_for_create, whose
|
|
55
|
+
# WhereClause#to_h(equality_only: true) dereferences `.relation` on
|
|
56
|
+
# top-level Equality nodes — our left operand is a SqlLiteral (no
|
|
57
|
+
# `.relation`), which would raise NoMethodError on every create for a
|
|
58
|
+
# scoped user. `to_h` skips Grouping nodes, so wrapping makes the
|
|
59
|
+
# predicate invisible to create-attribute inference while leaving the
|
|
60
|
+
# read-path SQL semantically identical: (("t"."col" = ?)).
|
|
61
|
+
#
|
|
62
|
+
# @param predicate [Arel::Nodes::Equality]
|
|
63
|
+
# @return [Arel::Nodes::Grouping]
|
|
64
|
+
def grouped(predicate)
|
|
65
|
+
Arel::Nodes::Grouping.new(predicate)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# --- Operator implementations ---
|
|
69
|
+
|
|
70
|
+
def op_equal(query, col, rule, val)
|
|
71
|
+
column = arel_col(col)
|
|
72
|
+
return query.where(column.eq(val).or(column.eq(nil))) if rule[:include_nulls]
|
|
73
|
+
|
|
74
|
+
query.where(grouped(column.eq(val)))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The `not_equal` operator strategy: `(query, col, rule, val)` returning a relation.
|
|
78
|
+
# @return [ActiveRecord::Relation]
|
|
79
|
+
# @api private
|
|
80
|
+
def op_not_equal(query, col, rule, val)
|
|
81
|
+
column = arel_col(col)
|
|
82
|
+
base = query.where(column.not_eq(val))
|
|
83
|
+
return base.or(query.where(column.eq(nil))) if rule[:include_nulls]
|
|
84
|
+
|
|
85
|
+
base
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The `in` operator strategy: `(query, col, rule, val)` returning a relation.
|
|
89
|
+
# @return [ActiveRecord::Relation]
|
|
90
|
+
# @api private
|
|
91
|
+
def op_in(query, col, rule, val)
|
|
92
|
+
column = arel_col(col)
|
|
93
|
+
values = list_values(rule, val)
|
|
94
|
+
return query.where(column.in(values).or(column.eq(nil))) if rule[:include_nulls]
|
|
95
|
+
|
|
96
|
+
query.where(column.in(values))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The `not_in` operator strategy: `(query, col, rule, val)` returning a relation.
|
|
100
|
+
# @return [ActiveRecord::Relation]
|
|
101
|
+
# @api private
|
|
102
|
+
def op_not_in(query, col, rule, val)
|
|
103
|
+
column = arel_col(col)
|
|
104
|
+
values = list_values(rule, val)
|
|
105
|
+
return query.where(column.not_in(values).or(column.eq(nil))) if rule[:include_nulls]
|
|
106
|
+
|
|
107
|
+
query.where(column.not_in(values))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The `contains` operator strategy: `(query, col, rule, val)` returning a relation.
|
|
111
|
+
# @return [ActiveRecord::Relation]
|
|
112
|
+
# @api private
|
|
113
|
+
def op_contains(query, col, _rule, val)
|
|
114
|
+
column = arel_col(col)
|
|
115
|
+
query.where(column.matches("%#{sanitize_like(val)}%", LIKE_ESCAPE))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The `not_contains` operator strategy: `(query, col, rule, val)` returning a relation.
|
|
119
|
+
# @return [ActiveRecord::Relation]
|
|
120
|
+
# @api private
|
|
121
|
+
def op_not_contains(query, col, _rule, val)
|
|
122
|
+
column = arel_col(col)
|
|
123
|
+
query.where(column.does_not_match("%#{sanitize_like(val)}%", LIKE_ESCAPE))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The `starts_with` operator strategy: `(query, col, rule, val)` returning a relation.
|
|
127
|
+
# @return [ActiveRecord::Relation]
|
|
128
|
+
# @api private
|
|
129
|
+
def op_starts_with(query, col, _rule, val)
|
|
130
|
+
column = arel_col(col)
|
|
131
|
+
query.where(column.matches("#{sanitize_like(val)}%", LIKE_ESCAPE))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The `ends_with` operator strategy: `(query, col, rule, val)` returning a relation.
|
|
135
|
+
# @return [ActiveRecord::Relation]
|
|
136
|
+
# @api private
|
|
137
|
+
def op_ends_with(query, col, _rule, val)
|
|
138
|
+
column = arel_col(col)
|
|
139
|
+
query.where(column.matches("%#{sanitize_like(val)}", LIKE_ESCAPE))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The node is built here, not from a Ruby Range: the bounds are already
|
|
143
|
+
# typed, and not every pair of them makes a Range (two booleans do not).
|
|
144
|
+
def op_between(query, col, rule, val)
|
|
145
|
+
bounds = range_values(rule, val).map { |bound| Arel::Nodes.build_quoted(bound) }
|
|
146
|
+
query.where(Arel::Nodes::Between.new(arel_col(col), Arel::Nodes::And.new(bounds)))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The `less_than` operator strategy: `(query, col, rule, val)` returning a relation.
|
|
150
|
+
# @return [ActiveRecord::Relation]
|
|
151
|
+
# @api private
|
|
152
|
+
def op_less_than(query, col, _rule, val)
|
|
153
|
+
query.where(arel_col(col).lt(val))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The `greater_than` operator strategy: `(query, col, rule, val)` returning a relation.
|
|
157
|
+
# @return [ActiveRecord::Relation]
|
|
158
|
+
# @api private
|
|
159
|
+
def op_greater_than(query, col, _rule, val)
|
|
160
|
+
query.where(arel_col(col).gt(val))
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The `less_than_or_equal` operator strategy: `(query, col, rule, val)` returning a relation.
|
|
164
|
+
# @return [ActiveRecord::Relation]
|
|
165
|
+
# @api private
|
|
166
|
+
def op_less_than_or_equal(query, col, _rule, val)
|
|
167
|
+
query.where(arel_col(col).lteq(val))
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The `greater_than_or_equal` operator strategy: `(query, col, rule, val)` returning a relation.
|
|
171
|
+
# @return [ActiveRecord::Relation]
|
|
172
|
+
# @api private
|
|
173
|
+
def op_greater_than_or_equal(query, col, _rule, val)
|
|
174
|
+
query.where(arel_col(col).gteq(val))
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The `is_null` operator strategy: `(query, col, rule, val)` returning a relation.
|
|
178
|
+
# @return [ActiveRecord::Relation]
|
|
179
|
+
# @api private
|
|
180
|
+
def op_is_null(query, col, _rule, _val)
|
|
181
|
+
query.where(grouped(arel_col(col).eq(nil)))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The `is_not_null` operator strategy: `(query, col, rule, val)` returning a relation.
|
|
185
|
+
# @return [ActiveRecord::Relation]
|
|
186
|
+
# @api private
|
|
187
|
+
def op_is_not_null(query, col, _rule, _val)
|
|
188
|
+
query.where(arel_col(col).not_eq(nil))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# --- Helpers ---
|
|
192
|
+
|
|
193
|
+
def parse_list_value(rule)
|
|
194
|
+
rule[:field_value].to_s.split(',').map(&:strip)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The list as PermissionRuleApplier typed it for the column. A strategy
|
|
198
|
+
# called directly with anything else reads the rule's own text, as it
|
|
199
|
+
# did before values were typed.
|
|
200
|
+
def list_values(rule, val)
|
|
201
|
+
val.is_a?(Array) ? val : parse_list_value(rule)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The two bounds, likewise.
|
|
205
|
+
def range_values(rule, val)
|
|
206
|
+
return val if val.is_a?(Array)
|
|
207
|
+
|
|
208
|
+
lower, upper = parse_list_value(rule)
|
|
209
|
+
coerce_between_values(lower, upper)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# `%`, `_` and the escape character itself match literally. The pattern
|
|
213
|
+
# only means that with an ESCAPE clause: SQLite has no default escape
|
|
214
|
+
# character, and neither has MySQL under NO_BACKSLASH_ESCAPES. The
|
|
215
|
+
# adapter quotes the clause, which is what keeps it one backslash there
|
|
216
|
+
# (trilogy does; the mysql2 driver cannot quote any text in that mode).
|
|
217
|
+
LIKE_ESCAPE = '\\'
|
|
218
|
+
|
|
219
|
+
# Escapes `%`, `_` and the escape character so a value is matched literally by LIKE.
|
|
220
|
+
# @return [String]
|
|
221
|
+
# @api private
|
|
222
|
+
def sanitize_like(value)
|
|
223
|
+
value.to_s.gsub(/[%_\\]/) { |char| "#{LIKE_ESCAPE}#{char}" }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
INTEGER_PATTERN = /\A-?\d+\z/
|
|
227
|
+
FLOAT_PATTERN = /\A-?\d+\.\d+\z/
|
|
228
|
+
DATE_PATTERN = /\A\d{4}-\d{2}-\d{2}\z/
|
|
229
|
+
|
|
230
|
+
COERCION_RULES = [
|
|
231
|
+
[INTEGER_PATTERN, lambda(&:to_i)],
|
|
232
|
+
[FLOAT_PATTERN, lambda(&:to_f)],
|
|
233
|
+
[DATE_PATTERN, ->(v) { Date.parse(v) }]
|
|
234
|
+
].freeze
|
|
235
|
+
|
|
236
|
+
# Types the two bounds of a `between` rule the same way (dates, times, numbers).
|
|
237
|
+
# @return [Array]
|
|
238
|
+
# @api private
|
|
239
|
+
def coerce_between_values(lower, upper)
|
|
240
|
+
COERCION_RULES.each do |pattern, coercer|
|
|
241
|
+
next unless lower.match?(pattern) && upper.match?(pattern)
|
|
242
|
+
|
|
243
|
+
return [coercer.call(lower), coercer.call(upper)]
|
|
244
|
+
end
|
|
245
|
+
[lower, upper]
|
|
246
|
+
rescue Date::Error
|
|
247
|
+
[lower, upper]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Builds the operator-to-strategy mapping by merging categorized sub-groups.
|
|
251
|
+
def build_strategies
|
|
252
|
+
comparison_operators.merge(set_operators).merge(string_operators).merge(range_and_null_operators)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# @return [Hash{String => Method}] the equality and ordering operators
|
|
256
|
+
# @api private
|
|
257
|
+
def comparison_operators
|
|
258
|
+
psr = PermissionForge::PermissionSetRule
|
|
259
|
+
{
|
|
260
|
+
psr::EQUAL => method(:op_equal),
|
|
261
|
+
psr::NOT_EQUAL => method(:op_not_equal),
|
|
262
|
+
psr::LESS_THAN => method(:op_less_than),
|
|
263
|
+
psr::GREATER_THAN => method(:op_greater_than),
|
|
264
|
+
psr::LESS_THAN_OR_EQUAL => method(:op_less_than_or_equal),
|
|
265
|
+
psr::GREATER_THAN_OR_EQUAL => method(:op_greater_than_or_equal)
|
|
266
|
+
}
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# @return [Hash{String => Method}] `in` and `not_in`
|
|
270
|
+
# @api private
|
|
271
|
+
def set_operators
|
|
272
|
+
psr = PermissionForge::PermissionSetRule
|
|
273
|
+
{ psr::IN => method(:op_in), psr::NOT_IN => method(:op_not_in) }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# @return [Hash{String => Method}] `contains`, `not_contains`, `starts_with` and `ends_with`
|
|
277
|
+
# @api private
|
|
278
|
+
def string_operators
|
|
279
|
+
psr = PermissionForge::PermissionSetRule
|
|
280
|
+
{
|
|
281
|
+
psr::CONTAINS => method(:op_contains),
|
|
282
|
+
psr::NOT_CONTAINS => method(:op_not_contains),
|
|
283
|
+
psr::STARTS_WITH => method(:op_starts_with),
|
|
284
|
+
psr::ENDS_WITH => method(:op_ends_with)
|
|
285
|
+
}
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# @return [Hash{String => Method}] `between`, `is_null` and `is_not_null`
|
|
289
|
+
# @api private
|
|
290
|
+
def range_and_null_operators
|
|
291
|
+
psr = PermissionForge::PermissionSetRule
|
|
292
|
+
{
|
|
293
|
+
psr::BETWEEN => method(:op_between),
|
|
294
|
+
psr::IS_NULL => method(:op_is_null),
|
|
295
|
+
psr::IS_NOT_NULL => method(:op_is_not_null)
|
|
296
|
+
}
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Operators
|
|
5
|
+
# The model column a rule's field_name names, as far as it can be told:
|
|
6
|
+
# what {ValueTyping} goes by to give the rule's value that column's type.
|
|
7
|
+
#
|
|
8
|
+
# A plain name is a column of the model the rule ends on — its own, or the
|
|
9
|
+
# last one on its association path. A dotted name (`customers.supplier_id`,
|
|
10
|
+
# `public.customers.supplier_id`) says which table: the rule's own or one
|
|
11
|
+
# along the path; a schema or database before it is not looked at. The
|
|
12
|
+
# column is looked up the way the database resolves the name: among the
|
|
13
|
+
# table's columns (a model's `ignored_columns` are still columns), and
|
|
14
|
+
# without regard to case where the database has none ({EXACT_NAMES}) — a
|
|
15
|
+
# name the database resolves has to be typed, or its value is compared as
|
|
16
|
+
# text after all. A lookup that is more generous than the database costs
|
|
17
|
+
# nothing: the database then rejects the name itself.
|
|
18
|
+
#
|
|
19
|
+
# When the table is none of the rule's, or has no such column, nothing is
|
|
20
|
+
# known and the value is left as it is. A field_name that is not a name
|
|
21
|
+
# ({.name?}) is {#malformed?}: such a rule matches nothing.
|
|
22
|
+
class RuleColumn
|
|
23
|
+
# Adapters whose database compares a quoted name as it is written. On
|
|
24
|
+
# every other one — MySQL, SQLite — `Flag` is the column `flag`.
|
|
25
|
+
EXACT_NAMES = %w[PostgreSQL PostGIS].freeze
|
|
26
|
+
|
|
27
|
+
# @return [ActiveRecord::ConnectionAdapters::Column, nil]
|
|
28
|
+
attr_reader :column
|
|
29
|
+
# @return [Hash, nil] label => stored value, when the model declares the column an enum
|
|
30
|
+
attr_reader :enum
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# A field name is one name, or several joined by dots. An empty one
|
|
34
|
+
# among them — a leading, trailing or doubled dot, or nothing at all —
|
|
35
|
+
# makes it no name: quoted segment by segment it would be another
|
|
36
|
+
# name than the one that was written.
|
|
37
|
+
def name?(field_name)
|
|
38
|
+
segments = field_name.to_s.split('.', -1)
|
|
39
|
+
segments.any? && segments.none?(&:empty?)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param field_name [String] the rule's field_name
|
|
43
|
+
# @param models [Array<Class>] the rule's model, then the model of each
|
|
44
|
+
# association on its path (see {.models_along})
|
|
45
|
+
# @return [RuleColumn]
|
|
46
|
+
def find(field_name, models)
|
|
47
|
+
return MALFORMED unless name?(field_name)
|
|
48
|
+
|
|
49
|
+
*qualifier, name = field_name.to_s.split('.')
|
|
50
|
+
model = table_model(qualifier.last, models)
|
|
51
|
+
model ? on(model, name) : UNKNOWN
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The rule's own model, then the model of each association on its path;
|
|
55
|
+
# a plain field_name is a column of the last one.
|
|
56
|
+
#
|
|
57
|
+
# @param model [Class] the rule's model
|
|
58
|
+
# @param association_chain [Array<String>] the rule's field_accessor_path, split
|
|
59
|
+
# @return [Array<Class>] empty when the path cannot be followed
|
|
60
|
+
def models_along(model, association_chain)
|
|
61
|
+
association_chain.reduce([model]) do |models, assoc|
|
|
62
|
+
joined = models.last.reflect_on_association(assoc.to_sym)&.klass
|
|
63
|
+
break [] unless joined
|
|
64
|
+
|
|
65
|
+
models << joined
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# The model a table name stands for; without one, the model the rule ends on.
|
|
72
|
+
def table_model(table, models)
|
|
73
|
+
return models.last unless table
|
|
74
|
+
|
|
75
|
+
models.find { |model| same_name?(model.table_name.to_s.split('.').last, table, exact_names?(model)) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def on(model, name)
|
|
79
|
+
connection = model.connection
|
|
80
|
+
column = column_named(connection.schema_cache.columns_hash(model.table_name), name, exact_names?(model))
|
|
81
|
+
new(column, column && model.defined_enums[column.name], connection.adapter_name)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def column_named(columns, name, exact)
|
|
85
|
+
columns[name] || columns.each_value.find { |column| same_name?(column.name, name, exact) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def same_name?(own, written, exact)
|
|
89
|
+
own == written || (!exact && own.casecmp?(written))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def exact_names?(model)
|
|
93
|
+
EXACT_NAMES.include?(model.connection.adapter_name)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def initialize(column, enum, adapter_name)
|
|
98
|
+
@column = column
|
|
99
|
+
@enum = enum
|
|
100
|
+
@adapter_name = adapter_name
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Nothing is known about the column: no table of the rule, or no such column.
|
|
104
|
+
UNKNOWN = new(nil, nil, nil).freeze
|
|
105
|
+
# The rule's field_name is not a name.
|
|
106
|
+
MALFORMED = new(nil, nil, nil).freeze
|
|
107
|
+
|
|
108
|
+
def malformed?
|
|
109
|
+
equal?(MALFORMED)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The caster for the column's type, on the database it is in.
|
|
113
|
+
#
|
|
114
|
+
# @return [#call] see ValueCasters.for
|
|
115
|
+
def caster
|
|
116
|
+
ValueCasters.for(column, @adapter_name)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module PermissionForge
|
|
2
|
+
module Operators
|
|
3
|
+
# Operator-specific checks of a rule's `field_value` (`between` takes exactly two values, `in` at least one).
|
|
4
|
+
# Add a check with one entry in `VALIDATORS`.
|
|
5
|
+
#
|
|
6
|
+
# @api public
|
|
7
|
+
module Validations
|
|
8
|
+
VALUE_REQUIRED_MESSAGE = lambda {
|
|
9
|
+
I18n.t('permission_forge.validations.permission_set_rule.value_required',
|
|
10
|
+
default: 'must not be blank for this operator')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
NUMERIC_REQUIRED_MESSAGE = lambda {
|
|
14
|
+
I18n.t('permission_forge.validations.permission_set_rule.numeric_or_date_required',
|
|
15
|
+
default: 'must be a numeric or date value for comparison operators')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
VALUE_NOT_EXPECTED_MESSAGE = lambda {
|
|
19
|
+
I18n.t('permission_forge.validations.permission_set_rule.value_not_expected',
|
|
20
|
+
default: 'does not require a value for this operator')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
SINGLE_VALUE_VALIDATOR = lambda { |values|
|
|
24
|
+
return unless values.empty?
|
|
25
|
+
|
|
26
|
+
{ field: :field_value, message: VALUE_REQUIRED_MESSAGE.call }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
NUMERIC_VALIDATOR = lambda { |values|
|
|
30
|
+
return if values.empty?
|
|
31
|
+
return if values.all? { |v| v.match?(/\A-?\d+(\.\d+)?\z/) || parseable_date?(v) }
|
|
32
|
+
|
|
33
|
+
{ field: :field_value, message: NUMERIC_REQUIRED_MESSAGE.call }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
NULL_OPERATOR_VALIDATOR = lambda { |values|
|
|
37
|
+
return if values.empty?
|
|
38
|
+
|
|
39
|
+
{ field: :field_value, message: VALUE_NOT_EXPECTED_MESSAGE.call }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
VALIDATORS = {
|
|
43
|
+
'between' => lambda { |values|
|
|
44
|
+
return if values.size == 2
|
|
45
|
+
|
|
46
|
+
{ field: :field_value,
|
|
47
|
+
message: I18n.t('permission_forge.validations.permission_set_rule.between_requires_two_values',
|
|
48
|
+
default: 'must contain exactly 2 comma-separated values for between operator') }
|
|
49
|
+
},
|
|
50
|
+
'in' => lambda { |values|
|
|
51
|
+
return unless values.empty?
|
|
52
|
+
|
|
53
|
+
{ field: :field_value,
|
|
54
|
+
message: I18n.t('permission_forge.validations.permission_set_rule.in_requires_values',
|
|
55
|
+
default: 'must contain at least 1 comma-separated value') }
|
|
56
|
+
},
|
|
57
|
+
'not_in' => lambda { |values|
|
|
58
|
+
return unless values.empty?
|
|
59
|
+
|
|
60
|
+
{ field: :field_value,
|
|
61
|
+
message: I18n.t('permission_forge.validations.permission_set_rule.in_requires_values',
|
|
62
|
+
default: 'must contain at least 1 comma-separated value') }
|
|
63
|
+
},
|
|
64
|
+
'equal' => SINGLE_VALUE_VALIDATOR,
|
|
65
|
+
'not_equal' => SINGLE_VALUE_VALIDATOR,
|
|
66
|
+
'contains' => SINGLE_VALUE_VALIDATOR,
|
|
67
|
+
'not_contains' => SINGLE_VALUE_VALIDATOR,
|
|
68
|
+
'starts_with' => SINGLE_VALUE_VALIDATOR,
|
|
69
|
+
'ends_with' => SINGLE_VALUE_VALIDATOR,
|
|
70
|
+
'greater_than' => NUMERIC_VALIDATOR,
|
|
71
|
+
'less_than' => NUMERIC_VALIDATOR,
|
|
72
|
+
'greater_than_or_equal' => NUMERIC_VALIDATOR,
|
|
73
|
+
'less_than_or_equal' => NUMERIC_VALIDATOR,
|
|
74
|
+
'is_null' => NULL_OPERATOR_VALIDATOR,
|
|
75
|
+
'is_not_null' => NULL_OPERATOR_VALIDATOR
|
|
76
|
+
}.freeze
|
|
77
|
+
|
|
78
|
+
# The operators that take no value (the null checks): the ones whose
|
|
79
|
+
# validator refuses any value.
|
|
80
|
+
VALUELESS = VALIDATORS.select { |_, validator| validator.equal?(NULL_OPERATOR_VALIDATOR) }.keys.freeze
|
|
81
|
+
|
|
82
|
+
# @return [Boolean] whether `operator` takes no value
|
|
83
|
+
def self.valueless?(operator)
|
|
84
|
+
VALUELESS.include?(operator.to_s)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param operator [String] an operator of PermissionSetRule
|
|
88
|
+
# @param field_value [String] the rule's value
|
|
89
|
+
# @return [Hash, nil] `{ field:, message: }` when the value is invalid for the operator, else nil
|
|
90
|
+
def self.validate(operator, field_value)
|
|
91
|
+
return if operator.blank? || field_value.blank?
|
|
92
|
+
# 'current_user' is a special substitution token valid for all operators
|
|
93
|
+
return if field_value.to_s.strip == 'current_user'
|
|
94
|
+
|
|
95
|
+
validator = VALIDATORS[operator]
|
|
96
|
+
return unless validator
|
|
97
|
+
|
|
98
|
+
parsed = field_value.to_s.split(',').map(&:strip).reject(&:blank?)
|
|
99
|
+
validator.call(parsed)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.parseable_date?(value)
|
|
103
|
+
Date.parse(value)
|
|
104
|
+
true
|
|
105
|
+
rescue ArgumentError, TypeError
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
private_class_method :parseable_date?
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bigdecimal'
|
|
4
|
+
|
|
5
|
+
module PermissionForge
|
|
6
|
+
module Operators
|
|
7
|
+
module ValueCasters
|
|
8
|
+
# Casts the text of a number for an integer, decimal or float column.
|
|
9
|
+
module Number
|
|
10
|
+
# A plain decimal, or one in scientific notation (`1e3`): every
|
|
11
|
+
# database reads both.
|
|
12
|
+
FORMAT = /\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d{1,3})?\z/
|
|
13
|
+
BIGINT = (-(2**63)..((2**63) - 1))
|
|
14
|
+
# Past the range of a double the databases part ways again: PostgreSQL
|
|
15
|
+
# raises on a float column, MySQL and SQLite round a tiny number to zero.
|
|
16
|
+
DOUBLE = (Float::MIN..Float::MAX)
|
|
17
|
+
# What a single-precision float holds, a little short of both ends.
|
|
18
|
+
SINGLE = (1.1754944e-38..3.4028234e38)
|
|
19
|
+
|
|
20
|
+
# A whole number that fits 64 bits is an Integer, however it is written
|
|
21
|
+
# (`50`, `50.0`, `5e1`): compared with an integer column it can use the
|
|
22
|
+
# column's index, where a decimal makes PostgreSQL cast every row. Any
|
|
23
|
+
# other number — a fraction, one past that range — is a BigDecimal,
|
|
24
|
+
# which every adapter writes as an exact numeric literal: the database
|
|
25
|
+
# compares numbers (`supplier_id < 5.5`, `supplier_id <
|
|
26
|
+
# 99999999999999999999`) and never casts text to the column's type.
|
|
27
|
+
EXACT = lambda do |value|
|
|
28
|
+
text = value.to_s.strip
|
|
29
|
+
next UNUSABLE unless text.match?(FORMAT)
|
|
30
|
+
|
|
31
|
+
decimal = BigDecimal(text)
|
|
32
|
+
next decimal.to_i if decimal.frac.zero? && BIGINT.cover?(decimal)
|
|
33
|
+
|
|
34
|
+
decimal.zero? || DOUBLE.cover?(decimal.abs) ? decimal : UNUSABLE
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# For a PostgreSQL `real` column. PostgreSQL compares it with a number
|
|
38
|
+
# in double precision, where 0.1 is not the 0.1 the column stores; text
|
|
39
|
+
# it reads as a `real`. So the rule's text is handed over as the text
|
|
40
|
+
# of the number it was checked to be, and compared in single precision
|
|
41
|
+
# — as it was before values were typed. Two things stay numbers: the
|
|
42
|
+
# id `current_user` stands for, and a number no `real` can hold, which
|
|
43
|
+
# equals none and on which PostgreSQL would raise as text. MySQL
|
|
44
|
+
# compares a FLOAT in double precision whatever it gets, and SQLite
|
|
45
|
+
# has doubles only: there the number goes as a number (EXACT).
|
|
46
|
+
SINGLE_PRECISION = lambda do |value|
|
|
47
|
+
number = EXACT.call(value)
|
|
48
|
+
next number unless value.is_a?(String) && !number.equal?(UNUSABLE)
|
|
49
|
+
next number unless number.zero? || SINGLE.cover?(number.abs)
|
|
50
|
+
|
|
51
|
+
number.is_a?(Integer) ? number.to_s : number.to_s('F')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The two bounds of `between` on a PostgreSQL `real` column, likewise
|
|
55
|
+
# as they always were: two plain integers or two plain decimals went
|
|
56
|
+
# to PostgreSQL as numbers (the reading still made where nothing is
|
|
57
|
+
# known about a column), any other pair as text.
|
|
58
|
+
SINGLE_PRECISION_BOUNDS = lambda do |bounds|
|
|
59
|
+
numbers = bounds.map { |bound| EXACT.call(bound) }
|
|
60
|
+
next UNUSABLE if numbers.include?(UNUSABLE)
|
|
61
|
+
|
|
62
|
+
plain = Registry.coerce_between_values(*bounds).first.is_a?(Numeric)
|
|
63
|
+
plain ? numbers : bounds.map { |bound| SINGLE_PRECISION.call(bound) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Operators
|
|
5
|
+
module ValueCasters
|
|
6
|
+
# Casts ISO 8601 text for a date, time or datetime column.
|
|
7
|
+
#
|
|
8
|
+
# The text is checked here (the notation, a real calendar day) and cast by
|
|
9
|
+
# the ActiveRecord type of the column, so the adapter quotes the result the
|
|
10
|
+
# way it stores that column: a date-time without an offset is read in the
|
|
11
|
+
# database's time zone (`ActiveRecord.default_timezone`), one with an
|
|
12
|
+
# offset is converted to it, and SQLite gets the text it compares with.
|
|
13
|
+
# A date column takes the day of a date-time, a time column the time of
|
|
14
|
+
# day. Anything else — another notation, `now`, `tomorrow`, the id that
|
|
15
|
+
# `current_user` stands for — is UNUSABLE, unless the caster is the one
|
|
16
|
+
# for a database that reads such text itself ({#as_written}).
|
|
17
|
+
class Temporal
|
|
18
|
+
DAY = /(?<year>\d{4})-(?<month>\d\d?)-(?<day>\d\d?)/
|
|
19
|
+
CLOCK = /(?:[01]?\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?/
|
|
20
|
+
OFFSET = /Z|[+-]\d\d(?::?\d\d)?/
|
|
21
|
+
DAY_AND_CLOCK = /\A#{DAY}(?:[T ]#{CLOCK}(?: ?#{OFFSET})?)?\z/
|
|
22
|
+
CLOCK_ONLY = /\A(?:\d{4}-\d\d-\d\d[T ])?#{CLOCK}\z/
|
|
23
|
+
|
|
24
|
+
# @param format [Regexp] what the text has to look like
|
|
25
|
+
# @param type [ActiveModel::Type::Value] casts the text and serializes the result
|
|
26
|
+
# @param as_written [Boolean] hand text of another notation over as it is
|
|
27
|
+
def initialize(format, type, as_written: false)
|
|
28
|
+
@format = format
|
|
29
|
+
@type = type
|
|
30
|
+
@as_written = as_written
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call(value)
|
|
34
|
+
return UNUSABLE unless value.is_a?(String)
|
|
35
|
+
|
|
36
|
+
recognised(value.strip) || (@as_written ? value : UNUSABLE)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The same caster for a database that reads date and time text itself
|
|
40
|
+
# and raises on what it cannot read (PostgreSQL). Text this caster does
|
|
41
|
+
# not recognise is handed over as it is written, as it was before
|
|
42
|
+
# values were typed: `today`, a date in a local notation. What is not
|
|
43
|
+
# text stays UNUSABLE.
|
|
44
|
+
#
|
|
45
|
+
# @return [Temporal]
|
|
46
|
+
def as_written
|
|
47
|
+
self.class.new(@format, @type, as_written: true)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
DATE = new(DAY_AND_CLOCK, ActiveRecord::Type::Date.new)
|
|
51
|
+
DATETIME = new(DAY_AND_CLOCK, ActiveRecord::Type::DateTime.new)
|
|
52
|
+
TIME = new(CLOCK_ONLY, ActiveRecord::Type::Time.new)
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def recognised(text)
|
|
57
|
+
parts = @format.match(text)
|
|
58
|
+
@type.serialize(@type.cast(text)) if parts && real_day?(parts)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A time of day names no day; a date has to be one of the calendar.
|
|
62
|
+
def real_day?(parts)
|
|
63
|
+
year, month, day = parts.named_captures.values_at('year', 'month', 'day')
|
|
64
|
+
year.nil? || Date.valid_date?(year.to_i, month.to_i, day.to_i)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|