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,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
module SecurityAuditChecks
|
|
6
|
+
# Personal data shown unmasked, from GdprFieldAuditor. Fields an admin
|
|
7
|
+
# marked "This is intended" in the privacy audit are left out.
|
|
8
|
+
class PersonalDataCheck < BaseCheck
|
|
9
|
+
def call
|
|
10
|
+
report = GdprFieldAuditor.call(configuration: configuration)
|
|
11
|
+
[unmasked(report), implicit_virtual(report), cross_set(report), unmapped(report)].compact
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def unmasked(report)
|
|
17
|
+
rows = report.select { |row| row[:type] == :unmasked_field && acknowledged_ids.exclude?(row[:resource_field_id]) }
|
|
18
|
+
return if rows.empty?
|
|
19
|
+
|
|
20
|
+
finding(:medium, :unmasked_personal_data, 'Personal data readable unmasked',
|
|
21
|
+
'These fields look like personal data and are readable without a mask. Mask them, or mark ' \
|
|
22
|
+
'them "This is intended" in the admin privacy audit if that access is deliberate.',
|
|
23
|
+
items: rows.map do |row|
|
|
24
|
+
"#{row[:permission_set]} › #{row[:resource_name]}.#{row[:field_name]} (suggested: #{row[:suggested_strategy]})"
|
|
25
|
+
end)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Declared virtual fields left on the implicit grant: readable and
|
|
29
|
+
# editable, unmasked, without anyone having chosen that. A field whose
|
|
30
|
+
# only row is switched off is one of them.
|
|
31
|
+
def implicit_virtual(report)
|
|
32
|
+
rows = report.select { |row| row[:type] == :implicit_virtual_grant }
|
|
33
|
+
return if rows.empty?
|
|
34
|
+
|
|
35
|
+
finding(:medium, :implicit_virtual_personal_data, 'Personal data in virtual fields granted by default',
|
|
36
|
+
'These virtual fields look like personal data and have no active field row, so ' \
|
|
37
|
+
'extra_fields_default_access = :grant makes them readable and editable, unmasked. Give them a ' \
|
|
38
|
+
'level, or set extra_fields_default_access = :unconfigured so they follow unconfigured_field_strategy.',
|
|
39
|
+
items: rows.map do |row|
|
|
40
|
+
"#{row[:permission_set]} › #{row[:resource_name]}.#{row[:field_name]} (suggested: #{row[:suggested_strategy]})"
|
|
41
|
+
end)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def cross_set(report)
|
|
45
|
+
rows = report.select { |row| row[:type] == :cross_set_unmasking }
|
|
46
|
+
return if rows.empty?
|
|
47
|
+
|
|
48
|
+
finding(:medium, :cross_set_unmasking, 'One set unmasks what another masks',
|
|
49
|
+
'Masks combine with AND: anyone holding both sets sees the full value.',
|
|
50
|
+
items: rows.map { |row| "#{row[:permission_set]} › #{row[:resource_name]}.#{row[:field_name]}" })
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def unmapped(report)
|
|
54
|
+
rows = report.select { |row| row[:type] == :unmapped_column }
|
|
55
|
+
return if rows.empty?
|
|
56
|
+
|
|
57
|
+
passes = %w[pass_through warn].include?(configuration.unconfigured_field_strategy.to_s)
|
|
58
|
+
detail = if passes
|
|
59
|
+
'unconfigured_field_strategy lets them through unfiltered. Add a field row, or switch ' \
|
|
60
|
+
'the existing one back on.'
|
|
61
|
+
else
|
|
62
|
+
'They are blocked from filtered responses until configured; add a field row (or switch ' \
|
|
63
|
+
'the existing one back on) to decide.'
|
|
64
|
+
end
|
|
65
|
+
finding(passes ? :high : :low, :unmapped_personal_data, 'Personal-data columns with no active field row', detail,
|
|
66
|
+
items: rows.map { |row| "#{row[:resource]}.#{row[:column]}" })
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def acknowledged_ids
|
|
70
|
+
@acknowledged_ids ||= if PermissionForge::Admin::AttentionItems.acknowledgement_supported?
|
|
71
|
+
PermissionForge::ResourceField.where.not(sensitive_acknowledged_at: nil).pluck(:id).to_set
|
|
72
|
+
else
|
|
73
|
+
Set.new
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
module SecurityAuditChecks
|
|
6
|
+
# Privileged access: View All / Modify All, sets that look temporary,
|
|
7
|
+
# and sets that grant delete almost everywhere.
|
|
8
|
+
class PrivilegeCheck < BaseCheck
|
|
9
|
+
TEMPORARY_NAME = /\b(dev|debug|dbg|test|tmp|temp|sandbox|demo)\b|full access/i
|
|
10
|
+
BROAD_DELETE_MIN = 10
|
|
11
|
+
BROAD_DELETE_SHARE = 0.75
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
[bypass_grants, temporary_sets, broad_delete].compact
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def granting_resources
|
|
20
|
+
@granting_resources ||= PermissionForge::Resource.active.where(permission_set_id: active_granting_sets.select(:id))
|
|
21
|
+
.includes(:permission_set).order(:name).to_a
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def bypass_grants
|
|
25
|
+
bypassing = granting_resources.select(&:rules_bypassed?)
|
|
26
|
+
return if bypassing.empty?
|
|
27
|
+
|
|
28
|
+
held = bypassing.any? { |resource| people_counts.fetch(resource.permission_set_id, 0).positive? }
|
|
29
|
+
finding(held ? :medium : :low, :bypass_grants, 'View All / Modify All skip every record rule',
|
|
30
|
+
'People with these grants see (and with Modify All change) every record of the resource. ' \
|
|
31
|
+
'Keep them for administrators and give them an end date where possible.',
|
|
32
|
+
items: bypassing.map { |resource| bypass_item(resource) })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def bypass_item(resource)
|
|
36
|
+
grant = resource.modify_all ? 'View All + Modify All' : 'View All'
|
|
37
|
+
"#{resource.permission_set.name} › #{resource.name}: #{grant} · #{people(resource.permission_set_id)}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def temporary_sets
|
|
41
|
+
sets = PermissionForge::PermissionSet.active.order(:name).select { |set| set.name.match?(TEMPORARY_NAME) }
|
|
42
|
+
return if sets.empty?
|
|
43
|
+
|
|
44
|
+
held = sets.map(&:id).any? { |id| people_counts.key?(id) }
|
|
45
|
+
finding(held ? :medium : :low, :temporary_sets, 'Sets that look temporary are still active',
|
|
46
|
+
'Their names suggest development, debugging or testing. Confirm they belong in this ' \
|
|
47
|
+
'environment, or delete them.',
|
|
48
|
+
items: sets.map { |set| "#{set.name} · #{people(set.id)}" })
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def broad_delete
|
|
52
|
+
broad = granting_resources.group_by(&:permission_set).filter_map do |set, resources|
|
|
53
|
+
deletable = resources.count(&:delete_access)
|
|
54
|
+
next if deletable < BROAD_DELETE_MIN || deletable < resources.size * BROAD_DELETE_SHARE
|
|
55
|
+
|
|
56
|
+
"#{set.name}: delete on #{deletable} of #{resources.size} resources · #{people(set.id)}"
|
|
57
|
+
end
|
|
58
|
+
return if broad.empty?
|
|
59
|
+
|
|
60
|
+
finding(:low, :broad_delete, 'Sets grant delete almost everywhere',
|
|
61
|
+
'Delete is rarely needed broadly. If people other than administrators hold these sets, ' \
|
|
62
|
+
'move delete into a separate set.', items: broad)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
module SecurityAuditChecks
|
|
6
|
+
# Record (row-level) rules: whether the app's query scoping applies them,
|
|
7
|
+
# rules whose value makes them match nothing, rules View All makes
|
|
8
|
+
# pointless, and likely misconfigurations.
|
|
9
|
+
class RuleCheck < BaseCheck
|
|
10
|
+
TEXT_OPERATORS = %w[contains starts_with ends_with].freeze
|
|
11
|
+
# What `current_user` is typed as here: an id is a number or its text,
|
|
12
|
+
# whoever the user is, and never a boolean or a date.
|
|
13
|
+
ANY_USER_ID = 0
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
return [no_rules] unless active_rules.exists?
|
|
17
|
+
|
|
18
|
+
[unenforced, unusable_values, bypassed, text_operator_on_ids].compact
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def active_rules
|
|
24
|
+
PermissionForge::PermissionSetRule.active
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def no_rules
|
|
28
|
+
finding(:info, :no_rules, 'No record rules are in use',
|
|
29
|
+
'Anyone with read access to a resource sees every record of it. That is fine if your app ' \
|
|
30
|
+
'limits records itself; otherwise add rules and enable auto_query_scoping.')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def unenforced
|
|
34
|
+
findings = PermissionForge::Admin::AttentionItems.new(configuration: configuration).unenforced_rules
|
|
35
|
+
return if findings.empty?
|
|
36
|
+
|
|
37
|
+
held = findings.any? { |item| item.people_count.positive? }
|
|
38
|
+
finding(held ? :high : :medium, :unenforced_rules, 'Record rules are not applied to every query',
|
|
39
|
+
unenforced_detail, items: findings.map { |item| unenforced_item(item) })
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def unenforced_detail
|
|
43
|
+
if configuration.auto_query_scoping_enabled?
|
|
44
|
+
'These are join rules: auto_query_scoping skips them, so only find_by / permission_scoped apply ' \
|
|
45
|
+
'them. Route the queries through Model.permission_scoped.'
|
|
46
|
+
else
|
|
47
|
+
'auto_query_scoping is off, so these rules only apply to find_by / permission_scoped; a bare ' \
|
|
48
|
+
'where / all / association query returns every record. Enable auto_query_scoping = :filter_only ' \
|
|
49
|
+
'(see docs/query-scoping.md), and unscoped_query_detection to find the call sites.'
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def unenforced_item(item)
|
|
54
|
+
"#{item.permission_set.name} › #{item.resource.name}: #{item.rule_count} rule(s) · " \
|
|
55
|
+
"#{people_label(item.people_count)}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def unusable_values
|
|
59
|
+
rules = rules_in_effect.select { |rule| unusable_value?(rule) }
|
|
60
|
+
return if rules.empty?
|
|
61
|
+
|
|
62
|
+
held = rules.any? { |rule| people_counts.fetch(rule.resource.permission_set_id, 0).positive? }
|
|
63
|
+
finding(held ? :medium : :low, :unusable_rule_values, 'Rules that match nothing',
|
|
64
|
+
'The value of these rules is not one their column can hold: text on a number column, a word that ' \
|
|
65
|
+
'is no boolean, an unknown enum label, a date that is not ISO 8601 (on MySQL and SQLite), a list ' \
|
|
66
|
+
'or range with the wrong number of values — or the field name is not a name (a trailing or ' \
|
|
67
|
+
'doubled dot). Such a rule matches nothing: everyone it applies to is denied every record of ' \
|
|
68
|
+
'the resource. Correct the rule or remove it (see "How Values Are Compared" in ' \
|
|
69
|
+
'docs/row-level-rules.md).',
|
|
70
|
+
items: rules.map { |rule| unusable_item(rule) })
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Active rules of active resources in active granting sets: the rules
|
|
74
|
+
# that are applied right now (a muting set's rules never are). Not the
|
|
75
|
+
# ones View All skips — nobody is denied by those; they are listed
|
|
76
|
+
# under bypassed_rules.
|
|
77
|
+
def rules_in_effect
|
|
78
|
+
resources = PermissionForge::Resource.in_effect.merge(PermissionForge::PermissionSet.granting)
|
|
79
|
+
active_rules.where(resource_id: resources.select(:id)).includes(resource: :permission_set).order(:resource_id, :id)
|
|
80
|
+
.reject { |rule| rule.resource.rules_bypassed? }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Typed the way the application layer types it (Operators::ValueTyping).
|
|
84
|
+
# A model that is not there, or cannot be read, says nothing about the
|
|
85
|
+
# value — that is the integrity check's finding, not one of this check.
|
|
86
|
+
def unusable_value?(rule)
|
|
87
|
+
target = rule_column(rule)
|
|
88
|
+
return false unless target
|
|
89
|
+
|
|
90
|
+
operator = rule.operator
|
|
91
|
+
value = rule.field_value
|
|
92
|
+
value = ANY_USER_ID if Operators::ValueTyping.current_user?(operator, value)
|
|
93
|
+
Operators::ValueTyping.call(operator, value, target).equal?(Operators::ValueTyping::UNUSABLE)
|
|
94
|
+
rescue ActiveRecord::ActiveRecordError, NameError, ArgumentError
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The column the rule names, found the way PermissionRuleApplier finds
|
|
99
|
+
# it. A rule whose association path cannot be followed is left out: the
|
|
100
|
+
# engine raises for it, it does not match nothing. Tables are checked
|
|
101
|
+
# first: asking a missing one for its columns fails the statement, and
|
|
102
|
+
# with it a transaction the audit may run in.
|
|
103
|
+
def rule_column(rule)
|
|
104
|
+
model = rule.resource.name.safe_constantize
|
|
105
|
+
return unless model.is_a?(Class) && model < ActiveRecord::Base
|
|
106
|
+
|
|
107
|
+
path = rule.join_rule? ? rule.field_accessor_path.to_s.split('.') : []
|
|
108
|
+
models = Operators::RuleColumn.models_along(model, path)
|
|
109
|
+
Operators::RuleColumn.find(rule.field_name, models) if models.any? && models.all?(&:table_exists?)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def unusable_item(rule)
|
|
113
|
+
resource = rule.resource
|
|
114
|
+
path = rule.field_accessor_path.presence if rule.join_rule?
|
|
115
|
+
field = [resource.name, path, rule.field_name].compact.join('.')
|
|
116
|
+
"#{resource.permission_set.name} › #{field} #{rule.operator} #{rule.field_value.inspect}: #{rule.name} · " \
|
|
117
|
+
"#{people(resource.permission_set_id)}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def bypassed
|
|
121
|
+
rules = active_rules.includes(resource: :permission_set).select { |rule| rule.resource&.rules_bypassed? }
|
|
122
|
+
return if rules.empty?
|
|
123
|
+
|
|
124
|
+
finding(:low, :bypassed_rules, 'Rules ignored because View All is on',
|
|
125
|
+
'View All / Modify All skip these rules. Remove the rule or the bypass so the setup says what it does.',
|
|
126
|
+
items: rules.map { |rule| "#{rule.resource.permission_set.name} › #{rule.resource.name}: #{rule.name}" })
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def text_operator_on_ids
|
|
130
|
+
rules = active_rules.where(operator: TEXT_OPERATORS).includes(:resource)
|
|
131
|
+
.select { |rule| rule.field_name.to_s.end_with?('id') }
|
|
132
|
+
return if rules.empty?
|
|
133
|
+
|
|
134
|
+
finding(:low, :text_operator_on_ids, 'Text matching on id columns',
|
|
135
|
+
'contains / starts_with / ends_with on an id matches more records than intended (12 contains 1). ' \
|
|
136
|
+
'Use equal or in.', items: rules.map { |rule| "#{rule.resource.name}.#{rule.field_name}: #{rule.name}" })
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
module SecurityAuditChecks
|
|
6
|
+
# Plain-text rendering of a SecurityAudit::Report for terminals and logs.
|
|
7
|
+
class TextReport
|
|
8
|
+
def initialize(report)
|
|
9
|
+
@report = report
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_s
|
|
13
|
+
[header, *@report.findings.map { |finding| section(finding) }, footer].join("\n\n")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def header
|
|
19
|
+
counts = @report.counts.map { |severity, count| "#{severity.to_s.capitalize}: #{count}" }.join(' · ')
|
|
20
|
+
["PermissionForge security audit — #{@report.generated_at.utc.iso8601}", preset, counts].compact.join("\n")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def preset
|
|
24
|
+
case @report.preset
|
|
25
|
+
when nil then nil
|
|
26
|
+
when :compat then 'Preset: :compat (the library defaults; config.preset = :strict closes the fail-open ones)'
|
|
27
|
+
else "Preset: #{@report.preset.inspect}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def section(finding)
|
|
32
|
+
lines = ["[#{finding.severity.to_s.upcase}] #{finding.title} (#{finding.check}/#{finding.code})",
|
|
33
|
+
" #{finding.detail}"]
|
|
34
|
+
lines.concat(Array(finding.items).map { |item| " - #{item}" })
|
|
35
|
+
lines.join("\n")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def footer
|
|
39
|
+
return 'No findings.' if @report.findings.empty?
|
|
40
|
+
|
|
41
|
+
'Read-only: nothing was changed. FORMAT=json for machine output; FAIL_ON=high to fail CI.'
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
module SecurityAuditChecks
|
|
6
|
+
# The user model (config.user_class): what sign-in needs from it, which
|
|
7
|
+
# runs before anyone is signed in, and what it shows then.
|
|
8
|
+
class UserModelCheck < BaseCheck
|
|
9
|
+
RULES = %i[user_model_scoped user_model_unmasked_without_user].freeze
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
return [] unless user_model
|
|
13
|
+
|
|
14
|
+
RULES.filter_map { |rule| send(rule) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def user_model
|
|
20
|
+
return @user_model if defined?(@user_model)
|
|
21
|
+
|
|
22
|
+
model = configuration.user_class.to_s.safe_constantize
|
|
23
|
+
@user_model = model.is_a?(Class) ? model : nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Record rules on the user model, with nobody signed in denied.
|
|
27
|
+
def user_model_scoped
|
|
28
|
+
return unless user_model < PermissionForge::Concerns::PermissionScopedFindable
|
|
29
|
+
return unless %i[deny raise].include?(configuration.nil_user_strategy)
|
|
30
|
+
|
|
31
|
+
finding(:low, :user_model_scoped, 'The user model cannot be looked up before sign-in',
|
|
32
|
+
"#{user_model.name} includes PermissionScopedFindable and nil_user_strategy is " \
|
|
33
|
+
":#{configuration.nil_user_strategy}: sign-in, session restore, password reset and " \
|
|
34
|
+
'confirmation run before anyone is signed in, and their lookups (where, find_by, ' \
|
|
35
|
+
'authenticate_by) find nothing. Remove the concern from the user model (scope a user ' \
|
|
36
|
+
'directory with permission_scoped in its controller), or run those lookups inside ' \
|
|
37
|
+
'PermissionForge.without_permission_scope.')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# :hide exempts the user model so sign-in can read it.
|
|
41
|
+
def user_model_unmasked_without_user
|
|
42
|
+
return unless configuration.hide_masked_attributes_without_user?
|
|
43
|
+
|
|
44
|
+
names = user_model_resource_names
|
|
45
|
+
return if names.empty?
|
|
46
|
+
|
|
47
|
+
finding(:low, :user_model_unmasked_without_user, 'The user model is not masked with nobody signed in',
|
|
48
|
+
'masked_attributes_nil_user_strategy is :hide, but the user model is exempt so that sign-in ' \
|
|
49
|
+
"can read it: with nobody signed in, #{user_model.name}'s columns — password digests and " \
|
|
50
|
+
'reset or confirmation tokens included — read as stored through its getters and as_json. ' \
|
|
51
|
+
'Do not render it with no current user. Another authenticatable model (a second Devise ' \
|
|
52
|
+
'scope) is not exempt: with a Resource row its sign-in reads hidden values.',
|
|
53
|
+
items: names)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The Resource names that mask the user model: its own and its STI
|
|
57
|
+
# ancestors' (ResourceChain), and its subclasses'.
|
|
58
|
+
def user_model_resource_names
|
|
59
|
+
chain = PermissionForge::ResourceChain.names(user_model).map(&:to_s)
|
|
60
|
+
PermissionForge::Resource.distinct.order(:name).pluck(:name).select do |name|
|
|
61
|
+
chain.include?(name) || configuration.user_model_class?(name.safe_constantize)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
# Read-only security review of a PermissionForge setup: configuration,
|
|
6
|
+
# privileged grants, assignments, data integrity, record rules,
|
|
7
|
+
# personal-data exposure and controller actions. Backs
|
|
8
|
+
# `rake permission_forge:audit`.
|
|
9
|
+
#
|
|
10
|
+
# Each check is a class answering `#call` with an array of Finding,
|
|
11
|
+
# listed by `SecurityAudit.checks` (config, privilege, assignment, rule,
|
|
12
|
+
# integrity, personal data, controller actions, the audit trail and the
|
|
13
|
+
# user model);
|
|
14
|
+
# add one by passing `checks:` to `call` or by overriding `.checks`
|
|
15
|
+
# (OCP). Nothing is ever changed.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# report = SecurityAudit.call
|
|
19
|
+
# report.counts # => { critical: 0, high: 1, ... }
|
|
20
|
+
# report.at_least(:high) # => [#<Finding ...>]
|
|
21
|
+
#
|
|
22
|
+
# @api public
|
|
23
|
+
class SecurityAudit
|
|
24
|
+
SEVERITIES = %i[critical high medium low info].freeze
|
|
25
|
+
|
|
26
|
+
# @api private
|
|
27
|
+
Finding = Struct.new(:check, :severity, :code, :title, :detail, :items, keyword_init: true) do
|
|
28
|
+
def to_h = super.merge(items: items || [])
|
|
29
|
+
def rank = SEVERITIES.index(severity)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# preset: the configuration's preset (:compat or :strict).
|
|
33
|
+
#
|
|
34
|
+
# @api private
|
|
35
|
+
Report = Struct.new(:findings, :generated_at, :preset, keyword_init: true) do
|
|
36
|
+
def counts = SEVERITIES.index_with { |severity| findings.count { |finding| finding.severity == severity } }
|
|
37
|
+
|
|
38
|
+
def at_least(severity)
|
|
39
|
+
limit = SEVERITIES.index(severity.to_sym) || SEVERITIES.size
|
|
40
|
+
findings.select { |finding| finding.rank <= limit }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_h = { generated_at: generated_at.iso8601, preset: preset, counts: counts, findings: findings.map(&:to_h) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Array<Class>] the check classes run by default
|
|
47
|
+
def self.checks
|
|
48
|
+
[
|
|
49
|
+
SecurityAuditChecks::ConfigCheck, SecurityAuditChecks::PrivilegeCheck,
|
|
50
|
+
SecurityAuditChecks::AssignmentCheck, SecurityAuditChecks::RuleCheck,
|
|
51
|
+
SecurityAuditChecks::IntegrityCheck, SecurityAuditChecks::PersonalDataCheck,
|
|
52
|
+
SecurityAuditChecks::ActionCheck, SecurityAuditChecks::AuditTrailCheck,
|
|
53
|
+
SecurityAuditChecks::UserModelCheck
|
|
54
|
+
]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Runs the read-only security review.
|
|
58
|
+
# @return [Report]
|
|
59
|
+
def self.call(...) = new(...).call
|
|
60
|
+
|
|
61
|
+
def initialize(configuration: PermissionForge.configuration, checks: self.class.checks,
|
|
62
|
+
environment: defined?(Rails) ? Rails.env.to_s : 'development')
|
|
63
|
+
@configuration = configuration
|
|
64
|
+
@checks = checks
|
|
65
|
+
@environment = environment
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Report] the findings, most severe first
|
|
69
|
+
def call
|
|
70
|
+
findings = @checks.flat_map { |check| check.new(configuration: @configuration, environment: @environment).call }
|
|
71
|
+
Report.new(findings: findings.sort_by(&:rank), generated_at: Time.current, preset: @configuration.preset)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module PermissionForge
|
|
2
|
+
module Services
|
|
3
|
+
# Audits permanent permission set assignments that haven't been reviewed
|
|
4
|
+
# within a configurable threshold (default: 90 days). Reviewed means the
|
|
5
|
+
# last access review "Keep" (reviewed_at), else the grant (created_at) —
|
|
6
|
+
# PermissionSetUser.stale_before; editing an assignment is not a review.
|
|
7
|
+
# Supports GDPR Art. 5(1)(e) — Storage Limitation.
|
|
8
|
+
#
|
|
9
|
+
# @example Find stale assignments
|
|
10
|
+
# result = StaleAssignmentAuditor.call
|
|
11
|
+
# # => { stale_count: 5, affected_user_ids: [1, 2], assignments: [...] }
|
|
12
|
+
#
|
|
13
|
+
# @example Custom threshold
|
|
14
|
+
# result = StaleAssignmentAuditor.call(threshold_days: 30)
|
|
15
|
+
#
|
|
16
|
+
# With a tenant ambient, that tenant's assignments; with none, every
|
|
17
|
+
# tenant's — also under acts_as_tenant's require_tenant
|
|
18
|
+
# (Concerns::AcrossTenants).
|
|
19
|
+
#
|
|
20
|
+
# @api public
|
|
21
|
+
class StaleAssignmentAuditor
|
|
22
|
+
include Concerns::AcrossTenants
|
|
23
|
+
|
|
24
|
+
# @param threshold_days [Integer] how long a permanent assignment may go unreviewed
|
|
25
|
+
# @return [Hash] see {#call}
|
|
26
|
+
def self.call(...) = new(...).call
|
|
27
|
+
|
|
28
|
+
def initialize(threshold_days: 90)
|
|
29
|
+
@threshold_days = threshold_days
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Hash] `{ stale_count:, affected_user_ids:, assignments: }`
|
|
33
|
+
def call
|
|
34
|
+
across_tenants do
|
|
35
|
+
stale = stale_assignments
|
|
36
|
+
{
|
|
37
|
+
stale_count: stale.count,
|
|
38
|
+
affected_user_ids: stale.distinct.pluck(:user_id),
|
|
39
|
+
threshold_days: @threshold_days,
|
|
40
|
+
assignments: stale.includes(:permission_set).map { |psu| row_for(psu) }
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def row_for(psu)
|
|
48
|
+
last_reviewed = psu.last_reviewed_at
|
|
49
|
+
{
|
|
50
|
+
user_id: psu.user_id,
|
|
51
|
+
permission_set: psu.permission_set&.name,
|
|
52
|
+
last_updated: psu.updated_at,
|
|
53
|
+
last_reviewed_at: last_reviewed,
|
|
54
|
+
reviewed_by_id: psu.try(:reviewed_by_id),
|
|
55
|
+
days_stale: ((Time.current - last_reviewed) / 1.day).round
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def stale_assignments
|
|
60
|
+
PermissionForge::PermissionSetUser.stale_before(threshold_cutoff)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def threshold_cutoff
|
|
64
|
+
@threshold_days.days.ago
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
# Level 2 — DETECT (config.unscoped_query_detection = :log | :raise).
|
|
6
|
+
#
|
|
7
|
+
# Subscribes to sql.active_record and reports SELECT/UPDATE/DELETE
|
|
8
|
+
# statements that touch a protected model's table without carrying the
|
|
9
|
+
# pf_scoped annotation (added by permission_scoped, Level 1 auto-scoping,
|
|
10
|
+
# and internal gem paths). INSERTs, schema/EXPLAIN queries, and anything
|
|
11
|
+
# executed inside PermissionForge.without_permission_scope (which also
|
|
12
|
+
# covers the gem's own internal probes) are never flagged.
|
|
13
|
+
#
|
|
14
|
+
# :log -> Rails.logger.warn with the SQL and the first app call site
|
|
15
|
+
# :raise -> PermissionForge::UnscopedQueryError
|
|
16
|
+
#
|
|
17
|
+
# Either way the finding is published as query.unscoped (model, table,
|
|
18
|
+
# mode, call site — not the SQL, which can carry values), at most once per
|
|
19
|
+
# request per table.
|
|
20
|
+
#
|
|
21
|
+
# Detection is environment-gated via
|
|
22
|
+
# config.unscoped_query_detection_environments (default: development, test)
|
|
23
|
+
# — it is a migration aid and CI guard, not a production control. The
|
|
24
|
+
# notification subscriber runs synchronously on the querying thread, so
|
|
25
|
+
# :raise surfaces at the offending call site.
|
|
26
|
+
#
|
|
27
|
+
# @api private
|
|
28
|
+
class UnscopedQueryDetector
|
|
29
|
+
MARKER = "/* #{PermissionForge::Concerns::PermissionScopedFindable::SCOPED_ANNOTATION} */".freeze
|
|
30
|
+
STATEMENT_PATTERN = /\A\s*(?:SELECT|UPDATE|DELETE)\b/i
|
|
31
|
+
IGNORED_PAYLOAD_NAMES = ['SCHEMA', 'EXPLAIN', nil].freeze
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# Installs the sql.active_record subscriber (idempotent).
|
|
35
|
+
def install!
|
|
36
|
+
# rubocop:disable-next Naming/MemoizedInstanceVariableName -- @subscriber is the handle uninstall! needs
|
|
37
|
+
@subscriber ||= ActiveSupport::Notifications.subscribe('sql.active_record') do |*, payload|
|
|
38
|
+
check(payload)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Removes the subscriber (used by tests to isolate detection state).
|
|
43
|
+
def uninstall!
|
|
44
|
+
return unless @subscriber
|
|
45
|
+
|
|
46
|
+
ActiveSupport::Notifications.unsubscribe(@subscriber)
|
|
47
|
+
@subscriber = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def installed?
|
|
51
|
+
!@subscriber.nil?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Evaluates a single sql.active_record payload. Public for direct
|
|
55
|
+
# testing without a live subscriber.
|
|
56
|
+
def check(payload)
|
|
57
|
+
return unless PermissionForge.configuration.unscoped_query_detection_active?
|
|
58
|
+
return if PermissionForge.scope_suspended?
|
|
59
|
+
return if IGNORED_PAYLOAD_NAMES.include?(payload[:name])
|
|
60
|
+
|
|
61
|
+
sql = payload[:sql].to_s
|
|
62
|
+
return unless sql.match?(STATEMENT_PATTERN)
|
|
63
|
+
return if sql.include?(MARKER)
|
|
64
|
+
|
|
65
|
+
table = protected_table_in(sql)
|
|
66
|
+
return unless table
|
|
67
|
+
|
|
68
|
+
report(sql, table)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def protected_table_in(sql)
|
|
74
|
+
PermissionForge::Concerns::PermissionScopedFindable.protected_table_names.find do |table|
|
|
75
|
+
sql.match?(/\b#{Regexp.escape(table)}\b/)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def report(sql, table)
|
|
80
|
+
publish(table)
|
|
81
|
+
message = I18n.t(
|
|
82
|
+
'permission_forge.errors.unscoped_query',
|
|
83
|
+
table: table,
|
|
84
|
+
default: "Unscoped query on protected table '#{table}'. Use permission_scoped / find_by, " \
|
|
85
|
+
'enable auto_query_scoping, or wrap intentional access in ' \
|
|
86
|
+
'PermissionForge.without_permission_scope { }.'
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
raise PermissionForge::UnscopedQueryError, "#{message} SQL: #{sql}" if raise_mode?
|
|
90
|
+
|
|
91
|
+
Rails.logger&.warn("[PermissionForge] #{message} SQL: #{truncate_sql(sql)}#{app_call_site}")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def publish(table)
|
|
95
|
+
return unless PermissionForge::Audit.instrument?(PermissionForge::Audit::Runtime::UNSCOPED)
|
|
96
|
+
|
|
97
|
+
PermissionForge::Audit::Runtime.unscoped(
|
|
98
|
+
model: protected_model_for(table), table: table,
|
|
99
|
+
data: { 'mode' => PermissionForge.configuration.unscoped_query_detection.to_s, 'call_site' => cleaned_backtrace_frame }
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def protected_model_for(table)
|
|
104
|
+
PermissionForge::Concerns::PermissionScopedFindable.protected_model_names.find do |model_name|
|
|
105
|
+
model_name.safe_constantize&.table_name == table
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def raise_mode?
|
|
110
|
+
PermissionForge.configuration.unscoped_query_detection == :raise
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def truncate_sql(sql)
|
|
114
|
+
sql.length > 300 ? "#{sql[0, 300]}…" : sql
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The subscriber runs synchronously on the querying thread, so caller
|
|
118
|
+
# still contains the app frame that triggered the query.
|
|
119
|
+
def app_call_site
|
|
120
|
+
frame = cleaned_backtrace_frame
|
|
121
|
+
frame ? " Called from: #{frame}" : ''
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def cleaned_backtrace_frame
|
|
125
|
+
return caller.first unless defined?(Rails) && Rails.respond_to?(:backtrace_cleaner)
|
|
126
|
+
|
|
127
|
+
Rails.backtrace_cleaner.clean(caller).first
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|