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,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
module SecurityAuditChecks
|
|
6
|
+
# Who holds access: stale, orphaned and leftover assignments, inactive
|
|
7
|
+
# users, people stacking many sets.
|
|
8
|
+
class AssignmentCheck < BaseCheck
|
|
9
|
+
MANY_SETS = 3
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
[stale, missing_users, inactive_users, expired_uncleaned, many_sets, inactive_sets_assigned,
|
|
13
|
+
all_permanent].compact
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def active
|
|
19
|
+
PermissionForge::PermissionSetUser.active_assignments
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def stale_days
|
|
23
|
+
configuration.try(:admin_stale_assignment_days) || 90
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def stale
|
|
27
|
+
result = StaleAssignmentAuditor.call(threshold_days: stale_days)
|
|
28
|
+
return if result[:stale_count].zero?
|
|
29
|
+
|
|
30
|
+
labels = labels_for(result[:assignments].pluck(:user_id))
|
|
31
|
+
finding(:medium, :stale_assignments, "Permanent access not reviewed in #{stale_days} days",
|
|
32
|
+
'Confirm these people still need it (Keep on the admin Home), or give it an end date.',
|
|
33
|
+
items: result[:assignments].map do |row|
|
|
34
|
+
"#{labels[row[:user_id]]} — #{row[:permission_set]} (#{row[:days_stale]} days)"
|
|
35
|
+
end)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def missing_users
|
|
39
|
+
ids = active.distinct.pluck(:user_id) - user_ids
|
|
40
|
+
return if ids.empty?
|
|
41
|
+
|
|
42
|
+
finding(:medium, :missing_users, 'Assignments for users that no longer exist',
|
|
43
|
+
'These rows grant access to ids the user table no longer has; a new user reusing an id ' \
|
|
44
|
+
'would inherit it. Remove them.', items: ids.sort.map { |id| "User ##{id}" })
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def inactive_users
|
|
48
|
+
check = configuration.user_active_check
|
|
49
|
+
return unless check
|
|
50
|
+
|
|
51
|
+
inactive = assigned_users.reject { |user| check.call(user) }
|
|
52
|
+
return if inactive.empty?
|
|
53
|
+
|
|
54
|
+
finding(:medium, :inactive_users, 'Deactivated users still hold access',
|
|
55
|
+
'user_active_check says these users are inactive, but their assignments remain and apply if ' \
|
|
56
|
+
'they are reactivated or use another way in. Remove the access.',
|
|
57
|
+
items: inactive.map { |user| label(user) })
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def expired_uncleaned
|
|
61
|
+
count = PermissionForge::PermissionSetUser.expired.count
|
|
62
|
+
return if count.zero?
|
|
63
|
+
|
|
64
|
+
finding(:low, :expired_uncleaned, "#{count} expired assignment(s) not cleaned up",
|
|
65
|
+
'Expired assignments no longer grant access but clutter reviews. Run ' \
|
|
66
|
+
'`rake permission_forge:cleanup_expired_assignments` or use Clean up on the admin Home.')
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def many_sets
|
|
70
|
+
heavy = active.group(:user_id).count.select { |_, count| count >= MANY_SETS }
|
|
71
|
+
return if heavy.empty?
|
|
72
|
+
|
|
73
|
+
labels = labels_for(heavy.keys)
|
|
74
|
+
finding(:low, :many_sets, "People holding #{MANY_SETS} or more sets",
|
|
75
|
+
'Granting sets add up (most permissive wins). Check their effective access on the admin Person page.',
|
|
76
|
+
items: heavy.sort_by { |_, count| -count }.map { |id, count| "#{labels[id]}: #{count} sets" })
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def inactive_sets_assigned
|
|
80
|
+
sets = PermissionForge::PermissionSet.where(is_active: false).order(:name)
|
|
81
|
+
.select { |set| people_counts.fetch(set.id, 0).positive? }
|
|
82
|
+
return if sets.empty?
|
|
83
|
+
|
|
84
|
+
finding(:low, :inactive_sets_assigned, 'Turned-off sets still have people',
|
|
85
|
+
'They grant nothing while off, but turning one on restores access silently.',
|
|
86
|
+
items: sets.map { |set| "#{set.name} · #{people(set.id)}" })
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def all_permanent
|
|
90
|
+
total = active.count
|
|
91
|
+
return if total.zero? || active.where.not(expires_at: nil).exists?
|
|
92
|
+
|
|
93
|
+
finding(:info, :all_permanent, "All #{total} assignment(s) are permanent",
|
|
94
|
+
'Consider end dates for temporary needs (contractors, projects, cover).')
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def assigned_users
|
|
98
|
+
@assigned_users ||= user_model.where(id: active.distinct.select(:user_id)).to_a
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def user_ids
|
|
102
|
+
@user_ids ||= user_model.where(id: active.distinct.select(:user_id)).pluck(:id)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def labels_for(ids)
|
|
106
|
+
users = user_model.where(id: ids).index_by(&:id)
|
|
107
|
+
ids.index_with { |id| users[id] ? label(users[id]) : "User ##{id}" }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def label(user)
|
|
111
|
+
PermissionForge::Admin::PersonLabel.new(user, configuration: configuration).name
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def user_model
|
|
115
|
+
configuration.user_model
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
module SecurityAuditChecks
|
|
6
|
+
# The audit event log ({PermissionForge::Audit}): is it recording, can
|
|
7
|
+
# its actors be trusted, and are the failure settings strict enough.
|
|
8
|
+
# Event queries use the indexed event / actor_type / occurred_at columns
|
|
9
|
+
# and follow the tenant scoping of the AuditEvent model like any other.
|
|
10
|
+
class AuditTrailCheck < BaseCheck
|
|
11
|
+
RULES = %i[
|
|
12
|
+
table_missing audit_off shared_basic_auth anonymous_api unknown_actors self_granted
|
|
13
|
+
default_runtime_events hook_failures_only_logged change_failures_not_raised kept_forever
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
UNKNOWN_ACTOR_DAYS = 30
|
|
17
|
+
SUBJECT_LIMIT = 10
|
|
18
|
+
SELF_GRANT_SCAN_LIMIT = 5000
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
RULES.filter_map { |rule| send(rule) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def audit_events = PermissionForge::AuditEvent
|
|
27
|
+
|
|
28
|
+
# true, false, or nil when the database could not be asked.
|
|
29
|
+
def table?
|
|
30
|
+
return @table if defined?(@table)
|
|
31
|
+
|
|
32
|
+
@table = audit_events.table_exists?
|
|
33
|
+
rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
|
|
34
|
+
@table = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def table_missing
|
|
38
|
+
return if configuration.audit_events == false || table? != false
|
|
39
|
+
|
|
40
|
+
finding(:high, :audit_table_missing, 'The audit events table is missing',
|
|
41
|
+
"audit_events is #{configuration.audit_events.inspect} but the table #{audit_events.table_name} " \
|
|
42
|
+
"does not exist, so #{missing_table_effect}. Run bin/rails generate permission_forge:upgrade " \
|
|
43
|
+
'and migrate.')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# :auto records nothing without the table; true tries every time.
|
|
47
|
+
def missing_table_effect
|
|
48
|
+
return 'no permission change is recorded (changes still go through)' unless configuration.audit_events == true
|
|
49
|
+
return 'every permission change fails (audit_change_failure_strategy :raise)' if
|
|
50
|
+
configuration.audit_change_failure_strategy == :raise
|
|
51
|
+
|
|
52
|
+
'every permission change goes through unrecorded (audit_change_failure_strategy ' \
|
|
53
|
+
":#{configuration.audit_change_failure_strategy})"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def audit_off
|
|
57
|
+
return unless configuration.audit_events == false
|
|
58
|
+
|
|
59
|
+
finding(:high, :audit_off, 'The audit event log is switched off',
|
|
60
|
+
'audit_events is false: nobody can later tell who granted, changed or revoked access. Set it ' \
|
|
61
|
+
'to :auto (or true) unless another system records these changes.')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def shared_basic_auth
|
|
65
|
+
return unless configuration.http_basic_auth_enabled && configuration.audit_actor_resolver.nil?
|
|
66
|
+
|
|
67
|
+
finding(:medium, :audit_shared_basic_auth, 'Admin changes can be attributed to one shared login',
|
|
68
|
+
'The admin UI uses HTTP Basic auth and audit_actor_resolver is not set, so when ' \
|
|
69
|
+
'current_user_resolver finds nobody in an admin request, its events name the shared ' \
|
|
70
|
+
'username, not a person. Set audit_actor_resolver to return the signed-in admin.')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def anonymous_api
|
|
74
|
+
return unless configuration.api_enabled && configuration.audit_actor_resolver.nil?
|
|
75
|
+
|
|
76
|
+
finding(:medium, :audit_anonymous_api, 'API changes can be recorded as an anonymous API client',
|
|
77
|
+
'The JSON API is enabled and audit_actor_resolver is not set, so when current_user_resolver ' \
|
|
78
|
+
'finds nobody in an API request (a token or API key), its events name an anonymous ' \
|
|
79
|
+
'"API client", not the caller. Set audit_actor_resolver to return the token, key or user.')
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def unknown_actors
|
|
83
|
+
return unless table?
|
|
84
|
+
|
|
85
|
+
count = audit_events.where(category: 'change', actor_type: 'unknown', occurred_at: UNKNOWN_ACTOR_DAYS.days.ago..)
|
|
86
|
+
.count
|
|
87
|
+
return if count.zero?
|
|
88
|
+
|
|
89
|
+
finding(:medium, :audit_unknown_actors,
|
|
90
|
+
"#{count} #{count == 1 ? 'change' : 'changes'} in #{UNKNOWN_ACTOR_DAYS} days " \
|
|
91
|
+
"#{count == 1 ? 'has' : 'have'} no known actor",
|
|
92
|
+
'Changes were made where no user, API caller or system label was available (a console, job or ' \
|
|
93
|
+
'script outside PermissionForge.with_audit_actor). Wrap such work in with_audit_actor, or set ' \
|
|
94
|
+
'current_user_resolver / audit_actor_resolver.')
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self_granted
|
|
98
|
+
return unless table?
|
|
99
|
+
|
|
100
|
+
grants = self_grant_events
|
|
101
|
+
return if grants.empty?
|
|
102
|
+
|
|
103
|
+
held = PermissionForge::PermissionSetUser.active_assignments.where(id: grants.map(&:first)).pluck(:user_id).uniq
|
|
104
|
+
return if held.empty?
|
|
105
|
+
|
|
106
|
+
finding(:high, :audit_self_granted,
|
|
107
|
+
"#{people_label(held.size)} #{held.size == 1 ? 'holds' : 'hold'} access they granted to themselves",
|
|
108
|
+
'The assignment.granted event names the same person as grantor and recipient. Confirm each ' \
|
|
109
|
+
'was approved by someone else, and restrict who may assign sets (admin_assignment_authorization).',
|
|
110
|
+
items: held.first(SUBJECT_LIMIT).map { |id| "user #{id}" })
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# [assignment id, subject id] of grants whose actor is their subject.
|
|
114
|
+
def self_grant_events
|
|
115
|
+
table = audit_events.arel_table
|
|
116
|
+
audit_events.where(event: 'assignment.granted', actor_type: 'user',
|
|
117
|
+
record_type: PermissionForge::PermissionSetUser.base_class.name)
|
|
118
|
+
.where(table[:actor_id].eq(table[:subject_user_id]))
|
|
119
|
+
.order(occurred_at: :desc).limit(SELF_GRANT_SCAN_LIMIT).pluck(:record_id, :subject_user_id)
|
|
120
|
+
.map { |record_id, subject| [record_id.to_i, subject] }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def default_runtime_events
|
|
124
|
+
groups = Array(configuration.audit_runtime_events)
|
|
125
|
+
return unless (groups - [:denied]).empty?
|
|
126
|
+
return no_runtime_events if groups.empty?
|
|
127
|
+
|
|
128
|
+
finding(:info, :audit_runtime_default, 'Only denied access is recorded at runtime',
|
|
129
|
+
'audit_runtime_events holds just :denied, so reads of masked fields, rule bypasses and cache ' \
|
|
130
|
+
'rebuilds leave no event. Add groups (:bypass, :field_read, ...) if you must show who saw what.')
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def no_runtime_events
|
|
134
|
+
finding(:info, :audit_runtime_default, 'No runtime events are stored',
|
|
135
|
+
'No runtime events are stored (audit_runtime_events is empty): denied access, reads of masked ' \
|
|
136
|
+
'fields, rule bypasses and cache rebuilds leave no event (they are still published as ' \
|
|
137
|
+
'notifications). Add :denied at least, and other groups if you must show who saw what.')
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def hook_failures_only_logged
|
|
141
|
+
return unless configuration.audit_hook_failure_strategy == :log
|
|
142
|
+
|
|
143
|
+
finding(:low, :audit_hook_failures_logged, 'Audit hook failures are only logged',
|
|
144
|
+
'audit_hook_failure_strategy is :log: a failing subscriber or runtime event store is not ' \
|
|
145
|
+
'handed to Rails.error. Use :report (default) so it reaches your error tracker.')
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def change_failures_not_raised
|
|
149
|
+
return if configuration.audit_change_failure_strategy == :raise
|
|
150
|
+
|
|
151
|
+
finding(:medium, :audit_changes_unrecorded, 'A permission change can go through without its audit event',
|
|
152
|
+
"audit_change_failure_strategy is :#{configuration.audit_change_failure_strategy}: when the event " \
|
|
153
|
+
'cannot be written the change still succeeds and the record is lost. Use :raise (default).')
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def kept_forever
|
|
157
|
+
return unless configuration.audit_retention_period.nil?
|
|
158
|
+
|
|
159
|
+
finding(:info, :audit_kept_forever, 'Audit events are kept forever',
|
|
160
|
+
'audit_retention_period is nil. Confirm this matches your retention policy, or set a period ' \
|
|
161
|
+
'so the purge service can remove older events.')
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
# @api private
|
|
6
|
+
module SecurityAuditChecks
|
|
7
|
+
# Shared plumbing for SecurityAudit checks: subclasses implement #call
|
|
8
|
+
# and build findings with #finding.
|
|
9
|
+
class BaseCheck
|
|
10
|
+
ITEM_LIMIT = 50
|
|
11
|
+
|
|
12
|
+
def initialize(configuration:, environment:)
|
|
13
|
+
@configuration = configuration
|
|
14
|
+
@environment = environment
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
attr_reader :configuration
|
|
20
|
+
|
|
21
|
+
def production?
|
|
22
|
+
@environment == 'production'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def finding(severity, code, title, detail, items: [])
|
|
26
|
+
SecurityAudit::Finding.new(check: check_name, severity: severity, code: code, title: title,
|
|
27
|
+
detail: detail, items: capped(items))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def capped(items)
|
|
31
|
+
extra = items.size - ITEM_LIMIT
|
|
32
|
+
extra.positive? ? items.first(ITEM_LIMIT) << "… and #{extra} more" : items
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def check_name
|
|
36
|
+
self.class.name.demodulize.delete_suffix('Check').underscore
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def active_granting_sets
|
|
40
|
+
PermissionForge::PermissionSet.active.granting
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def people_counts
|
|
44
|
+
@people_counts ||= PermissionForge::PermissionSetUser.active_assignments.group(:permission_set_id).count
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def people(set_id)
|
|
48
|
+
people_label(people_counts.fetch(set_id, 0))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def people_label(count)
|
|
52
|
+
"#{count} #{count == 1 ? 'person' : 'people'}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
module SecurityAuditChecks
|
|
6
|
+
# Configuration hardening: who can reach the admin, whether the cache is
|
|
7
|
+
# shared, fail-closed flags, GDPR and audit settings, and the JSON API.
|
|
8
|
+
class ConfigCheck < BaseCheck
|
|
9
|
+
RULES = %i[
|
|
10
|
+
admin_ui_exposed process_local_cache unconfigured_fields_pass write_protection_off nested_parent_fallback
|
|
11
|
+
nil_user_bypass masked_nil_user_bypass gdpr_defaults_off log_filtering_off audit_trail_off conditional_sets_lenient
|
|
12
|
+
detection_raises_in_production api_without_auth api_mutations_unguarded api_sequential_ids
|
|
13
|
+
api_rule_values_exposed tenantless_admin strict_overrides
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
RULES.filter_map { |rule| send(rule) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def admin_ui_exposed
|
|
23
|
+
return if configuration.http_basic_auth_enabled
|
|
24
|
+
return unless configuration.base_controller_class.to_s.delete_prefix('::') == 'ActionController::Base'
|
|
25
|
+
|
|
26
|
+
finding(:critical, :admin_ui_exposed, 'The admin UI has no authentication',
|
|
27
|
+
'http_basic_auth_enabled is false and base_controller_class is ActionController::Base, so ' \
|
|
28
|
+
'anyone who can reach the mount path can change permissions. Set base_controller_class to ' \
|
|
29
|
+
'a controller that authenticates and authorizes admins, or turn HTTP Basic back on.')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def process_local_cache
|
|
33
|
+
return unless production? && configuration.cache_backend.to_s == 'memory'
|
|
34
|
+
|
|
35
|
+
finding(:high, :process_local_cache, 'Permission cache is local to each process',
|
|
36
|
+
'cache_backend is :memory in production: a revoked permission stays cached in other Puma ' \
|
|
37
|
+
'workers until their TTL expires. Use :rails_cache with a shared store.')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def unconfigured_fields_pass
|
|
41
|
+
strategy = configuration.unconfigured_field_strategy.to_s
|
|
42
|
+
return unless %w[pass_through warn].include?(strategy)
|
|
43
|
+
|
|
44
|
+
finding(:high, :unconfigured_fields_pass, 'Unconfigured fields are returned unfiltered',
|
|
45
|
+
"unconfigured_field_strategy is :#{strategy}, so a new column reaches filtered responses " \
|
|
46
|
+
'before anyone decides who may read it. Use :block (the default).')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def write_protection_off
|
|
50
|
+
return unless configuration.masked_write_protection.to_s == 'off'
|
|
51
|
+
|
|
52
|
+
finding(:high, :write_protection_off, 'Masked values can be written back',
|
|
53
|
+
'masked_write_protection is :off: saving a record that holds a masked value overwrites the ' \
|
|
54
|
+
'real data with the mask. Use :skip (default) or :raise.')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def nested_parent_fallback
|
|
58
|
+
return unless configuration.nested_attributes_parent_fallback
|
|
59
|
+
|
|
60
|
+
finding(:medium, :nested_parent_fallback, 'Nested attributes fall back to the parent resource',
|
|
61
|
+
'nested_attributes_parent_fallback is true: a `<assoc>_attributes` payload for a model the user ' \
|
|
62
|
+
"has no Resource entry for is filtered against the parent's fields, so a key editable on the parent " \
|
|
63
|
+
'admits the same key on a child the user was never granted. Configure the child Resource and set ' \
|
|
64
|
+
'it back to false (the default).')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def nil_user_bypass
|
|
68
|
+
strategies = { nil_user_strategy: configuration.nil_user_strategy,
|
|
69
|
+
response_filter_nil_user_strategy: configuration.response_filter_nil_user_strategy }
|
|
70
|
+
bypassing = strategies.select { |_, value| value.to_s == 'bypass' }.keys
|
|
71
|
+
return if bypassing.empty?
|
|
72
|
+
|
|
73
|
+
finding(:low, :nil_user_bypass, 'No current user means no permission checks',
|
|
74
|
+
"#{bypassing.join(' and ')} #{bypassing.one? ? 'is' : 'are'} :bypass. Make sure every request " \
|
|
75
|
+
'that touches protected models is authenticated first, or use :deny / :raise.')
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The one nil-user setting that is about the getters themselves.
|
|
79
|
+
def masked_nil_user_bypass
|
|
80
|
+
return if configuration.hide_masked_attributes_without_user?
|
|
81
|
+
|
|
82
|
+
finding(:medium, :masked_nil_user_bypass, 'Masked attributes are readable with no current user',
|
|
83
|
+
'masked_attributes_nil_user_strategy is :bypass: when nobody is signed in (a job, a mailer, the ' \
|
|
84
|
+
'console, a resolver that returns nil) the getters, attributes and as_json of a masked model return ' \
|
|
85
|
+
'the stored values, whatever nil_user_strategy says. Set it to :hide (the user model stays readable, as ' \
|
|
86
|
+
'sign-in needs), and set the current user in ' \
|
|
87
|
+
'the jobs and mailers that read masked data.')
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def gdpr_defaults_off
|
|
91
|
+
return if configuration.gdpr_defaults
|
|
92
|
+
|
|
93
|
+
finding(:medium, :gdpr_defaults_off, 'GDPR defaults are off',
|
|
94
|
+
'Generated fields for personal data are not masked by default. Set gdpr_defaults = true if ' \
|
|
95
|
+
'you hold EU personal data.')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def log_filtering_off
|
|
99
|
+
return if configuration.filter_parameters_integration
|
|
100
|
+
|
|
101
|
+
finding(:low, :log_filtering_off, 'Personal-data parameters are not filtered from logs',
|
|
102
|
+
'filter_parameters_integration is false. Enable it, or add ' \
|
|
103
|
+
'PermissionForge.gdpr_filter_parameters to Rails filter_parameters yourself.')
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def audit_trail_off
|
|
107
|
+
return if configuration.audit_trail_enabled
|
|
108
|
+
|
|
109
|
+
finding(:medium, :audit_trail_off, 'Permission changes are not attributed',
|
|
110
|
+
'audit_trail_enabled is false, so permission records do not keep created_by / updated_by.')
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def conditional_sets_lenient
|
|
114
|
+
return if configuration.conditional_sets_strict_mode
|
|
115
|
+
|
|
116
|
+
conditional = conditional_set_names
|
|
117
|
+
return if conditional.empty?
|
|
118
|
+
|
|
119
|
+
finding(:medium, :conditional_sets_lenient, 'Conditional sets apply when the request context is blank',
|
|
120
|
+
'conditional_sets_strict_mode is false: where the request context is blank (no resolver, or one ' \
|
|
121
|
+
'that returns {} in a job or the console), session-flag / IP / hours conditions are skipped and ' \
|
|
122
|
+
'the set applies, MFA- or IP-gated grants included.',
|
|
123
|
+
items: conditional)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def detection_raises_in_production
|
|
127
|
+
return unless configuration.unscoped_query_detection.to_s == 'raise'
|
|
128
|
+
return unless Array(configuration.unscoped_query_detection_environments).map(&:to_s).include?('production')
|
|
129
|
+
|
|
130
|
+
finding(:medium, :detection_raises_in_production, 'Unscoped-query detection raises in production',
|
|
131
|
+
'unscoped_query_detection is :raise for production, so a missed scope becomes a 500 for users. ' \
|
|
132
|
+
'Use :log in production and :raise in test/CI.')
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def api_without_auth
|
|
136
|
+
return unless configuration.api_enabled && configuration.api_auth.nil?
|
|
137
|
+
|
|
138
|
+
finding(:medium, :api_without_auth, 'The JSON API is enabled without api_auth',
|
|
139
|
+
'Every API request returns 401. Configure api_auth, or turn the API off.')
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def api_mutations_unguarded
|
|
143
|
+
return unless configuration.api_enabled && !configuration.api_require_authorization
|
|
144
|
+
return if configuration.api_action_authorization && configuration.api_assignment_authorization
|
|
145
|
+
|
|
146
|
+
finding(:high, :api_mutations_unguarded, 'Any authenticated API caller can change permissions',
|
|
147
|
+
'api_auth proves identity, not admin rights. Set api_action_authorization and ' \
|
|
148
|
+
'api_assignment_authorization, or keep api_require_authorization = true (the default) ' \
|
|
149
|
+
'to refuse changes until they are set.')
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Tenancy on, and an admin base controller that sets no tenant.
|
|
153
|
+
def tenantless_admin
|
|
154
|
+
defaults = configuration.default_admin_controllers_under_tenancy
|
|
155
|
+
return if defaults.empty?
|
|
156
|
+
|
|
157
|
+
names = defaults.join(' and ')
|
|
158
|
+
if configuration.admin_tenantless_access_allowed?
|
|
159
|
+
finding(:high, :tenantless_admin, 'The admin can read and change every tenant',
|
|
160
|
+
'tenant_model is set, admin_tenantless_access is :allow and ActionController::Base, which sets ' \
|
|
161
|
+
"no tenant, is still the base of #{names}: one admin request reads and changes the existing " \
|
|
162
|
+
'Permission Sets, assignments and audit log of every tenant. Point each at a controller that ' \
|
|
163
|
+
'sets the current tenant, and keep admin_tenantless_access :deny (the default).')
|
|
164
|
+
else
|
|
165
|
+
finding(:medium, :tenantless_admin, 'The admin base controllers set no tenant',
|
|
166
|
+
"tenant_model is set but ActionController::Base, which sets no tenant, is still the base of #{names}: " \
|
|
167
|
+
'every admin request without one is refused (admin_tenantless_access :deny) unless middleware ' \
|
|
168
|
+
'sets it. Point each at a controller that sets the current tenant.')
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def api_sequential_ids
|
|
173
|
+
return unless configuration.api_enabled && configuration.api_record_identifier.to_s == 'id'
|
|
174
|
+
|
|
175
|
+
finding(:low, :api_sequential_ids, 'The API exposes sequential ids',
|
|
176
|
+
'api_record_identifier is :id, which makes records easy to enumerate. Use :uuid.')
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def api_rule_values_exposed
|
|
180
|
+
return unless configuration.api_enabled && !configuration.api_redact_rule_field_values
|
|
181
|
+
|
|
182
|
+
finding(:low, :api_rule_values_exposed, 'The API returns rule values',
|
|
183
|
+
'Rule field_value can hold user ids or business data. Set api_redact_rule_field_values = true ' \
|
|
184
|
+
'if that matters for your data.')
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Under the :strict preset, the options set looser than it (the
|
|
188
|
+
# findings above rate the risk of each; this one names them all).
|
|
189
|
+
def strict_overrides
|
|
190
|
+
return unless configuration.preset == :strict
|
|
191
|
+
|
|
192
|
+
looser = configuration.options_looser_than_strict
|
|
193
|
+
return if looser.empty?
|
|
194
|
+
|
|
195
|
+
finding(:low, :strict_overrides, 'Settings override the :strict preset',
|
|
196
|
+
"config.preset is :strict, but #{looser.size} #{looser.one? ? 'option is' : 'options are'} set " \
|
|
197
|
+
'to a looser value. Check that each override is intended.',
|
|
198
|
+
items: looser.map { |option, value| strict_override_item(option, value) })
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def strict_override_item(option, value)
|
|
202
|
+
strict = Configuration::Presets::STRICT.fetch(option)
|
|
203
|
+
"#{option} = #{value.inspect} (strict: #{strict.inspect})"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def conditional_set_names
|
|
207
|
+
PermissionForge::PermissionSet.active.conditional.order(:name).pluck(:name)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
module SecurityAuditChecks
|
|
6
|
+
# Rows the models' validations would refuse but the database holds
|
|
7
|
+
# anyway (written by SQL, an older version, or another app).
|
|
8
|
+
class IntegrityCheck < BaseCheck
|
|
9
|
+
RESOURCE_RULES = {
|
|
10
|
+
'edit without read' => ->(r) { r.edit_access && !r.read_access },
|
|
11
|
+
'delete without read' => ->(r) { r.delete_access && !r.read_access },
|
|
12
|
+
'Modify All without View All' => ->(r) { r.modify_all && !r.view_all }
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
FIELD_RULES = {
|
|
16
|
+
'edit without read' => ->(field) { field.edit_access && !field.read_access },
|
|
17
|
+
'masked without read' => ->(field) { field.mask_access && !field.read_access }
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
[duplicate_fields, duplicate_actions, dependency_violations, unknown_models].compact
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def duplicate_fields
|
|
27
|
+
groups = PermissionForge::ResourceField.group(:resource_id, :name).having('COUNT(*) > 1').count
|
|
28
|
+
return if groups.empty?
|
|
29
|
+
|
|
30
|
+
names = PermissionForge::Resource.where(id: groups.keys.map(&:first)).pluck(:id, :name).to_h
|
|
31
|
+
finding(:high, :duplicate_fields, 'Duplicate field rows',
|
|
32
|
+
'Two rows for one field merge with mask AND-logic, so an unmasked duplicate unmasks the field. ' \
|
|
33
|
+
'Run `bin/rails generate permission_forge:upgrade` to de-duplicate and add the unique index.',
|
|
34
|
+
items: groups.map { |(resource_id, name), count| "#{names[resource_id]}.#{name}: #{count} rows" })
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def duplicate_actions
|
|
38
|
+
groups = PermissionForge::ResourceAction.group(:resource_id, :name).having('COUNT(*) > 1').count
|
|
39
|
+
return if groups.empty?
|
|
40
|
+
|
|
41
|
+
resources = PermissionForge::Resource.where(id: groups.keys.map(&:first)).includes(:permission_set).index_by(&:id)
|
|
42
|
+
finding(:medium, :duplicate_actions, 'Duplicate action rows',
|
|
43
|
+
'Two rows for one action are OR-ed, so a permitting duplicate permits what the other row blocks. ' \
|
|
44
|
+
'Remove the extra row; a unique index on (resource, name) keeps new ones out ' \
|
|
45
|
+
'(the install migration creates idx_pf_resource_actions_unique).',
|
|
46
|
+
items: groups.map do |(resource_id, name), count|
|
|
47
|
+
resource = resources[resource_id]
|
|
48
|
+
"#{resource&.permission_set&.name} › #{resource&.name}.#{name}: #{count} rows"
|
|
49
|
+
end)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def dependency_violations
|
|
53
|
+
items = resource_violations + field_violations
|
|
54
|
+
return if items.empty?
|
|
55
|
+
|
|
56
|
+
finding(:low, :dependency_violations, 'Grants that skip a required permission',
|
|
57
|
+
'Validations require read before edit / delete / mask, and View All before Modify All. ' \
|
|
58
|
+
'Fix these rows in the admin so they mean what they say.', items: items)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def resource_violations
|
|
62
|
+
granting_resources.flat_map do |resource|
|
|
63
|
+
violations(RESOURCE_RULES, resource).map { |what| "#{resource.permission_set.name} › #{resource.name}: #{what}" }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def field_violations
|
|
68
|
+
fields = PermissionForge::ResourceField.where(resource_id: granting_resources.map(&:id))
|
|
69
|
+
.where('edit_access = ? OR mask_access = ?', true, true)
|
|
70
|
+
.where(read_access: false).includes(resource: :permission_set)
|
|
71
|
+
fields.flat_map do |field|
|
|
72
|
+
resource = field.resource
|
|
73
|
+
violations(FIELD_RULES, field).map do |what|
|
|
74
|
+
"#{resource.permission_set.name} › #{resource.name}.#{field.name}: #{what}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def violations(rules, record)
|
|
80
|
+
rules.select { |_, broken| broken.call(record) }.keys
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def granting_resources
|
|
84
|
+
@granting_resources ||= PermissionForge::Resource.where(
|
|
85
|
+
permission_set_id: PermissionForge::PermissionSet.granting.select(:id)
|
|
86
|
+
).includes(:permission_set).to_a
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def unknown_models
|
|
90
|
+
names = PermissionForge::Resource.distinct.order(:name).pluck(:name).reject { |name| model?(name) }
|
|
91
|
+
return if names.empty?
|
|
92
|
+
|
|
93
|
+
finding(:low, :unknown_models, 'Resources for models this app does not have',
|
|
94
|
+
'No ActiveRecord class matches these names, so the grants do nothing here. If another app ' \
|
|
95
|
+
'shares these tables it may still use them — check there before deleting.', items: names)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def model?(name)
|
|
99
|
+
klass = name.safe_constantize
|
|
100
|
+
klass.is_a?(Class) && klass < ActiveRecord::Base
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|