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,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
# Operator -> SQL strategy registry for RLS policy generation, plus the
|
|
6
|
+
# value-quoting helpers the strategies call back into. Extracted from
|
|
7
|
+
# {RlsPolicyGenerator} for SRP/OCP — adding an operator translation is a
|
|
8
|
+
# new registry entry, not a generator change.
|
|
9
|
+
#
|
|
10
|
+
# Each strategy receives (col, value, generator) and returns a SQL boolean
|
|
11
|
+
# expression that selects the rows the app layer's strategy selects
|
|
12
|
+
# (Operators::Registry). The value is the rule's, typed for the column by
|
|
13
|
+
# the same Operators::ValueTyping — a literal, a list of them, or
|
|
14
|
+
# {SessionUser} where `current_user` stands for the session variable. LIKE
|
|
15
|
+
# is ILIKE on the column itself, as Arel writes it for PostgreSQL, with the
|
|
16
|
+
# app layer's escaping. Where the app layer's query raises, the policy
|
|
17
|
+
# shows no row: it is never the wider of the two.
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
20
|
+
module RlsOperatorSql
|
|
21
|
+
# `current_user` in a policy: the SQL the session user's id is read with.
|
|
22
|
+
SessionUser = Struct.new(:sql)
|
|
23
|
+
|
|
24
|
+
UNUSABLE = Operators::ValueTyping::UNUSABLE
|
|
25
|
+
|
|
26
|
+
OPERATOR_SQL = {
|
|
27
|
+
Operators::Constants::EQUAL => ->(col, value, g) { "#{col} = #{g.value_sql(value)}" },
|
|
28
|
+
Operators::Constants::NOT_EQUAL => ->(col, value, g) { "#{col} <> #{g.value_sql(value)}" },
|
|
29
|
+
Operators::Constants::LESS_THAN => ->(col, value, g) { "#{col} < #{g.value_sql(value)}" },
|
|
30
|
+
Operators::Constants::GREATER_THAN => ->(col, value, g) { "#{col} > #{g.value_sql(value)}" },
|
|
31
|
+
Operators::Constants::LESS_THAN_OR_EQUAL => ->(col, value, g) { "#{col} <= #{g.value_sql(value)}" },
|
|
32
|
+
Operators::Constants::GREATER_THAN_OR_EQUAL => ->(col, value, g) { "#{col} >= #{g.value_sql(value)}" },
|
|
33
|
+
Operators::Constants::IN => ->(col, values, g) { "#{col} IN (#{g.list_sql(values)})" },
|
|
34
|
+
Operators::Constants::NOT_IN => ->(col, values, g) { "#{col} NOT IN (#{g.list_sql(values)})" },
|
|
35
|
+
Operators::Constants::CONTAINS => ->(col, value, g) { g.like_sql(col, value, prefix: '%', suffix: '%') },
|
|
36
|
+
Operators::Constants::NOT_CONTAINS => lambda { |col, value, g|
|
|
37
|
+
"NOT #{g.like_sql(col, value, prefix: '%', suffix: '%')}"
|
|
38
|
+
},
|
|
39
|
+
Operators::Constants::STARTS_WITH => ->(col, value, g) { g.like_sql(col, value, prefix: '', suffix: '%') },
|
|
40
|
+
Operators::Constants::ENDS_WITH => ->(col, value, g) { g.like_sql(col, value, prefix: '%', suffix: '') },
|
|
41
|
+
Operators::Constants::BETWEEN => ->(col, bounds, g) { "#{col} BETWEEN #{g.list_sql(bounds, ' AND ')}" },
|
|
42
|
+
Operators::Constants::IS_NULL => ->(col, _value, _g) { "#{col} IS NULL" },
|
|
43
|
+
Operators::Constants::IS_NOT_NULL => ->(col, _value, _g) { "#{col} IS NOT NULL" }
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# The operators whose app-layer strategy adds the NULL rows under
|
|
47
|
+
# include_nulls. The others ignore the flag, and so does the policy.
|
|
48
|
+
NULL_INCLUDING = [
|
|
49
|
+
Operators::Constants::EQUAL, Operators::Constants::NOT_EQUAL,
|
|
50
|
+
Operators::Constants::IN, Operators::Constants::NOT_IN
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
# The column types PostgreSQL has ILIKE for. On a column of any other
|
|
54
|
+
# type the app layer's LIKE query raises, so the rule compiles to FALSE.
|
|
55
|
+
LIKE_TYPES = %i[string text citext].freeze
|
|
56
|
+
|
|
57
|
+
# How the session user's id is read for a comparison, by the caster the
|
|
58
|
+
# column's type has in the app layer: the id itself where the column is
|
|
59
|
+
# a number or of a type nothing is known about, its text on a string
|
|
60
|
+
# column. A column of any other type (a boolean, a date) cannot equal an
|
|
61
|
+
# id — the rule is UNUSABLE there, as it is in the app layer.
|
|
62
|
+
SESSION_USER_SQL = {
|
|
63
|
+
Operators::ValueCasters::UNTYPED => :uid_sql,
|
|
64
|
+
Operators::ValueCasters::NUMBER => :numeric_uid_sql,
|
|
65
|
+
Operators::ValueCasters::Number::SINGLE_PRECISION => :numeric_uid_sql,
|
|
66
|
+
Operators::ValueCasters::TEXT => :uid_text_sql
|
|
67
|
+
}.freeze
|
|
68
|
+
|
|
69
|
+
# A rule's value as the app layer compares it with the column.
|
|
70
|
+
#
|
|
71
|
+
# @param target [Operators::RuleColumn] the column the rule names
|
|
72
|
+
# @return [Object] a typed literal, an Array of them, a SessionUser, or UNUSABLE
|
|
73
|
+
def typed_value(operator, value, target)
|
|
74
|
+
return UNUSABLE if target.malformed? || unmatchable?(operator, target.column)
|
|
75
|
+
return session_user(operator, target) if Operators::ValueTyping.current_user?(operator, value)
|
|
76
|
+
|
|
77
|
+
Operators::ValueTyping.call(operator, value, target)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def value_sql(value)
|
|
81
|
+
case value
|
|
82
|
+
when SessionUser then value.sql
|
|
83
|
+
else connection.quote(value)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def list_sql(values, separator = ', ')
|
|
88
|
+
values.map { |value| connection.quote(value) }.join(separator)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def like_sql(col, value, prefix:, suffix:)
|
|
92
|
+
pattern = if value.is_a?(SessionUser)
|
|
93
|
+
[connection.quote(prefix), value.sql, connection.quote(suffix)].join(' || ')
|
|
94
|
+
else
|
|
95
|
+
connection.quote("#{prefix}#{Operators::Registry.sanitize_like(value)}#{suffix}")
|
|
96
|
+
end
|
|
97
|
+
"#{col} ILIKE #{pattern} ESCAPE #{connection.quote(Operators::Registry::LIKE_ESCAPE)}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# A LIKE operator on a column PostgreSQL cannot match a pattern with. (A
|
|
103
|
+
# column the model does not have fails the sync, as it fails the query.)
|
|
104
|
+
def unmatchable?(operator, column)
|
|
105
|
+
return false unless column && Operators::ValueTyping::SHAPES[operator] == :pattern
|
|
106
|
+
|
|
107
|
+
column.try(:array?) || LIKE_TYPES.exclude?(column.type)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A comparison takes the id as the column's type lets it. A LIKE pattern
|
|
111
|
+
# is built from the id's text whatever the column (and a null check
|
|
112
|
+
# takes no value at all).
|
|
113
|
+
def session_user(operator, target)
|
|
114
|
+
single = Operators::ValueTyping::SHAPES[operator] == :single
|
|
115
|
+
reader = single ? SESSION_USER_SQL[target.caster] : :uid_text_sql
|
|
116
|
+
sql = send(reader) if reader
|
|
117
|
+
sql ? SessionUser.new(sql) : UNUSABLE
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def uid_text_sql
|
|
121
|
+
"(#{uid_sql})::text"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# An id that is not a number (a uuid) is never the value of a number column.
|
|
125
|
+
def numeric_uid_sql
|
|
126
|
+
column = PermissionForge::PermissionSetUser.columns_hash['user_id']
|
|
127
|
+
uid_sql if column.nil? || Operators::ValueCasters.for(column).equal?(Operators::ValueCasters::NUMBER)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module PermissionForge
|
|
6
|
+
module Services
|
|
7
|
+
# Level 3 — Postgres Row-Level Security policy generation.
|
|
8
|
+
#
|
|
9
|
+
# Builds the DDL for one protected model: enable/force RLS plus four
|
|
10
|
+
# policies (SELECT/UPDATE/DELETE share a USING expression; INSERT is
|
|
11
|
+
# WITH CHECK (true) because app-level rules never gate creation).
|
|
12
|
+
#
|
|
13
|
+
# The USING expression is a hybrid of static and dynamic SQL:
|
|
14
|
+
# - MEMBERSHIP is dynamic — EXISTS subqueries against
|
|
15
|
+
# permission_set_users / permission_sets evaluated per statement, so
|
|
16
|
+
# assigning or revoking a user NEVER requires a policy resync.
|
|
17
|
+
# - RULE CONTENT is static — each granting set's active FILTER-type rules
|
|
18
|
+
# are compiled to SQL at generation time as
|
|
19
|
+
# (NOT member(set) OR (rule1 AND rule2 ...)) conjuncts. Editing rule
|
|
20
|
+
# rows requires `rake permission_forge:rls:sync` (drift is detected via
|
|
21
|
+
# a digest stored as the SELECT policy's comment).
|
|
22
|
+
#
|
|
23
|
+
# Semantics parity with the application layer:
|
|
24
|
+
# - view_all/modify_all in a granting set bypasses rules; a muting set
|
|
25
|
+
# with view_all/modify_all cancels that bypass (both checked
|
|
26
|
+
# dynamically). Only a resource row in effect counts (r.is_active,
|
|
27
|
+
# ps.is_active), and with tenancy on only one of the row's tenant: a
|
|
28
|
+
# view_all grant in tenant A never bypasses the rules on tenant B's
|
|
29
|
+
# rows, nor on rows of no tenant (narrower than an ambient-tenant app
|
|
30
|
+
# build there, never wider).
|
|
31
|
+
# - A granting set's rules are compiled whatever the set's and its
|
|
32
|
+
# resource row's is_active; the membership EXISTS reads both states
|
|
33
|
+
# per statement, so switching a set or a resource off or on needs no
|
|
34
|
+
# re-sync and the policy is never wider than the app layer in between.
|
|
35
|
+
# - A user in no rule-bearing set sees all rows (rules only restrict).
|
|
36
|
+
# - Muting sets contribute no rules; conditional sets' rules are included
|
|
37
|
+
# (more restrictive than the app, never less — documented).
|
|
38
|
+
# - Missing session variable follows nil_user_strategy: :bypass allows,
|
|
39
|
+
# :deny/:raise hides every row.
|
|
40
|
+
# - A rule's value is typed for its column by the app layer's own
|
|
41
|
+
# Operators::ValueTyping; a value the column cannot hold compiles to
|
|
42
|
+
# FALSE, as the app layer matches nothing for it (see RlsOperatorSql).
|
|
43
|
+
# - Join-type rules and dotted field names are NOT translatable — they are
|
|
44
|
+
# reported in :skipped_rules, never silently dropped. So are the rules
|
|
45
|
+
# of a Resource named after an STI subclass of the model: a policy is
|
|
46
|
+
# per table and compiled for the class that includes the concern, so
|
|
47
|
+
# those rules (which the app layer applies to the subclass, see
|
|
48
|
+
# ResourceChain) hold at the application layer only.
|
|
49
|
+
#
|
|
50
|
+
# This service only BUILDS SQL strings (testable on any adapter);
|
|
51
|
+
# PermissionForge::RLS executes them against Postgres.
|
|
52
|
+
#
|
|
53
|
+
# @api private
|
|
54
|
+
class RlsPolicyGenerator
|
|
55
|
+
POLICY_NAMES = {
|
|
56
|
+
select: 'permission_forge_select',
|
|
57
|
+
insert: 'permission_forge_insert',
|
|
58
|
+
update: 'permission_forge_update',
|
|
59
|
+
delete: 'permission_forge_delete'
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
DIGEST_COMMENT_PREFIX = 'permission_forge digest: '
|
|
63
|
+
|
|
64
|
+
# Bumped whenever the generated SQL *shape* changes (not just rule data),
|
|
65
|
+
# so existing installs get a drift warning and re-sync after upgrading.
|
|
66
|
+
# 3: what a rule compiles to follows the app layer — typed values, ILIKE,
|
|
67
|
+
# include_nulls only for the operators that honour it there.
|
|
68
|
+
# 4: the view_all bypass needs the resource row to be active and, with
|
|
69
|
+
# tenancy on, in the row's tenant.
|
|
70
|
+
# 5: a rule set's membership EXISTS joins its resource row and needs it
|
|
71
|
+
# active; the rules are compiled whatever the row's — or the set's —
|
|
72
|
+
# state (the set's is_active was already read live).
|
|
73
|
+
GENERATOR_VERSION = 5
|
|
74
|
+
|
|
75
|
+
include RlsOperatorSql
|
|
76
|
+
|
|
77
|
+
def self.call(model_class:, configuration: PermissionForge.configuration)
|
|
78
|
+
new(model_class: model_class, configuration: configuration).call
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def initialize(model_class:, configuration: PermissionForge.configuration)
|
|
82
|
+
@model_class = model_class
|
|
83
|
+
@configuration = configuration
|
|
84
|
+
@conn = model_class.connection
|
|
85
|
+
@skipped_rules = []
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [Hash] { table:, statements: Array<String>, digest:, skipped_rules: }
|
|
89
|
+
def call
|
|
90
|
+
note_subclass_rules
|
|
91
|
+
statements = [
|
|
92
|
+
"ALTER TABLE #{quoted_table} ENABLE ROW LEVEL SECURITY",
|
|
93
|
+
"ALTER TABLE #{quoted_table} FORCE ROW LEVEL SECURITY",
|
|
94
|
+
*POLICY_NAMES.values.map { |name| "DROP POLICY IF EXISTS #{name} ON #{quoted_table}" },
|
|
95
|
+
*policy_statements,
|
|
96
|
+
comment_statement
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
{ table: table_name, statements: statements, digest: digest, skipped_rules: @skipped_rules }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Canonical fingerprint of everything static in the policy. Stored as the
|
|
103
|
+
# SELECT policy's comment; a mismatch at boot means rules changed since
|
|
104
|
+
# the last sync.
|
|
105
|
+
def digest
|
|
106
|
+
parts = [
|
|
107
|
+
GENERATOR_VERSION,
|
|
108
|
+
@configuration.nil_user_strategy,
|
|
109
|
+
@configuration.rls_session_variable,
|
|
110
|
+
table_name,
|
|
111
|
+
tenant_predicate_sql,
|
|
112
|
+
rule_sets.map { |set| [set[:id], set[:resource_id], set[:rules].map { |r| rule_fingerprint(r) }.sort] }.sort
|
|
113
|
+
]
|
|
114
|
+
Digest::SHA256.hexdigest(parts.inspect)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# Adapter connection used for quoting by RlsOperatorSql helpers too.
|
|
120
|
+
def connection
|
|
121
|
+
@conn
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def table_name
|
|
125
|
+
@model_class.table_name
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def quoted_table
|
|
129
|
+
@conn.quote_table_name(table_name)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def resource_name
|
|
133
|
+
@model_class.name
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def policy_statements
|
|
137
|
+
using = using_expression
|
|
138
|
+
[
|
|
139
|
+
"CREATE POLICY #{POLICY_NAMES[:select]} ON #{quoted_table} FOR SELECT USING (#{using})",
|
|
140
|
+
"CREATE POLICY #{POLICY_NAMES[:insert]} ON #{quoted_table} FOR INSERT WITH CHECK (true)",
|
|
141
|
+
"CREATE POLICY #{POLICY_NAMES[:update]} ON #{quoted_table} FOR UPDATE USING (#{using}) WITH CHECK (true)",
|
|
142
|
+
"CREATE POLICY #{POLICY_NAMES[:delete]} ON #{quoted_table} FOR DELETE USING (#{using})"
|
|
143
|
+
]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def comment_statement
|
|
147
|
+
"COMMENT ON POLICY #{POLICY_NAMES[:select]} ON #{quoted_table} IS " \
|
|
148
|
+
"#{@conn.quote("#{DIGEST_COMMENT_PREFIX}#{digest}")}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# :bypass -> uid IS NULL OR (view_all OR rules)
|
|
152
|
+
# :deny/:raise -> uid IS NOT NULL AND (view_all OR rules)
|
|
153
|
+
# The explicit uid guard matters: with a NULL uid every membership EXISTS
|
|
154
|
+
# is false, which would make the rules conjunction vacuously TRUE — the
|
|
155
|
+
# deny strategies must therefore hide rows before that branch is reached.
|
|
156
|
+
def using_expression
|
|
157
|
+
guarded = "(#{view_all_bypass_sql} OR #{rules_conjunction_sql})"
|
|
158
|
+
if @configuration.nil_user_strategy == :bypass
|
|
159
|
+
"((#{uid_sql}) IS NULL OR #{guarded})"
|
|
160
|
+
else
|
|
161
|
+
"((#{uid_sql}) IS NOT NULL AND #{guarded})"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# current_setting(var, true) returns NULL when unset but '' after RESET,
|
|
166
|
+
# hence the NULLIF. Cast to the real user_id column type so uuid or
|
|
167
|
+
# bigint PKs both work.
|
|
168
|
+
def uid_sql
|
|
169
|
+
var = @conn.quote(@configuration.rls_session_variable)
|
|
170
|
+
"NULLIF(current_setting(#{var}, true), '')::#{user_id_cast_type}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def user_id_cast_type
|
|
174
|
+
PermissionForge::PermissionSetUser.columns_hash['user_id']&.sql_type || 'bigint'
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Granting-set view_all/modify_all bypasses rules; a muting set with
|
|
178
|
+
# view_all/modify_all cancels the bypass. Both checked dynamically.
|
|
179
|
+
#
|
|
180
|
+
# GDPR/consent guard: activation conditions (session flag / IP / login
|
|
181
|
+
# hours) cannot be evaluated inside a database policy, so only
|
|
182
|
+
# UNCONDITIONAL sets may grant the bypass — a consent-gated set granting
|
|
183
|
+
# view_all must never widen DB-level access while its condition is
|
|
184
|
+
# unmet. Muting sets keep canceling regardless (restrictive direction is
|
|
185
|
+
# always safe).
|
|
186
|
+
def view_all_bypass_sql
|
|
187
|
+
"(#{view_all_exists_sql(muting: false)} AND NOT #{view_all_exists_sql(muting: true)})"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def view_all_exists_sql(muting:)
|
|
191
|
+
<<~SQL.squish
|
|
192
|
+
EXISTS (
|
|
193
|
+
SELECT 1 FROM #{psu_table} psu
|
|
194
|
+
JOIN #{ps_table} ps ON ps.id = psu.permission_set_id
|
|
195
|
+
JOIN #{resources_table} r ON r.permission_set_id = ps.id
|
|
196
|
+
WHERE psu.user_id = (#{uid_sql})
|
|
197
|
+
AND #{assignment_active_sql}
|
|
198
|
+
AND ps.is_active = TRUE AND ps.is_muting = #{muting ? 'TRUE' : 'FALSE'}
|
|
199
|
+
#{"AND #{unconditional_set_sql}" unless muting}
|
|
200
|
+
AND r.name = #{@conn.quote(resource_name)}
|
|
201
|
+
AND r.is_active = TRUE
|
|
202
|
+
#{tenant_predicate_sql}
|
|
203
|
+
AND (r.view_all = TRUE OR r.modify_all = TRUE)
|
|
204
|
+
)
|
|
205
|
+
SQL
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Ties the resource row to the protected row's tenant, when tenancy is
|
|
209
|
+
# on and both tables have the tenant column: `AND r.account_id =
|
|
210
|
+
# "customers"."account_id"`. A NULL tenant on either side matches
|
|
211
|
+
# nothing. Empty otherwise (no tenant to compare).
|
|
212
|
+
def tenant_predicate_sql
|
|
213
|
+
column = @configuration.tenant_enabled? && @configuration.resolved_tenant_column.to_s
|
|
214
|
+
return '' unless column && [@model_class, PermissionForge::Resource].all? { |klass| klass.column_names.include?(column) }
|
|
215
|
+
|
|
216
|
+
quoted = @conn.quote_column_name(column)
|
|
217
|
+
"AND r.#{quoted} = #{quoted_table}.#{quoted}"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Mirrors PermissionSet.unconditional (activation conditions absent).
|
|
221
|
+
def unconditional_set_sql
|
|
222
|
+
"(ps.requires_session_flag IS NULL OR ps.requires_session_flag = '') " \
|
|
223
|
+
"AND (ps.allowed_ips IS NULL OR ps.allowed_ips = '') " \
|
|
224
|
+
'AND ps.allowed_hours_start IS NULL'
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# One conjunct per rule-bearing granting set:
|
|
228
|
+
# (NOT member(set) OR (rule1 AND rule2 ...))
|
|
229
|
+
# A user in none of these sets passes every conjunct (rules only
|
|
230
|
+
# restrict); a member must satisfy that set's rules. TRUE when no set has
|
|
231
|
+
# translatable rules.
|
|
232
|
+
def rules_conjunction_sql
|
|
233
|
+
conjuncts = rule_sets.map do |set|
|
|
234
|
+
"(NOT #{membership_exists_sql(set[:id], set[:resource_id])} OR (#{set[:rules].map { |r| rule_sql(r) }.join(' AND ')}))"
|
|
235
|
+
end
|
|
236
|
+
return 'TRUE' if conjuncts.empty?
|
|
237
|
+
|
|
238
|
+
"(#{conjuncts.join(' AND ')})"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Member of the set, with the set and the resource row the rules belong
|
|
242
|
+
# to both active, as the app layer reads them: a switched-off resource
|
|
243
|
+
# holds nobody to its rules.
|
|
244
|
+
def membership_exists_sql(set_id, resource_id)
|
|
245
|
+
<<~SQL.squish
|
|
246
|
+
EXISTS (
|
|
247
|
+
SELECT 1 FROM #{psu_table} psu
|
|
248
|
+
JOIN #{ps_table} ps ON ps.id = psu.permission_set_id
|
|
249
|
+
JOIN #{resources_table} r ON r.id = #{Integer(resource_id)} AND r.is_active = TRUE
|
|
250
|
+
WHERE psu.user_id = (#{uid_sql})
|
|
251
|
+
AND psu.permission_set_id = #{Integer(set_id)}
|
|
252
|
+
AND ps.is_active = TRUE
|
|
253
|
+
AND #{assignment_active_sql}
|
|
254
|
+
)
|
|
255
|
+
SQL
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def assignment_active_sql
|
|
259
|
+
clauses = ['(psu.expires_at IS NULL OR psu.expires_at > now())']
|
|
260
|
+
clauses << 'psu.is_active = TRUE' if PermissionForge::PermissionSetUser.column_names.include?('is_active')
|
|
261
|
+
clauses.join(' AND ')
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def psu_table
|
|
265
|
+
@conn.quote_table_name(PermissionForge::PermissionSetUser.table_name)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def ps_table
|
|
269
|
+
@conn.quote_table_name(PermissionForge::PermissionSet.table_name)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def resources_table
|
|
273
|
+
@conn.quote_table_name(PermissionForge::Resource.table_name)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# A value the column cannot hold makes the rule FALSE — the app layer
|
|
277
|
+
# matches nothing for it, NULL rows included.
|
|
278
|
+
def rule_sql(rule)
|
|
279
|
+
operator, field_name = rule.values_at(:operator, :field_name)
|
|
280
|
+
value = typed_value(operator, rule.field_value, Operators::RuleColumn.find(field_name, [@model_class]))
|
|
281
|
+
return 'FALSE' if value.equal?(UNUSABLE)
|
|
282
|
+
|
|
283
|
+
col = "#{quoted_table}.#{@conn.quote_column_name(field_name)}"
|
|
284
|
+
base = OPERATOR_SQL.fetch(operator).call(col, value, self)
|
|
285
|
+
rule.include_nulls? && NULL_INCLUDING.include?(operator) ? "((#{base}) OR #{col} IS NULL)" : "(#{base})"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Granting sets with active, translatable FILTER-type rules on this
|
|
289
|
+
# resource, whatever the set's or the resource row's own is_active (the
|
|
290
|
+
# membership EXISTS reads both per statement: a set switched off at
|
|
291
|
+
# sync time and on later must not be wider than the app layer until the
|
|
292
|
+
# next re-sync). Join-type rules and dotted field names go to
|
|
293
|
+
# :skipped_rules — bounded coverage is reported, never silent.
|
|
294
|
+
def rule_sets
|
|
295
|
+
@rule_sets ||= PermissionForge::Resource.where(name: resource_name).filter_map do |resource|
|
|
296
|
+
permission_set = resource.permission_set
|
|
297
|
+
next unless permission_set && !permission_set.is_muting
|
|
298
|
+
|
|
299
|
+
rules = partition_rules(resource, permission_set)
|
|
300
|
+
next if rules.empty?
|
|
301
|
+
|
|
302
|
+
{ id: permission_set.id, resource_id: resource.id, rules: rules }
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def partition_rules(resource, permission_set)
|
|
307
|
+
resource.permission_set_rules.active.filter_map do |rule|
|
|
308
|
+
reason = untranslatable_reason(rule)
|
|
309
|
+
if reason
|
|
310
|
+
@skipped_rules << { permission_set: permission_set.name, rule: rule.name, reason: reason }
|
|
311
|
+
next
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
rule
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# The active rules of resources named after the model's loaded STI
|
|
319
|
+
# subclasses, in effect in a granting set: not compiled (see the class
|
|
320
|
+
# comment), reported.
|
|
321
|
+
def note_subclass_rules
|
|
322
|
+
names = subclass_names
|
|
323
|
+
return if names.empty?
|
|
324
|
+
|
|
325
|
+
subclass_rules(names).each do |rule|
|
|
326
|
+
@skipped_rules << { permission_set: rule.permission_set.name, rule: rule.name, reason: :sti_subclass_resource }
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# The loaded descendants on this table (a subclass with a table of its
|
|
331
|
+
# own is another policy's business), minus one that answers to one of
|
|
332
|
+
# the model's own names (a test double, a renamed subclass).
|
|
333
|
+
def subclass_names
|
|
334
|
+
subclasses = @model_class.descendants.select { |klass| klass.table_name == table_name }
|
|
335
|
+
subclasses.filter_map(&:name).uniq - ResourceChain.names(@model_class).map(&:to_s)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# The active rules of the resource rows in effect (Resource.in_effect),
|
|
339
|
+
# in granting sets, named after the model's loaded STI subclasses.
|
|
340
|
+
def subclass_rules(names)
|
|
341
|
+
resources = PermissionForge::Resource.in_effect.where(name: names)
|
|
342
|
+
.where(PermissionForge::PermissionSet.table_name => { is_muting: false })
|
|
343
|
+
PermissionForge::PermissionSetRule.active.where(resource_id: resources.select(:id)).includes(:permission_set)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# A field_name that is not a name is translated, whatever the rule is:
|
|
347
|
+
# to FALSE, as the app layer matches nothing for it.
|
|
348
|
+
def untranslatable_reason(rule)
|
|
349
|
+
return unless Operators::RuleColumn.name?(rule.field_name)
|
|
350
|
+
return :join_rule if rule.join_rule?
|
|
351
|
+
return :dotted_field_name if rule.field_name.to_s.include?('.')
|
|
352
|
+
return :unknown_operator unless OPERATOR_SQL.key?(rule.operator)
|
|
353
|
+
|
|
354
|
+
nil
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# The compiled SQL is part of it: what a rule compiles to also depends on
|
|
358
|
+
# the model (the column's type, an enum's stored values). All text, so
|
|
359
|
+
# the fingerprints of one set sort whatever the rules are.
|
|
360
|
+
def rule_fingerprint(rule)
|
|
361
|
+
rule.values_at(:operator, :field_name, :field_value, :include_nulls).map(&:to_s) << rule_sql(rule)
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
module SecurityAuditChecks
|
|
6
|
+
# Controller actions as permissions: managed actions nobody is permitted
|
|
7
|
+
# to run, managed actions whose controller does not run the guard, rows
|
|
8
|
+
# whose name can never be an action key, rows that match no controller
|
|
9
|
+
# action, and discovery switched on while no controller includes the guard.
|
|
10
|
+
class ActionCheck < BaseCheck
|
|
11
|
+
# One action row in effect (PermissionForge::ResourceAction.in_effect).
|
|
12
|
+
LiveRow = Struct.new(:tenant, :resource, :action, :permitted, :muting, :set) do
|
|
13
|
+
def target = [tenant, resource, action]
|
|
14
|
+
def key = [resource, action]
|
|
15
|
+
def label = "#{resource} › #{action}"
|
|
16
|
+
def grants? = permitted && !muting
|
|
17
|
+
def valid_key? = action.match?(PermissionForge::ControllerActions::KEY_FORMAT)
|
|
18
|
+
|
|
19
|
+
# 'Sales › Customer › "archive "' — quoted, so stray spaces show.
|
|
20
|
+
def quoted_label = "#{set} › #{resource} › #{action.inspect}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param catalog [PermissionForge::Admin::ControllerCatalog, nil] the
|
|
24
|
+
# app's controller actions; read from the routes on first use when nil
|
|
25
|
+
def initialize(configuration:, environment:, catalog: nil)
|
|
26
|
+
super(configuration: configuration, environment: environment)
|
|
27
|
+
@catalog = catalog
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call
|
|
31
|
+
[ungranted_actions, unguarded_actions, invalid_keys, unlinked_actions, guard_missing].compact
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def discovery?
|
|
37
|
+
configuration.controller_action_discovery?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def catalog
|
|
41
|
+
@catalog ||= PermissionForge::Admin::ControllerCatalog.new(configuration: configuration)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# { 'Customer' => { 'export' => [ControllerCatalog::Entry, ...] } }
|
|
45
|
+
def endpoints
|
|
46
|
+
@endpoints ||= Hash.new { |index, resource| index[resource] = catalog.for_resource(resource).group_by(&:key) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Managed, and reachable through a controller that does not run the
|
|
50
|
+
# guard: there the row restricts nobody unless the code checks can?.
|
|
51
|
+
# Covers a partial rollout, which guard_missing (no guard anywhere)
|
|
52
|
+
# does not see.
|
|
53
|
+
def unguarded_actions
|
|
54
|
+
return unless discovery?
|
|
55
|
+
|
|
56
|
+
items = live_rows.map(&:key).uniq.sort.filter_map { |resource, action| unguarded_item(resource, action) }
|
|
57
|
+
return if items.empty?
|
|
58
|
+
|
|
59
|
+
finding(:medium, :unguarded_actions, 'Managed actions on controllers that do not run the guard',
|
|
60
|
+
'A Permission Set manages these actions, but the listed controller actions run in a controller ' \
|
|
61
|
+
'that does not include PermissionForge::Concerns::ControllerActionGuard (or skips it for every ' \
|
|
62
|
+
'action). Through those routes everyone can still run the action, whatever the admin shows, ' \
|
|
63
|
+
'unless your code checks can?. Include the guard in those controllers.', items: items)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# "Customer › merge: customers/merges#create", or nil when every
|
|
67
|
+
# controller action behind the key runs the guard (or none maps to it).
|
|
68
|
+
def unguarded_item(resource, action)
|
|
69
|
+
unguarded = endpoints[resource].fetch(action, []).reject(&:guarded?).map(&:endpoint).join(', ')
|
|
70
|
+
"#{resource} › #{action}: #{unguarded}" unless unguarded.empty?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Managed (a row is in effect) but permitted by no granting set: the
|
|
74
|
+
# guard and can? refuse everyone.
|
|
75
|
+
def ungranted_actions
|
|
76
|
+
ungranted = live_rows.group_by(&:target).values.reject { |rows| rows.any?(&:grants?) }
|
|
77
|
+
return if ungranted.empty?
|
|
78
|
+
|
|
79
|
+
finding(:low, :ungranted_actions, 'Managed actions nobody is permitted to run',
|
|
80
|
+
'A row for these actions is in effect, so only people whose Permission Sets permit them can ' \
|
|
81
|
+
'run them — and no active granting set does. Permit each one in a set, or remove its rows to ' \
|
|
82
|
+
'stop managing it.', items: ungranted.map { |rows| ungranted_item(rows) }.uniq.sort)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def ungranted_item(rows)
|
|
86
|
+
"#{rows.first.label}: only in #{rows.map(&:set).uniq.sort.join(', ')}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Rows in effect under a name that is not an action key. The admin and
|
|
90
|
+
# the JSON API refuse such names; older rows, seeds and other apps
|
|
91
|
+
# writing to these tables may still carry them. The guard only matches
|
|
92
|
+
# one when a controller action is literally called that.
|
|
93
|
+
def invalid_keys
|
|
94
|
+
invalid = live_rows.reject(&:valid_key?)
|
|
95
|
+
return if invalid.empty?
|
|
96
|
+
|
|
97
|
+
finding(:low, :invalid_action_keys, 'Action rows whose name is not a valid action key',
|
|
98
|
+
'Action keys are lowercase letters, digits and underscores, starting with a letter. A row ' \
|
|
99
|
+
'under any other name is not the key of the action it looks like ("Export" does not cover ' \
|
|
100
|
+
'"export"), so that action stays unmanaged while the admin shows a row for it. The row only ' \
|
|
101
|
+
'takes effect where your code checks can? with exactly this name, or where a controller ' \
|
|
102
|
+
'action is literally called that. Rename each row to the key of the action it is for.',
|
|
103
|
+
items: invalid.map(&:quoted_label).uniq.sort)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def unlinked_actions
|
|
107
|
+
return unless discovery?
|
|
108
|
+
|
|
109
|
+
unlinked = configured_actions.reject { |resource, action| endpoints[resource].key?(action) }
|
|
110
|
+
return if unlinked.empty?
|
|
111
|
+
|
|
112
|
+
finding(:info, :unlinked_actions, 'Actions not linked to a controller action',
|
|
113
|
+
'No controller action maps to these keys. That is expected for an action your code checks ' \
|
|
114
|
+
'with can?. Otherwise the key is misspelled or its route is gone, and the row does nothing.',
|
|
115
|
+
items: unlinked.map { |resource, action| "#{resource} › #{action}" })
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def guard_missing
|
|
119
|
+
return unless discovery? && !catalog.any_guarded?
|
|
120
|
+
|
|
121
|
+
finding(live_rows.any? ? :medium : :low, :action_guard_missing,
|
|
122
|
+
'Controller actions are listed, but no controller checks them',
|
|
123
|
+
'controller_action_discovery is on, yet no listed controller includes ' \
|
|
124
|
+
'PermissionForge::Concerns::ControllerActionGuard. Permitting or blocking an action in the ' \
|
|
125
|
+
'admin changes nothing at runtime unless your code checks can?. Include the guard in the ' \
|
|
126
|
+
'controllers you want checked.')
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def live_rows
|
|
130
|
+
@live_rows ||= PermissionForge::ResourceAction.in_effect.pluck(*live_columns).map { |values| LiveRow.new(*values) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# "Managed" is per tenant, so rows of different tenants never cover each other.
|
|
134
|
+
def live_columns
|
|
135
|
+
[tenant_column, column(PermissionForge::Resource, :name), column(PermissionForge::ResourceAction, :name),
|
|
136
|
+
column(PermissionForge::ResourceAction, :is_permitted), column(PermissionForge::PermissionSet, :is_muting),
|
|
137
|
+
column(PermissionForge::PermissionSet, :name)]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def tenant_column
|
|
141
|
+
name = configuration.resolved_tenant_column
|
|
142
|
+
name ? column(PermissionForge::Resource, name) : Arel.sql('NULL')
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def column(model, name)
|
|
146
|
+
model.arel_table[name]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# [[resource name, action key], ...] for every action row, on or off.
|
|
150
|
+
def configured_actions
|
|
151
|
+
resource_name = column(PermissionForge::Resource, :name)
|
|
152
|
+
PermissionForge::ResourceAction.joins(:resource).distinct.order(resource_name, :name).pluck(resource_name, :name)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|