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,305 @@
|
|
|
1
|
+
module PermissionForge
|
|
2
|
+
module Services
|
|
3
|
+
# Builds a merged permission hash for a user across multiple permission sets.
|
|
4
|
+
# Uses most-permissive-wins logic for CRUD/boolean flags and AND logic for masks.
|
|
5
|
+
# Muting permission sets subtract permissions after granting sets are merged.
|
|
6
|
+
# Field rows are merged by {FieldPermissionMerger}; the implicit entries of
|
|
7
|
+
# extra (virtual) fields are added by {ExtraFieldDefaults}.
|
|
8
|
+
#
|
|
9
|
+
# Every row a build reads is loaded up front, one query per table
|
|
10
|
+
# (#load_permission_rows): the sets' resources with their fields, actions
|
|
11
|
+
# and rules, active or not, and the user's assignment of each set with
|
|
12
|
+
# the people who granted and last changed it. Inactive rows are skipped in
|
|
13
|
+
# Ruby (`select(&:is_active)`) — the `active` scope would query again, per
|
|
14
|
+
# resource. Each holder of a set rebuilds after any edit to it, so a
|
|
15
|
+
# build must not cost a query per resource.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# data = PermissionHashBuilder.call(user: user, permission_sets: user.permission_sets.active)
|
|
19
|
+
# # => { resources: { Customer: { permissions: { read_access: true, ... }, fields: {...}, ... } } }
|
|
20
|
+
#
|
|
21
|
+
# @api public
|
|
22
|
+
class PermissionHashBuilder
|
|
23
|
+
RESOURCE_PERMISSION_OPTIONS = %i[read_access create_access edit_access delete_access view_all modify_all].freeze
|
|
24
|
+
# The rows each resource brings into the hash (#load_permission_rows).
|
|
25
|
+
RESOURCE_ROWS = %i[resource_fields resource_actions permission_set_rules].freeze
|
|
26
|
+
# The columns naming who granted and last changed an assignment (HasAuditTrail).
|
|
27
|
+
GRANTOR_KEYS = %w[created_by_id updated_by_id].freeze
|
|
28
|
+
|
|
29
|
+
# Resources and their rows in id order, in place. The database returns
|
|
30
|
+
# them in whatever order its plan gives, and order decides: the first row
|
|
31
|
+
# of a name wins a tie (two fields mapping to one variant — "first
|
|
32
|
+
# mask_strategy wins"), and rules and actions keep it in the hash. The one
|
|
33
|
+
# definition: Admin::EffectiveAccess sorts the rows it shows with it. An
|
|
34
|
+
# unsaved row (no id yet) sorts first.
|
|
35
|
+
def self.sort_rows(permission_set)
|
|
36
|
+
resources = sorted(permission_set.association(:resources))
|
|
37
|
+
resources.each { |resource| RESOURCE_ROWS.each { |rows| sorted(resource.association(rows)) } }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.sorted(association)
|
|
41
|
+
association.target.sort_by! { |row| row.id.to_i }
|
|
42
|
+
end
|
|
43
|
+
private_class_method :sorted
|
|
44
|
+
|
|
45
|
+
# Builds a user's permission hash from their permission sets.
|
|
46
|
+
# @param user [Object]
|
|
47
|
+
# @param permission_sets [Enumerable<PermissionSet>]
|
|
48
|
+
# @return [Hash]
|
|
49
|
+
def self.call(user:, permission_sets:)
|
|
50
|
+
new(user: user, permission_sets: permission_sets).call
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(user:, permission_sets:)
|
|
54
|
+
@user = user
|
|
55
|
+
@permission_sets = permission_sets
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Hash] the permission hash (the format is in the architecture guide)
|
|
59
|
+
def call
|
|
60
|
+
data = { resources: {} }
|
|
61
|
+
load_permission_rows
|
|
62
|
+
|
|
63
|
+
granting_sets, muting_sets = partition_permission_sets
|
|
64
|
+
|
|
65
|
+
granting_sets.each { |ps| process_permission_set(data, ps) }
|
|
66
|
+
add_extra_field_defaults(data)
|
|
67
|
+
muting_sets.each { |ps| apply_muting_permission_set(data, ps) }
|
|
68
|
+
|
|
69
|
+
data
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def partition_permission_sets
|
|
75
|
+
@permission_sets.partition { |ps| !ps.is_muting }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# --- Loading (every row of the build, one query per table) ---
|
|
79
|
+
|
|
80
|
+
# The sets in the order given (created_at, id: "first mask_strategy
|
|
81
|
+
# wins" depends on it), each with its resources and their rows loaded.
|
|
82
|
+
def load_permission_rows
|
|
83
|
+
@permission_sets = @permission_sets.to_a
|
|
84
|
+
preload(@permission_sets, resources: RESOURCE_ROWS)
|
|
85
|
+
@permission_sets.each { |permission_set| self.class.sort_rows(permission_set) }
|
|
86
|
+
@assignments = assignments_by_set
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The user's assignment of each set (any row of the pair, as find_by
|
|
90
|
+
# would answer), with its grantors.
|
|
91
|
+
def assignments_by_set
|
|
92
|
+
return {} if @permission_sets.empty?
|
|
93
|
+
|
|
94
|
+
assignments = PermissionForge::PermissionSetUser.where(user_id: @user.id, permission_set_id: @permission_sets.map(&:id)).to_a
|
|
95
|
+
preload_grantors(assignments)
|
|
96
|
+
assignments.each_with_object({}) { |assignment, by_set| by_set[assignment.permission_set_id] ||= assignment }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Only where an assignment names one: preloading builds the user
|
|
100
|
+
# model's scope — its default scopes run — even for no id at all, which
|
|
101
|
+
# the association readers never did.
|
|
102
|
+
def preload_grantors(assignments)
|
|
103
|
+
stamped = assignments.select { |assignment| GRANTOR_KEYS.any? { |key| assignment[key] } }
|
|
104
|
+
preload(stamped, %i[created_by updated_by]) if stamped.any?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def preload(records, associations)
|
|
108
|
+
ActiveRecord::Associations::Preloader.new(records: records, associations: associations).call
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# --- Granting logic (additive, most-permissive-wins) ---
|
|
112
|
+
|
|
113
|
+
def process_permission_set(data, permission_set)
|
|
114
|
+
permission_set.resources.select(&:is_active).each do |resource|
|
|
115
|
+
process_resource(data, permission_set, resource)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def process_resource(data, permission_set, resource)
|
|
120
|
+
return unless resource.is_active
|
|
121
|
+
|
|
122
|
+
resource_data = init_resource_data(data, resource.name)
|
|
123
|
+
|
|
124
|
+
add_source_permission_set(resource_data[:source_permission_sets], permission_set)
|
|
125
|
+
merge_resource_permissions(resource_data[:permissions], resource)
|
|
126
|
+
FieldPermissionMerger.call(fields: resource_data[:fields], resource: resource)
|
|
127
|
+
merge_resource_actions(resource_data[:actions], resource)
|
|
128
|
+
merge_permission_set_rules(resource_data[:permission_set_rules], resource)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Once per resource, after every granting set and before muting: a
|
|
132
|
+
# ResourceField row in any of the user's sets wins over the implicit
|
|
133
|
+
# grant, whichever set is older, and the grant follows the resource's
|
|
134
|
+
# merged read and edit.
|
|
135
|
+
def add_extra_field_defaults(data)
|
|
136
|
+
data[:resources].each do |name, resource_data|
|
|
137
|
+
ExtraFieldDefaults.call(entry: resource_data, resource_name: name)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def init_resource_data(data, name)
|
|
142
|
+
data[:resources][name] ||= {
|
|
143
|
+
permissions: {},
|
|
144
|
+
fields: {},
|
|
145
|
+
actions: {},
|
|
146
|
+
source_permission_sets: [],
|
|
147
|
+
permission_set_rules: []
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def add_source_permission_set(source_permission_sets, permission_set)
|
|
152
|
+
permission_set_user = @assignments[permission_set.id]
|
|
153
|
+
|
|
154
|
+
source_permission_sets << {
|
|
155
|
+
uuid: permission_set.uuid,
|
|
156
|
+
name: permission_set.name,
|
|
157
|
+
is_muting: permission_set.is_muting,
|
|
158
|
+
permission_set_granted_by: user_reference(permission_set_user&.created_by),
|
|
159
|
+
permission_set_granted_updated_by: user_reference(permission_set_user&.updated_by),
|
|
160
|
+
permission_set_granted_at: permission_set_user&.created_at,
|
|
161
|
+
permission_set_granted_updated_at: permission_set_user&.updated_at
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Who granted or last changed an assignment: the person's uuid when the
|
|
166
|
+
# audit user model has one, else their id. Nothing asks a host user
|
|
167
|
+
# model for a uuid column.
|
|
168
|
+
def user_reference(user)
|
|
169
|
+
return unless user
|
|
170
|
+
|
|
171
|
+
user.respond_to?(:uuid) ? user.uuid : user.id
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def merge_resource_permissions(permissions, resource)
|
|
175
|
+
RESOURCE_PERMISSION_OPTIONS.each do |action|
|
|
176
|
+
permissions[action] ||= resource.send(action)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def merge_resource_actions(actions, resource)
|
|
181
|
+
resource.resource_actions.select(&:is_active).each do |action|
|
|
182
|
+
key = action.name.to_sym
|
|
183
|
+
if actions.key?(key)
|
|
184
|
+
actions[key][:is_permitted] ||= action.is_permitted
|
|
185
|
+
else
|
|
186
|
+
actions[key] = { is_permitted: action.is_permitted }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def merge_permission_set_rules(permission_set_rules, resource)
|
|
192
|
+
resource.permission_set_rules.select(&:is_active).each do |rule|
|
|
193
|
+
permission_set_rules << {
|
|
194
|
+
name: rule.name,
|
|
195
|
+
failure_message: rule.failure_message,
|
|
196
|
+
join_type: rule.join_type,
|
|
197
|
+
field_name: rule.field_name,
|
|
198
|
+
field_value: rule.field_value,
|
|
199
|
+
field_accessor_path: rule.field_accessor_path,
|
|
200
|
+
operator: rule.operator,
|
|
201
|
+
include_nulls: rule.include_nulls
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# --- Muting logic (subtractive, deny overrides allow) ---
|
|
207
|
+
|
|
208
|
+
# A muting resource reaches every entry whose resource chain names it
|
|
209
|
+
# (ResourceChain): the entry of its own name and, when it names a
|
|
210
|
+
# model, the entries of that model's STI subclasses. A subclass can
|
|
211
|
+
# only be narrower than its base, and the CRUD flags, the bypasses and
|
|
212
|
+
# the actions are read from one entry (can_*?, the controller guard):
|
|
213
|
+
# a muted base that left the subclass entry whole let the subclass
|
|
214
|
+
# controller destroy the row the admin believed muted, while the rows
|
|
215
|
+
# and fields, composed along the chain, were already closed. A muting
|
|
216
|
+
# row naming a subclass reaches that subclass alone.
|
|
217
|
+
def apply_muting_permission_set(data, permission_set)
|
|
218
|
+
permission_set.resources.select(&:is_active).each do |resource|
|
|
219
|
+
entries_under(data, resource.name).each do |resource_data|
|
|
220
|
+
apply_muting_resource(resource_data, permission_set, resource)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# The entries whose chain includes the resource name, in hash order;
|
|
226
|
+
# each entry's chain is worked out once per build.
|
|
227
|
+
def entries_under(data, name)
|
|
228
|
+
muted = name.to_sym
|
|
229
|
+
@entry_chains ||= {}
|
|
230
|
+
data[:resources].filter_map do |entry_name, resource_data|
|
|
231
|
+
chain = (@entry_chains[entry_name] ||= PermissionForge::ResourceChain.names_for(entry_name))
|
|
232
|
+
resource_data if chain.include?(muted)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# The set is listed once per entry: one with rows for a base and its
|
|
237
|
+
# subclass reaches the subclass entry twice.
|
|
238
|
+
def apply_muting_resource(resource_data, permission_set, resource)
|
|
239
|
+
sources = resource_data[:source_permission_sets]
|
|
240
|
+
add_source_permission_set(sources, permission_set) if sources.none? { |source| source[:uuid] == permission_set.uuid }
|
|
241
|
+
mute_resource_permissions(resource_data[:permissions], resource)
|
|
242
|
+
mute_field_permissions(resource_data[:fields], resource)
|
|
243
|
+
mute_fields_of_resource(resource_data[:fields], resource)
|
|
244
|
+
mute_resource_actions(resource_data[:actions], resource)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def mute_resource_permissions(permissions, resource)
|
|
248
|
+
RESOURCE_PERMISSION_OPTIONS.each do |action|
|
|
249
|
+
permissions[action] = false if resource.send(action)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# A muting set that revokes the resource's read revokes read and edit on
|
|
254
|
+
# every field of the resource; variants and implicit extra fields are
|
|
255
|
+
# entries too. Field enforcement (getters, as_json, the filters) reads
|
|
256
|
+
# the field entries only, so muting the resource used to protect less
|
|
257
|
+
# than never granting it. Read only: PermitsAttributes filters creates
|
|
258
|
+
# too, so the same cascade on edit would block a user who may create but
|
|
259
|
+
# not change ("can submit, cannot edit afterwards"). Granting sets are
|
|
260
|
+
# left to their field rows, as before.
|
|
261
|
+
def mute_fields_of_resource(fields, resource)
|
|
262
|
+
return unless resource.read_access
|
|
263
|
+
|
|
264
|
+
fields.each_value do |entry|
|
|
265
|
+
entry[:read_access] = false
|
|
266
|
+
entry[:edit_access] = false
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def mute_field_permissions(fields, resource)
|
|
271
|
+
resource.resource_fields.select(&:is_active).each do |field|
|
|
272
|
+
next unless field.is_active
|
|
273
|
+
|
|
274
|
+
# Mute the base field AND every configured variant (_uuid, base name for
|
|
275
|
+
# _cents, etc.), matching the variants the granting phase writes — otherwise
|
|
276
|
+
# a muting set that revokes `user_id` would leave `user_uuid` readable.
|
|
277
|
+
mute_single_field(fields, field.name, field)
|
|
278
|
+
PermissionForge.configuration.field_variants_for(field.name).each do |variant|
|
|
279
|
+
mute_single_field(fields, variant, field)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# A row that mutes read mutes edit too: a field nobody may read is not
|
|
285
|
+
# one they may write (the admin UI's Hide writes both flags; the API
|
|
286
|
+
# and code can write read alone, which left the field writable through
|
|
287
|
+
# PermitsAttributes). Admin::EffectiveFields#mute_field says the same.
|
|
288
|
+
def mute_single_field(fields, field_name, field)
|
|
289
|
+
entry = fields[field_name]
|
|
290
|
+
return unless entry
|
|
291
|
+
|
|
292
|
+
entry[:read_access] = false if field.read_access
|
|
293
|
+
entry[:edit_access] = false if field.read_access || field.edit_access
|
|
294
|
+
entry[:mask_access] = true if field.mask_access
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def mute_resource_actions(actions, resource)
|
|
298
|
+
resource.resource_actions.select(&:is_active).each do |action|
|
|
299
|
+
key = action.name.to_sym
|
|
300
|
+
actions[key][:is_permitted] = false if actions.key?(key) && action.is_permitted
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
module PermissionForge
|
|
2
|
+
module Services
|
|
3
|
+
# Applies row-level permission rules to build a scoped ActiveRecord query.
|
|
4
|
+
# Extracted from PermissionScopedFindable for SRP and testability.
|
|
5
|
+
#
|
|
6
|
+
# Column references are quoted with the connection's real identifier quoting
|
|
7
|
+
# (quote_table_name / quote_column_name) rather than string interpolation, so
|
|
8
|
+
# a malformed field_name can never reach SQL as a literal even if a rule was
|
|
9
|
+
# created through a path that bypasses model validation.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
class PermissionRuleApplier
|
|
13
|
+
# join_type => the relation method that makes the join. The model
|
|
14
|
+
# validates join_type, but a rule row can be written past the model (the
|
|
15
|
+
# tables are shared with other applications): the value is looked up
|
|
16
|
+
# here, never sent to the relation, and anything else raises.
|
|
17
|
+
JOIN_METHODS = {
|
|
18
|
+
PermissionForge::Operators::Constants::JOINS => :joins,
|
|
19
|
+
PermissionForge::Operators::Constants::LEFT_JOINS => :left_joins,
|
|
20
|
+
PermissionForge::Operators::Constants::LEFT_OUTER_JOINS => :left_outer_joins
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def self.call(model_class:, rules:, args:, current_user: PermissionForge.current_user,
|
|
24
|
+
operator_registry: PermissionForge::Operators::Registry)
|
|
25
|
+
new(model_class:, rules:, args:, current_user:, operator_registry:).call
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Builds the rule-constrained relation without evaluating a specific record
|
|
29
|
+
# or computing failure messages. Used by the `permission_scoped` relation
|
|
30
|
+
# API so callers can chain further `.where`, `.first`, `.find`, etc.
|
|
31
|
+
#
|
|
32
|
+
# @return [ActiveRecord::Relation]
|
|
33
|
+
def self.scope(model_class:, rules:, current_user: PermissionForge.current_user,
|
|
34
|
+
operator_registry: PermissionForge::Operators::Registry)
|
|
35
|
+
new(model_class:, rules:, args: nil, current_user:, operator_registry:).scoped_relation
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [ActiveRecord::Relation] all rules applied, no args/denial check
|
|
39
|
+
def scoped_relation
|
|
40
|
+
report_no_rules if @rules.empty?
|
|
41
|
+
@rules.reduce(@model_class.where(nil)) { |q, rule| build_rule_query(q, rule) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(model_class:, rules:, args:, current_user:, operator_registry:)
|
|
45
|
+
@model_class = model_class
|
|
46
|
+
@rules = rules
|
|
47
|
+
@args = args
|
|
48
|
+
@current_user = current_user
|
|
49
|
+
@operator_registry = operator_registry
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Array(ActiveRecord::Relation, Array<String>)] query and failure messages
|
|
53
|
+
def call
|
|
54
|
+
report_no_rules if @rules.empty?
|
|
55
|
+
query = @rules.reduce(@model_class.where(nil)) { |q, rule| build_rule_query(q, rule) }
|
|
56
|
+
|
|
57
|
+
# Happy path is a single existence check: rule constraints are monotonic
|
|
58
|
+
# (each only removes rows), so if the fully-constrained query still matches
|
|
59
|
+
# the target, no individual rule denied it.
|
|
60
|
+
return [query, []] if @rules.empty? || query.where(*@args).exists?
|
|
61
|
+
|
|
62
|
+
# Denial path only: attribute the denial to specific rules for the error
|
|
63
|
+
# message. The extra per-rule queries here run solely when access is denied.
|
|
64
|
+
[query, denial_messages]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Called with no rules (the gem's own callers check first; a host app
|
|
70
|
+
# calling the applier directly may not): rule.bypassed, no_rules.
|
|
71
|
+
def report_no_rules
|
|
72
|
+
PermissionForge::Audit::Runtime.bypassed(:no_rules, model: @model_class, via: 'rule_applier', user: @current_user)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def rule_failure_message(rule)
|
|
76
|
+
"[Forbidden by Rule: #{rule[:name]}] with message: #{rule[:failure_message]}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Rebuilds the query rule-by-rule, blaming each rule whose addition removes
|
|
80
|
+
# the target from the result set (the first denying rule and every rule
|
|
81
|
+
# after it, matching the original cumulative semantics).
|
|
82
|
+
def denial_messages
|
|
83
|
+
messages = []
|
|
84
|
+
@rules.reduce(@model_class.where(nil)) do |q, rule|
|
|
85
|
+
next_query = build_rule_query(q, rule)
|
|
86
|
+
messages << rule_failure_message(rule) unless next_query.where(*@args).exists?
|
|
87
|
+
next_query
|
|
88
|
+
end
|
|
89
|
+
messages
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_rule_query(query, rule)
|
|
93
|
+
join_type = rule[:join_type].to_s
|
|
94
|
+
return apply_filter(query, rule) if PermissionForge::PermissionSetRule.filter_join_type?(join_type)
|
|
95
|
+
|
|
96
|
+
apply_join(query, rule, join_method(join_type))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @raise [PermissionForge::ConfigurationError] for a join_type that is not one
|
|
100
|
+
def join_method(join_type)
|
|
101
|
+
JOIN_METHODS.fetch(join_type) do
|
|
102
|
+
raise PermissionForge::ConfigurationError,
|
|
103
|
+
"Rule join_type #{join_type.inspect} is not a join type. Allowed: #{JOIN_METHODS.keys.join(', ')}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def apply_filter(query, rule)
|
|
108
|
+
column = quoted_column(@model_class.table_name, rule[:field_name])
|
|
109
|
+
build_query(query, column, rule, [@model_class])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A path that cannot be followed leaves no model to type by: the value
|
|
113
|
+
# goes as it is, and ActiveRecord raises for the join when the query is built.
|
|
114
|
+
def apply_join(query, rule, join_method)
|
|
115
|
+
query, models = resolve_join(query, rule, join_method)
|
|
116
|
+
column = quoted_column((models.last || @model_class).table_name, rule[:field_name])
|
|
117
|
+
build_query(query, column, rule, models)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Quotes a column reference with the connection's identifier quoting.
|
|
121
|
+
# A dotted field_name is treated as already-qualified path segments and
|
|
122
|
+
# each segment is quoted independently.
|
|
123
|
+
def quoted_column(table, field_name)
|
|
124
|
+
conn = @model_class.connection
|
|
125
|
+
name = field_name.to_s
|
|
126
|
+
return name.split('.').map { |seg| conn.quote_column_name(seg) }.join('.') if name.include?('.')
|
|
127
|
+
|
|
128
|
+
"#{conn.quote_table_name(table)}.#{conn.quote_column_name(name)}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def resolve_join(query, rule, join_method)
|
|
132
|
+
return [query, [@model_class]] if rule[:field_accessor_path].blank?
|
|
133
|
+
|
|
134
|
+
association_chain = rule[:field_accessor_path].split('.')
|
|
135
|
+
query = apply_join_to_query(query, join_method, association_chain)
|
|
136
|
+
|
|
137
|
+
[query, PermissionForge::Operators::RuleColumn.models_along(@model_class, association_chain)]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @param join_method [Symbol] one of JOIN_METHODS — never a rule's own text
|
|
141
|
+
def apply_join_to_query(query, join_method, association_chain)
|
|
142
|
+
if association_chain.size > 1
|
|
143
|
+
query.public_send(join_method, build_nested_joins_structure(association_chain))
|
|
144
|
+
else
|
|
145
|
+
query.public_send(join_method, association_chain.first.to_sym)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def build_nested_joins_structure(association_chain)
|
|
150
|
+
structure = {}
|
|
151
|
+
current = structure
|
|
152
|
+
|
|
153
|
+
association_chain.each_with_index do |assoc, index|
|
|
154
|
+
current[assoc.to_sym] = {}
|
|
155
|
+
current = current[assoc.to_sym] unless index == association_chain.length - 1
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
structure
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# A built-in operator gets its value in the type of the column it is
|
|
162
|
+
# compared with (see Operators::ValueTyping). A value that cannot be one
|
|
163
|
+
# of that column makes the rule match nothing, whatever the database
|
|
164
|
+
# would have made of the comparison. A custom operator's value is passed
|
|
165
|
+
# as it is and its strategy runs. A field_name that is not a name
|
|
166
|
+
# (Operators::RuleColumn.name?) matches nothing under any operator.
|
|
167
|
+
def build_query(query, quoted_column, rule, models)
|
|
168
|
+
operator = rule[:operator]
|
|
169
|
+
strategy = @operator_registry.fetch(operator)
|
|
170
|
+
target = PermissionForge::Operators::RuleColumn.find(rule[:field_name], models)
|
|
171
|
+
value = PermissionForge::Operators::ValueTyping.call(operator, formatted_field_value(rule), target)
|
|
172
|
+
return query.none if value.equal?(PermissionForge::Operators::ValueTyping::UNUSABLE)
|
|
173
|
+
|
|
174
|
+
strategy.call(query, quoted_column, rule, value)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The only magic value is 'current_user', substituted with the current
|
|
178
|
+
# user's id where one value is compared (in a list it is an element like
|
|
179
|
+
# any other). Every other field_value reaches ValueTyping as it is
|
|
180
|
+
# written, and what comes back is quoted by the adapter — rule text is
|
|
181
|
+
# never interpolated into SQL.
|
|
182
|
+
def formatted_field_value(rule)
|
|
183
|
+
value = rule[:field_value]
|
|
184
|
+
return value unless PermissionForge::Operators::ValueTyping.current_user?(rule[:operator], value)
|
|
185
|
+
|
|
186
|
+
raise ArgumentError, 'current_user is nil but rule requires current_user substitution' unless @current_user
|
|
187
|
+
|
|
188
|
+
@current_user.id
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
module PermissionForge
|
|
2
|
+
module Services
|
|
3
|
+
# Deep-copies a Permission Set: its resources with their fields, actions
|
|
4
|
+
# and rules — never its assignments. Every copied row is created by an
|
|
5
|
+
# audited save credited to whoever makes the copy (Clonable), and the copy
|
|
6
|
+
# records `permission_set.cloned` naming its source; all of it shares one
|
|
7
|
+
# correlation id (an enclosing Audit.correlate block's, if any).
|
|
8
|
+
#
|
|
9
|
+
# PermissionSetCloner.call(permission_set: set, attrs: { name: 'Copy' })
|
|
10
|
+
# PermissionSetCloner.call(permission_set: set, attrs: { name: 'Copy' }, unlock: true) # every row editable
|
|
11
|
+
#
|
|
12
|
+
# @api public
|
|
13
|
+
class PermissionSetCloner
|
|
14
|
+
include Clonable
|
|
15
|
+
|
|
16
|
+
# @param permission_set [PermissionSet] the source
|
|
17
|
+
# @param attrs [Hash] attributes of the copy of the set itself
|
|
18
|
+
# @param unlock [Boolean] create every copied row editable
|
|
19
|
+
# (`is_editable: true`) instead of keeping the source's locks
|
|
20
|
+
# @return [PermissionSet] the copy
|
|
21
|
+
def self.call(permission_set:, attrs: {}, unlock: false)
|
|
22
|
+
new(permission_set, attrs, unlock: unlock).call
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(permission_set, attrs, unlock: false)
|
|
26
|
+
@permission_set = permission_set
|
|
27
|
+
@attrs = attrs
|
|
28
|
+
@unlock = unlock
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [PermissionSet] the copy
|
|
32
|
+
def call
|
|
33
|
+
PermissionForge::Audit.correlate do
|
|
34
|
+
@permission_set.class.transaction do
|
|
35
|
+
cloned = copy(@permission_set, **@attrs)
|
|
36
|
+
clone_resources(cloned)
|
|
37
|
+
record_clone(cloned)
|
|
38
|
+
cloned
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def clone_resources(target_set)
|
|
46
|
+
@permission_set.resources.includes(:resource_fields, :resource_actions, :permission_set_rules).find_each do |resource|
|
|
47
|
+
clone_resource(target_set, resource)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def clone_resource(target_set, resource)
|
|
52
|
+
cloned_resource = copy(resource, permission_set: target_set)
|
|
53
|
+
|
|
54
|
+
resource.resource_fields.each { |field| copy(field, resource: cloned_resource) }
|
|
55
|
+
clone_actions(resource, cloned_resource)
|
|
56
|
+
resource.permission_set_rules.each { |rule| copy(rule, permission_set: target_set, resource: cloned_resource) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# One copy per action name. Rows that share a name (possible only on a
|
|
60
|
+
# table without the unique index) are OR-ed when permissions are built,
|
|
61
|
+
# so the copy is the single row they amount to.
|
|
62
|
+
def clone_actions(resource, cloned_resource)
|
|
63
|
+
resource.resource_actions.group_by(&:name).each_value do |rows|
|
|
64
|
+
copy(rows.first, resource: cloned_resource, **combined_action_flags(rows))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def combined_action_flags(rows)
|
|
69
|
+
return {} if rows.one?
|
|
70
|
+
|
|
71
|
+
active = rows.select(&:is_active)
|
|
72
|
+
{ is_active: active.any?, is_permitted: (active.presence || rows).any?(&:is_permitted) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The lock is set before the first save, so the row's create event
|
|
76
|
+
# holds it: there is no second, unaudited write.
|
|
77
|
+
def copy(record, **attrs)
|
|
78
|
+
attrs = { is_editable: true }.merge(attrs) if @unlock && record.has_attribute?(:is_editable)
|
|
79
|
+
duplicate_record(record, **attrs)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def record_clone(cloned)
|
|
83
|
+
PermissionForge::Audit.record('permission_set.cloned', record: cloned,
|
|
84
|
+
data: { 'source_uuid' => @permission_set.uuid,
|
|
85
|
+
'source_name' => @permission_set.name })
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
require 'ipaddr'
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
# Evaluates activation conditions on a permission set against a request context.
|
|
6
|
+
# Returns true if the set is active for the given context (all conditions pass).
|
|
7
|
+
# A nil/blank condition always passes (unconditional).
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# PermissionSetConditionEvaluator.call(
|
|
11
|
+
# permission_set: ps,
|
|
12
|
+
# context: { ip: '192.168.1.5', session_flags: ['mfa_verified'], time: Time.current }
|
|
13
|
+
# )
|
|
14
|
+
# # => true or false
|
|
15
|
+
#
|
|
16
|
+
# @api public
|
|
17
|
+
class PermissionSetConditionEvaluator
|
|
18
|
+
# @param permission_set [PermissionSet]
|
|
19
|
+
# @param context [Hash] `ip:`, `session_flags:` and `time:` of the request
|
|
20
|
+
# @return [Boolean]
|
|
21
|
+
def self.call(permission_set:, context:)
|
|
22
|
+
new(permission_set, context).call
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Whether a condition of the set cannot be judged in `context`: it
|
|
26
|
+
# restricts the IP and the context has none, or it requires a session
|
|
27
|
+
# flag and the context says nothing about flags (no :session_flags at
|
|
28
|
+
# all — an empty list is an answer). The hours are always judged (the
|
|
29
|
+
# time defaults to now). Under config.conditional_sets_strict_mode a
|
|
30
|
+
# conditional muting set applies then (ConditionalSets#passing).
|
|
31
|
+
#
|
|
32
|
+
# @return [Boolean]
|
|
33
|
+
def self.unjudgeable?(permission_set:, context:)
|
|
34
|
+
context ||= {}
|
|
35
|
+
(permission_set.allowed_ips.present? && context[:ip].blank?) ||
|
|
36
|
+
(permission_set.requires_session_flag.present? && context[:session_flags].nil?)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(permission_set, context)
|
|
40
|
+
@permission_set = permission_set
|
|
41
|
+
@context = context || {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Boolean] whether every condition of the set passes in the context
|
|
45
|
+
def call
|
|
46
|
+
session_flag_passes? && ip_passes? && hours_pass?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Session flag: set only active when context includes the required flag.
|
|
52
|
+
def session_flag_passes?
|
|
53
|
+
flag = @permission_set.requires_session_flag
|
|
54
|
+
return true if flag.blank?
|
|
55
|
+
|
|
56
|
+
Array(@context[:session_flags]).include?(flag)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# IP restriction: set only active when request IP matches allowed CIDRs.
|
|
60
|
+
# The IP may be a String or an IPAddr; anything that is not an address
|
|
61
|
+
# fails the condition.
|
|
62
|
+
def ip_passes?
|
|
63
|
+
allowed = @permission_set.allowed_ips
|
|
64
|
+
return true if allowed.blank?
|
|
65
|
+
|
|
66
|
+
request_ip = @context[:ip]
|
|
67
|
+
return false if request_ip.blank?
|
|
68
|
+
|
|
69
|
+
address = request_ip.is_a?(IPAddr) ? request_ip : IPAddr.new(request_ip.to_s)
|
|
70
|
+
parse_ip_ranges(allowed).any? { |range| range.include?(address) }
|
|
71
|
+
rescue IPAddr::Error
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Login hours: set only active during allowed hour window.
|
|
76
|
+
def hours_pass?
|
|
77
|
+
start_hour = @permission_set.allowed_hours_start
|
|
78
|
+
end_hour = @permission_set.allowed_hours_end
|
|
79
|
+
return true if start_hour.nil? || end_hour.nil?
|
|
80
|
+
|
|
81
|
+
hour_in_range?((@context[:time] || Time.current).hour, start_hour, end_hour)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def hour_in_range?(hour, start_hour, end_hour)
|
|
85
|
+
if start_hour <= end_hour
|
|
86
|
+
hour >= start_hour && hour < end_hour
|
|
87
|
+
else
|
|
88
|
+
# Wraps midnight (e.g., 22-06 means 22:00 to 05:59)
|
|
89
|
+
hour >= start_hour || hour < end_hour
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def parse_ip_ranges(allowed_ips)
|
|
94
|
+
allowed_ips.split(',').filter_map do |cidr|
|
|
95
|
+
IPAddr.new(cidr.strip)
|
|
96
|
+
rescue IPAddr::Error
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|