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,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# Level 3 — Postgres Row-Level Security runtime plumbing.
|
|
5
|
+
#
|
|
6
|
+
# Policies are generated by {Services::RlsPolicyGenerator} and installed via
|
|
7
|
+
# `rails generate permission_forge:rls` (initial migration) or
|
|
8
|
+
# `rake permission_forge:rls:sync` (after rule edits). This module executes
|
|
9
|
+
# that DDL and manages the per-request session variable the policies read.
|
|
10
|
+
#
|
|
11
|
+
# Setting the user:
|
|
12
|
+
#
|
|
13
|
+
# # Block form — transaction-scoped (SET LOCAL), ideal for jobs/services:
|
|
14
|
+
# PermissionForge::RLS.with_user(user) { Customer.where(...) }
|
|
15
|
+
#
|
|
16
|
+
# # Controller form — session-scoped, cleared in ensure:
|
|
17
|
+
# around_action do |_controller, action|
|
|
18
|
+
# PermissionForge::RLS.set_user!(current_user)
|
|
19
|
+
# action.call
|
|
20
|
+
# ensure
|
|
21
|
+
# PermissionForge::RLS.clear_user!
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# Enforcement caveats (documented in docs/query-scoping.md): a superuser or
|
|
25
|
+
# BYPASSRLS role bypasses policies entirely — the app must connect as a
|
|
26
|
+
# regular role. Under RLS a denied row is invisible, so find_by reports
|
|
27
|
+
# missing (nil / RecordNotFound) rather than NotAuthorizedError.
|
|
28
|
+
#
|
|
29
|
+
# @api public
|
|
30
|
+
module RLS
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def postgres?
|
|
34
|
+
with_connection { |connection| connection.adapter_name.match?(/postgresql/i) }
|
|
35
|
+
rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ensure_postgres!
|
|
40
|
+
return if postgres?
|
|
41
|
+
|
|
42
|
+
raise PermissionForge::ConfigurationError,
|
|
43
|
+
I18n.t('permission_forge.errors.rls_requires_postgres',
|
|
44
|
+
default: 'PermissionForge RLS requires PostgreSQL — the current database ' \
|
|
45
|
+
'adapter does not support row-level security policies.')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Runs the block with the RLS session variable set transaction-locally
|
|
49
|
+
# (set_config(..., true) — automatically cleared when the transaction
|
|
50
|
+
# ends, so no reset bookkeeping and no leakage across pooled connections).
|
|
51
|
+
#
|
|
52
|
+
# The block runs on the connection the variable is set on: one the
|
|
53
|
+
# calling thread already holds, or one taken for the block and given
|
|
54
|
+
# back to the pool after it.
|
|
55
|
+
def with_user(user_or_id)
|
|
56
|
+
ensure_postgres!
|
|
57
|
+
with_connection do |connection|
|
|
58
|
+
connection.transaction do
|
|
59
|
+
apply_user(user_or_id, local: true, connection: connection)
|
|
60
|
+
yield
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Session-scoped variant for around_action use. MUST be paired with
|
|
66
|
+
# clear_user! in an ensure block — the setting survives on the pooled
|
|
67
|
+
# connection otherwise. Set on the connection the calling thread keeps
|
|
68
|
+
# (leased to it until the request ends), so the request's queries see it.
|
|
69
|
+
def set_user!(user_or_id)
|
|
70
|
+
ensure_postgres!
|
|
71
|
+
apply_user(user_or_id, local: false)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Clears the database session variable that carries the current user (PostgreSQL only).
|
|
75
|
+
# @return [void]
|
|
76
|
+
def clear_user!
|
|
77
|
+
return unless postgres?
|
|
78
|
+
|
|
79
|
+
run_set_config('', local: false)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Generates and executes RLS DDL for every protected model (enable/force +
|
|
83
|
+
# four policies + digest comment), one transaction per table.
|
|
84
|
+
#
|
|
85
|
+
# @return [Array<Hash>] per-table { table:, digest:, skipped_rules: }
|
|
86
|
+
def sync!
|
|
87
|
+
ensure_postgres!
|
|
88
|
+
protected_model_classes.map do |model_class|
|
|
89
|
+
result = Services::RlsPolicyGenerator.call(model_class: model_class)
|
|
90
|
+
with_connection { |connection| execute_in_transaction(connection, result[:statements]) }
|
|
91
|
+
log_skipped_rules(result)
|
|
92
|
+
result.slice(:table, :digest, :skipped_rules)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Drops PermissionForge policies and disables RLS on every protected table
|
|
97
|
+
# (used by the generated migration's `down`).
|
|
98
|
+
#
|
|
99
|
+
# @return [Array<String>] the table names cleaned up
|
|
100
|
+
def remove!
|
|
101
|
+
ensure_postgres!
|
|
102
|
+
protected_model_classes.map do |model_class|
|
|
103
|
+
with_connection { |connection| drop_policies(connection, model_class.table_name) }
|
|
104
|
+
model_class.table_name
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Compares each protected table's live policy digest with the digest the
|
|
109
|
+
# current rule state would produce. A missing policy reports out-of-sync
|
|
110
|
+
# (stored: nil) — enabling Level 3 without installing policies is itself
|
|
111
|
+
# a misconfiguration worth surfacing.
|
|
112
|
+
#
|
|
113
|
+
# @return [Array<Hash>] { table:, expected:, stored:, in_sync: }
|
|
114
|
+
def drift_report
|
|
115
|
+
protected_model_classes.map do |model_class|
|
|
116
|
+
expected = Services::RlsPolicyGenerator.new(model_class: model_class).digest
|
|
117
|
+
stored = stored_digest(model_class.table_name)
|
|
118
|
+
{ table: model_class.table_name, expected: expected, stored: stored, in_sync: expected == stored }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @param table_name [String]
|
|
123
|
+
# @return [String, nil] the digest of the policy installed on the table
|
|
124
|
+
# @api private
|
|
125
|
+
def stored_digest(table_name)
|
|
126
|
+
row = with_connection do |connection|
|
|
127
|
+
connection.select_value(<<~SQL)
|
|
128
|
+
SELECT d.description
|
|
129
|
+
FROM pg_policy pol
|
|
130
|
+
JOIN pg_class c ON c.oid = pol.polrelid
|
|
131
|
+
JOIN pg_description d ON d.objoid = pol.oid
|
|
132
|
+
WHERE c.relname = #{connection.quote(table_name)}
|
|
133
|
+
AND pol.polname = #{connection.quote(Services::RlsPolicyGenerator::POLICY_NAMES[:select])}
|
|
134
|
+
SQL
|
|
135
|
+
end
|
|
136
|
+
row&.delete_prefix(Services::RlsPolicyGenerator::DIGEST_COMMENT_PREFIX)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @return [Array<Class>] the models that include PermissionScopedFindable
|
|
140
|
+
# @api private
|
|
141
|
+
def protected_model_classes
|
|
142
|
+
Concerns::PermissionScopedFindable.protected_model_names.filter_map(&:safe_constantize)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# A connection for one unit of work: the one the calling thread holds,
|
|
146
|
+
# else one from the pool that goes back to it when the block ends.
|
|
147
|
+
# (`ActiveRecord::Base.connection` is soft-deprecated from Rails 7.2 and
|
|
148
|
+
# would keep a connection leased to the thread.)
|
|
149
|
+
def with_connection(&)
|
|
150
|
+
ActiveRecord::Base.connection_pool.with_connection(&)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The connection the calling thread keeps until it is released (the end
|
|
154
|
+
# of the request, or of the job): where a session-scoped setting has to
|
|
155
|
+
# live. `lease_connection` from Rails 7.2; on 7.1 that is `connection`.
|
|
156
|
+
def leased_connection
|
|
157
|
+
pool = ActiveRecord::Base.connection_pool
|
|
158
|
+
pool.respond_to?(:lease_connection) ? pool.lease_connection : pool.connection
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def execute_in_transaction(connection, statements)
|
|
162
|
+
connection.transaction do
|
|
163
|
+
statements.each { |statement| connection.execute(statement) }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def drop_policies(connection, table_name)
|
|
168
|
+
table = connection.quote_table_name(table_name)
|
|
169
|
+
connection.transaction do
|
|
170
|
+
Services::RlsPolicyGenerator::POLICY_NAMES.each_value do |name|
|
|
171
|
+
connection.execute("DROP POLICY IF EXISTS #{name} ON #{table}")
|
|
172
|
+
end
|
|
173
|
+
connection.execute("ALTER TABLE #{table} NO FORCE ROW LEVEL SECURITY")
|
|
174
|
+
connection.execute("ALTER TABLE #{table} DISABLE ROW LEVEL SECURITY")
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# `connection` defaults to the one the calling thread keeps (set_user!).
|
|
179
|
+
def apply_user(user_or_id, local:, connection: leased_connection)
|
|
180
|
+
id = user_or_id.respond_to?(:id) ? user_or_id.id : user_or_id
|
|
181
|
+
run_set_config(id.to_s, local: local, connection: connection)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# set_config avoids interpolating the setting name as an identifier —
|
|
185
|
+
# both the name and value travel as quoted literals.
|
|
186
|
+
def run_set_config(value, local:, connection: leased_connection)
|
|
187
|
+
var = PermissionForge.configuration.rls_session_variable
|
|
188
|
+
connection.execute(
|
|
189
|
+
"SELECT set_config(#{connection.quote(var)}, #{connection.quote(value)}, #{local})"
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Logs the rules a policy sync left out because they cannot be expressed in SQL.
|
|
194
|
+
# @param result [Hash] the sync result
|
|
195
|
+
# @api private
|
|
196
|
+
def log_skipped_rules(result)
|
|
197
|
+
return if result[:skipped_rules].empty?
|
|
198
|
+
|
|
199
|
+
Rails.logger&.warn(
|
|
200
|
+
"[PermissionForge] RLS sync for '#{result[:table]}' skipped " \
|
|
201
|
+
"#{result[:skipped_rules].size} untranslatable rule(s) " \
|
|
202
|
+
"(#{result[:skipped_rules].map { |r| "#{r[:rule]}: #{r[:reason]}" }.join(', ')}) — " \
|
|
203
|
+
'join-type rules, dotted field names and rules on an STI subclass resource are enforced at the application layer only.'
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private_class_method :with_connection, :leased_connection, :execute_in_transaction, :drop_policies
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
module PermissionForge
|
|
2
|
+
module Services
|
|
3
|
+
# Decides what value an attribute reader should return for a given user, based on
|
|
4
|
+
# the user's field permissions. Used by {PermissionForge::Concerns::MaskedAttributes}.
|
|
5
|
+
#
|
|
6
|
+
# Returns a `[include?, value]` tuple so callers can distinguish "omit this key from
|
|
7
|
+
# the hash" from "substitute this value in place." This mirrors the asymmetry between
|
|
8
|
+
# Salesforce's `Security.stripInaccessible()` (omits the field) and a direct getter
|
|
9
|
+
# call (which has to return *something*).
|
|
10
|
+
#
|
|
11
|
+
# @example Single attribute
|
|
12
|
+
# include?, value = AttributeAccessGuard.call(
|
|
13
|
+
# resource_name: :Customer, attribute: :email, raw_value: 'a@b.com', user: current_user
|
|
14
|
+
# )
|
|
15
|
+
#
|
|
16
|
+
# Every decision is reported for the field.access audit event
|
|
17
|
+
# (Audit::FieldAccess), which also calls the legacy on_field_filtered hook:
|
|
18
|
+
# to `observer:` — the frame of the record read in progress, or nil when
|
|
19
|
+
# nobody listens (MaskedAttributes) — or, by default, to whatever read is
|
|
20
|
+
# in progress on this thread (a call of its own is a read of its own).
|
|
21
|
+
#
|
|
22
|
+
# `resource_name:` is one Resource name, or the chain of names the
|
|
23
|
+
# record's class answers to (ResourceChain: its own and its STI
|
|
24
|
+
# ancestors', nearest first). The field is looked up along the chain; the
|
|
25
|
+
# decision is reported under the first name, the record's own.
|
|
26
|
+
#
|
|
27
|
+
# @api private
|
|
28
|
+
class AttributeAccessGuard
|
|
29
|
+
ACTIONS = %i[hidden masked passed bypassed unconfigured].freeze
|
|
30
|
+
|
|
31
|
+
# The default observer: Audit::FieldAccess.note.
|
|
32
|
+
STANDALONE = Object.new.freeze
|
|
33
|
+
|
|
34
|
+
# Called on every guarded read: the instance is set up without passing
|
|
35
|
+
# the keywords on a second time (half the cost of `new(...).call`).
|
|
36
|
+
# rubocop:disable Metrics/ParameterLists -- model: the record's class, so a read with no user looks up no constant
|
|
37
|
+
def self.call(resource_name:, attribute:, raw_value:, user: nil, observer: STANDALONE, model: nil)
|
|
38
|
+
allocate.decide(resource_name, attribute, raw_value, user, observer, model)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param resource_name [Symbol, String, Array<Symbol>] a name, or the chain
|
|
42
|
+
# @param observer [Audit::FieldAccess::Frame, nil] where decisions go
|
|
43
|
+
# @param model [Class, nil] the record's class (default: the class the
|
|
44
|
+
# first name names)
|
|
45
|
+
def initialize(resource_name:, attribute:, raw_value:, user: nil, observer: STANDALONE, model: nil)
|
|
46
|
+
assign(resource_name, attribute, raw_value, user, observer)
|
|
47
|
+
@model = model
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# .call on an allocated instance. @api private
|
|
51
|
+
def decide(resource_name, attribute, raw_value, user, observer, model = nil)
|
|
52
|
+
assign(resource_name, attribute, raw_value, user, observer)
|
|
53
|
+
@model = model
|
|
54
|
+
call
|
|
55
|
+
end
|
|
56
|
+
# rubocop:enable Metrics/ParameterLists
|
|
57
|
+
|
|
58
|
+
# @return [Array(Boolean, Object)] tuple of (include_in_hash?, value_to_return)
|
|
59
|
+
def call
|
|
60
|
+
return without_user unless @user
|
|
61
|
+
return unconfigured unless field
|
|
62
|
+
|
|
63
|
+
return hidden unless field[:read_access]
|
|
64
|
+
return masked if field[:mask_access]
|
|
65
|
+
|
|
66
|
+
passed
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def assign(resource_name, attribute, raw_value, user, observer)
|
|
72
|
+
@resource_names = resource_name.is_a?(Array) ? resource_name : resource_name.to_sym
|
|
73
|
+
@resource_name = resource_name.is_a?(Array) ? resource_name.first : @resource_names
|
|
74
|
+
@attribute = attribute.to_sym
|
|
75
|
+
@raw_value = raw_value
|
|
76
|
+
@user = user || PermissionForge.current_user
|
|
77
|
+
@observer = observer
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def field
|
|
81
|
+
return @field if defined?(@field)
|
|
82
|
+
|
|
83
|
+
@field = PermissionForge::ResourceChain.field_permissions(@user, @resource_names, @attribute)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Without a user there is no entry to decide by:
|
|
87
|
+
# config.masked_attributes_nil_user_strategy says whether every
|
|
88
|
+
# attribute passes (:bypass, the default) or is hidden (:hide) — except
|
|
89
|
+
# on the user model, which sign-in reads before anyone is signed in.
|
|
90
|
+
# The model is the record's class, or else the one the first name of the
|
|
91
|
+
# chain names (an anonymous subclass answers as its base).
|
|
92
|
+
def without_user
|
|
93
|
+
model = @model || @resource_name.to_s.safe_constantize
|
|
94
|
+
PermissionForge.configuration.hide_masked_attributes_without_user_for?(model) ? hidden : bypass
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def bypass
|
|
98
|
+
notify(:bypassed)
|
|
99
|
+
[true, @raw_value]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def hidden
|
|
103
|
+
notify(:hidden)
|
|
104
|
+
[false, PermissionForge.configuration.hidden_attribute_value]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def masked
|
|
108
|
+
notify(:masked)
|
|
109
|
+
strategy = field[:mask_strategy] || 'full'
|
|
110
|
+
[true, MaskValue.call(@raw_value, strategy: strategy)]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def passed
|
|
114
|
+
notify(:passed)
|
|
115
|
+
[true, @raw_value]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def unconfigured
|
|
119
|
+
strategy_key = PermissionForge.configuration.unconfigured_field_strategy
|
|
120
|
+
# Unknown strategy keys fall back to :block (fail closed), never pass-through.
|
|
121
|
+
strategy = FiltersResponseAttributes::UNCONFIGURED_FIELD_STRATEGIES.fetch(
|
|
122
|
+
strategy_key,
|
|
123
|
+
FiltersResponseAttributes::UNCONFIGURED_FIELD_STRATEGIES[:block]
|
|
124
|
+
)
|
|
125
|
+
include_field, value = strategy.call(@resource_name.to_s, @attribute, @raw_value)
|
|
126
|
+
notify(:unconfigured)
|
|
127
|
+
[include_field, value]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def notify(action)
|
|
131
|
+
return if @observer.nil?
|
|
132
|
+
return PermissionForge::Audit::FieldAccess.note(@resource_name, @attribute, action, @user) if @observer.equal?(STANDALONE)
|
|
133
|
+
|
|
134
|
+
@observer.add(@resource_name, @attribute, action, @user)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
# Retention: deletes audit events older than the retention period and
|
|
6
|
+
# records that it did (`audit.purged`: count, cutoff, period, batch).
|
|
7
|
+
#
|
|
8
|
+
# AuditEventPurger.call # config.audit_retention_period
|
|
9
|
+
# AuditEventPurger.call(older_than: 400.days, actor: 'nightly job')
|
|
10
|
+
# AuditEventPurger.call(dry_run: true) # counts, writes nothing
|
|
11
|
+
#
|
|
12
|
+
# Only events whose `occurred_at` is before now - period are deleted — an
|
|
13
|
+
# event inside the period is never touched, and neither is an
|
|
14
|
+
# `audit.purged` event (the record of purges outlives them). A Duration
|
|
15
|
+
# is subtracted by the calendar (30.days across a DST change, 1.year),
|
|
16
|
+
# a number is seconds. No period (nil: the default) deletes nothing and
|
|
17
|
+
# says so. With auditing off (nothing could record the purge) a real run
|
|
18
|
+
# raises ConfigurationError.
|
|
19
|
+
#
|
|
20
|
+
# Each batch is `delete_all` plus its own `audit.purged` event in ONE
|
|
21
|
+
# transaction: a batch whose event cannot be written is not deleted,
|
|
22
|
+
# under every audit_change_failure_strategy (a purge is always fail-closed),
|
|
23
|
+
# so no deletion is ever unrecorded. In a tenant context only that tenant's events are
|
|
24
|
+
# purged; with none, all of them. A run that finds nothing records nothing.
|
|
25
|
+
#
|
|
26
|
+
# A purge with no tenant (the rake task, a platform job) records one
|
|
27
|
+
# `audit.purged` per tenant whose events the batch deleted, in that
|
|
28
|
+
# tenant and with that tenant's count (`across_tenants: true`), plus one
|
|
29
|
+
# without a tenant for the events that had none: a tenant's auditors
|
|
30
|
+
# never see an event without a tenant, and must still see that their
|
|
31
|
+
# log was purged. Other tenants' counts are not in it.
|
|
32
|
+
#
|
|
33
|
+
# Reached only from Ruby and `rake permission_forge:audit_events:purge`:
|
|
34
|
+
# there is deliberately no controller, route or API for it.
|
|
35
|
+
#
|
|
36
|
+
# @api public
|
|
37
|
+
class AuditEventPurger
|
|
38
|
+
include Concerns::AuditedOperation
|
|
39
|
+
|
|
40
|
+
BATCH_SIZE = 1000
|
|
41
|
+
EVENT = 'audit.purged'
|
|
42
|
+
|
|
43
|
+
# Deletes audit events past the retention period.
|
|
44
|
+
# @return [Hash] the counts of the run
|
|
45
|
+
def self.call(...) = new(...).call
|
|
46
|
+
|
|
47
|
+
# @param older_than [ActiveSupport::Duration, Numeric, nil] the retention period
|
|
48
|
+
# @param actor [Object, nil] a user, an Audit::Actor or a label String
|
|
49
|
+
# @param batch_size [Integer] rows deleted per statement (>= 1)
|
|
50
|
+
# @param now [Time] the clock (tests)
|
|
51
|
+
# @raise [PermissionForge::ConfigurationError] for a period that is not valid
|
|
52
|
+
# @raise [ArgumentError] for a batch_size below 1
|
|
53
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
54
|
+
def initialize(older_than: PermissionForge.configuration.audit_retention_period, actor: nil, reason: nil,
|
|
55
|
+
dry_run: false, batch_size: BATCH_SIZE, now: Time.current)
|
|
56
|
+
raise ArgumentError, "batch_size must be at least 1 (got #{batch_size.inspect})" unless batch_size.is_a?(Integer) && batch_size >= 1
|
|
57
|
+
|
|
58
|
+
@period = PermissionForge::Configuration::AuditSettings.retention_period(older_than)
|
|
59
|
+
@actor = actor
|
|
60
|
+
@reason = reason
|
|
61
|
+
@dry_run = dry_run
|
|
62
|
+
@batch_size = batch_size
|
|
63
|
+
@now = now
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Hash] { purged:, retention_configured:, cutoff:, period:, dry_run: } (+ message: when not configured)
|
|
67
|
+
def call
|
|
68
|
+
return not_configured_result if @period.nil?
|
|
69
|
+
|
|
70
|
+
cutoff = @now - @period
|
|
71
|
+
return dry_run_result(cutoff) if @dry_run
|
|
72
|
+
|
|
73
|
+
refuse_without_auditing!
|
|
74
|
+
audited(actor: @actor, reason: @reason) { purge(cutoff) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def dry_run_result(cutoff)
|
|
80
|
+
{ purged: expired(cutoff).count, retention_configured: true, cutoff: cutoff, period: @period.to_i, dry_run: true }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def not_configured_result
|
|
84
|
+
{ purged: 0, retention_configured: false, dry_run: @dry_run, message: 'retention not configured' }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def refuse_without_auditing!
|
|
88
|
+
return if PermissionForge::Audit.enabled?
|
|
89
|
+
|
|
90
|
+
raise PermissionForge::ConfigurationError,
|
|
91
|
+
'audit events are switched off (or their table is missing): a purge would be unrecorded, so it is refused'
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def expired(cutoff)
|
|
95
|
+
PermissionForge::AuditEvent.unscoped.in_tenant(PermissionForge.ambient_tenant_id)
|
|
96
|
+
.where(PermissionForge::AuditEvent.arel_table[:occurred_at].lt(cutoff))
|
|
97
|
+
.where.not(event: EVENT)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def purge(cutoff)
|
|
101
|
+
purged = 0
|
|
102
|
+
batch = 0
|
|
103
|
+
loop do
|
|
104
|
+
ids = expired(cutoff).order(:id).limit(@batch_size).pluck(:id)
|
|
105
|
+
break if ids.empty?
|
|
106
|
+
|
|
107
|
+
batch += 1
|
|
108
|
+
purged += purge_batch(ids, cutoff, batch)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
{ purged: purged, retention_configured: true, cutoff: cutoff, period: @period.to_i, dry_run: false }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The deletion and its records commit together.
|
|
115
|
+
def purge_batch(ids, cutoff, batch)
|
|
116
|
+
PermissionForge::AuditEvent.transaction do
|
|
117
|
+
per_tenant = counts_by_tenant(ids)
|
|
118
|
+
deleted = PermissionForge::AuditEvent.unscoped.where(id: ids).delete_all
|
|
119
|
+
record_batch(per_tenant || { nil => deleted }, summary_data(cutoff, batch, across_tenants: !per_tenant.nil?))
|
|
120
|
+
deleted
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def record_batch(per_tenant, data)
|
|
125
|
+
per_tenant.each do |tenant_id, count|
|
|
126
|
+
# fail closed whatever audit_change_failure_strategy says: a nil
|
|
127
|
+
# (:report / :log swallowed a failed write) rolls the batch back
|
|
128
|
+
recorded = PermissionForge::Audit.record(EVENT, tenant_id: tenant_id, data: data.merge('count' => count))
|
|
129
|
+
raise PermissionForge::Audit::EventNotRecordedError.new(event: EVENT) if recorded.nil?
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# { tenant id => events of the batch } for a purge with no tenant
|
|
134
|
+
# ambient; nil (one record, in the ambient tenant) otherwise.
|
|
135
|
+
def counts_by_tenant(ids)
|
|
136
|
+
column = PermissionForge::AuditEvent.tenant_column
|
|
137
|
+
return if column.nil? || PermissionForge.ambient_tenant_id
|
|
138
|
+
|
|
139
|
+
PermissionForge::AuditEvent.unscoped.where(id: ids).group(column).count
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def summary_data(cutoff, batch, across_tenants:)
|
|
143
|
+
data = { 'operation' => 'audit_events_purge', 'count' => 0, 'batch' => batch,
|
|
144
|
+
'cutoff' => cutoff.utc.iso8601(6), 'period_seconds' => @period.to_i, 'dry_run' => false }
|
|
145
|
+
data['across_tenants'] = true if across_tenants
|
|
146
|
+
data
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
module PermissionForge
|
|
2
|
+
module Services
|
|
3
|
+
# Assigns or revokes a permission set for multiple users in a single
|
|
4
|
+
# transaction. Each assignment created or destroyed invalidates its
|
|
5
|
+
# user's cached permissions after the commit (PermissionSetUser's own
|
|
6
|
+
# after_commit), once per user; each user's hash is rebuilt read-through
|
|
7
|
+
# on their next request, in their own context.
|
|
8
|
+
#
|
|
9
|
+
# @example Bulk assign
|
|
10
|
+
# result = BulkPermissionSetAssignment.call(
|
|
11
|
+
# permission_set: ps, user_ids: [1, 2, 3], action: :assign
|
|
12
|
+
# )
|
|
13
|
+
# # => { assigned: 3, revoked: 0, invalidated: 3 }
|
|
14
|
+
#
|
|
15
|
+
# `invalidated` counts the users assigned or revoked whose invalidation
|
|
16
|
+
# the cache store confirmed. Fewer means the store failed for the rest
|
|
17
|
+
# (reported through `config.on_cache_failure`): their cached permissions
|
|
18
|
+
# may live until cache_ttl. A user who already held the set (assign) or
|
|
19
|
+
# never did (revoke) changed nothing and is not invalidated. Inside a
|
|
20
|
+
# transaction of the caller the invalidations happen at its commit, after
|
|
21
|
+
# this returns: `invalidated` is then nil (not known).
|
|
22
|
+
#
|
|
23
|
+
# Every row is created or destroyed through its callbacks, so each person
|
|
24
|
+
# gets an assignment.granted / assignment.revoked audit event, all with
|
|
25
|
+
# one correlation id (the transaction's). The reason (a ticket, free
|
|
26
|
+
# text) comes from the surrounding Audit::Context.
|
|
27
|
+
#
|
|
28
|
+
# @api public
|
|
29
|
+
class BulkPermissionSetAssignment
|
|
30
|
+
ACTION_STRATEGIES = {
|
|
31
|
+
assign: :assign_users,
|
|
32
|
+
revoke: :revoke_users
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
VALID_ACTIONS = ACTION_STRATEGIES.keys.freeze
|
|
36
|
+
|
|
37
|
+
# Assigns or revokes a Permission Set for many users in one transaction.
|
|
38
|
+
# @return [Hash] `{ assigned:, revoked:, invalidated: }`
|
|
39
|
+
def self.call(...) = new(...).call
|
|
40
|
+
|
|
41
|
+
# @raise [ArgumentError] unless `action` is one of VALID_ACTIONS
|
|
42
|
+
def self.validate_action!(action)
|
|
43
|
+
action = action.presence&.to_sym
|
|
44
|
+
return action if VALID_ACTIONS.include?(action)
|
|
45
|
+
|
|
46
|
+
raise ArgumentError, "Invalid action: #{action}. Must be one of: #{VALID_ACTIONS.join(', ')}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def initialize(permission_set:, user_ids:, action:, created_by: nil, expires_at: nil)
|
|
50
|
+
@permission_set = permission_set
|
|
51
|
+
# Host keys as given: `to_i` would turn a UUID into its leading digits
|
|
52
|
+
# and assign whoever owns that number.
|
|
53
|
+
@user_ids = Array(user_ids).compact_blank.map(&:to_s).uniq
|
|
54
|
+
@action = action&.to_sym
|
|
55
|
+
@created_by = created_by
|
|
56
|
+
@expires_at = expires_at
|
|
57
|
+
@assigned_count = 0
|
|
58
|
+
@revoked_count = 0
|
|
59
|
+
@changed_user_ids = []
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Hash] `{ assigned:, revoked:, invalidated: }`
|
|
63
|
+
def call
|
|
64
|
+
validate_action!
|
|
65
|
+
|
|
66
|
+
deferred = PermissionForge::OpenTransaction.current
|
|
67
|
+
failures = PermissionForge::Cache::Failure.collect do
|
|
68
|
+
ActiveRecord::Base.transaction { send(ACTION_STRATEGIES.fetch(@action)) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# what the store confirmed (Cache::Failure) — not what a host's override of invalidate_permissions returns
|
|
72
|
+
{ assigned: @assigned_count, revoked: @revoked_count, invalidated: deferred ? nil : failures.confirmed(@changed_user_ids).size }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def validate_action!
|
|
78
|
+
self.class.validate_action!(@action)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def assign_users
|
|
82
|
+
users = PermissionForge.configuration.user_model.where(id: @user_ids)
|
|
83
|
+
existing_user_ids = @permission_set.permission_set_users.where(user_id: @user_ids).pluck(:user_id)
|
|
84
|
+
|
|
85
|
+
users.each do |user|
|
|
86
|
+
next if existing_user_ids.include?(user.id)
|
|
87
|
+
|
|
88
|
+
@permission_set.permission_set_users.create!(
|
|
89
|
+
user: user,
|
|
90
|
+
expires_at: @expires_at,
|
|
91
|
+
created_by_id: @created_by&.id,
|
|
92
|
+
updated_by_id: @created_by&.id
|
|
93
|
+
)
|
|
94
|
+
@assigned_count += 1
|
|
95
|
+
@changed_user_ids << user.id
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Through callbacks, as destroy_all; a row a callback refuses to
|
|
100
|
+
# destroy (destroy answers false) is neither revoked nor counted.
|
|
101
|
+
def revoke_users
|
|
102
|
+
revoked = @permission_set.permission_set_users.where(user_id: @user_ids).to_a.select(&:destroy)
|
|
103
|
+
@revoked_count = revoked.size
|
|
104
|
+
@changed_user_ids = revoked.map(&:user_id).uniq
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Services
|
|
5
|
+
# @api private
|
|
6
|
+
module Concerns
|
|
7
|
+
# A housekeeping service run with no tenant ambient (a rake task, a
|
|
8
|
+
# console, a platform job) works on every tenant's rows; with a tenant
|
|
9
|
+
# ambient, on that tenant's only.
|
|
10
|
+
#
|
|
11
|
+
# #across_tenants lifts acts_as_tenant's scope for the tenant-less run,
|
|
12
|
+
# as PermissionedUser's destroy and PermissionCacheClearer do, so
|
|
13
|
+
# `require_tenant = true` does not refuse it with NoTenantSet; with a
|
|
14
|
+
# tenant ambient, or tenancy off, the block runs as it is. No hard
|
|
15
|
+
# dependency on acts_as_tenant: without it there is nothing to lift.
|
|
16
|
+
#
|
|
17
|
+
# #record_per_tenant writes a run's summary event the way audit.purged
|
|
18
|
+
# and cache.cleared are written: with a tenant ambient, once (in it);
|
|
19
|
+
# tenant-less, once in each tenant the run touched, with that tenant's
|
|
20
|
+
# rows, plus the total without a tenant — each marked across_tenants —
|
|
21
|
+
# so every tenant's auditors see what was done in their tenant.
|
|
22
|
+
module AcrossTenants
|
|
23
|
+
# One summary event of a run over `rows`; `data` builds the event's
|
|
24
|
+
# data from the rows it covers.
|
|
25
|
+
Summary = Struct.new(:event, :rows, :payload, :data) do
|
|
26
|
+
def record(covered = rows, tenant_id: nil, more: {})
|
|
27
|
+
PermissionForge::Audit.record(event, **payload, tenant_id: tenant_id, data: data.call(covered).merge(more))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# In each tenant with its rows, then the total without a tenant.
|
|
31
|
+
def record_per_tenant(column)
|
|
32
|
+
by_tenant = rows.select { |row| row.has_attribute?(column) && !row[column].nil? }.group_by { |row| row[column] }
|
|
33
|
+
by_tenant.each { |tenant_id, tenant_rows| record(tenant_rows, tenant_id: tenant_id, more: { 'across_tenants' => true }) }
|
|
34
|
+
record(more: { 'across_tenants' => true, 'tenants' => by_tenant.size })
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def across_tenants(&)
|
|
41
|
+
return yield unless tenantless_run?
|
|
42
|
+
|
|
43
|
+
::ActsAsTenant.without_tenant(&)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Tenancy is on and no tenant is ambient.
|
|
47
|
+
def tenantless_run?
|
|
48
|
+
PermissionForge.configuration.tenant_enabled? && defined?(::ActsAsTenant) && PermissionForge.ambient_tenant.nil?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param event [String] the summary event
|
|
52
|
+
# @param rows [Array<ActiveRecord::Base>] the rows the run changed
|
|
53
|
+
# @param payload [Hash] more of Audit.record's payload (subject_user_id: …)
|
|
54
|
+
# @yieldparam rows [Array<ActiveRecord::Base>] the rows of one tenant, or all of them
|
|
55
|
+
# @yieldreturn [Hash] the event's data for those rows
|
|
56
|
+
def record_per_tenant(event, rows, **payload, &data)
|
|
57
|
+
summary = Summary.new(event, rows, payload, data)
|
|
58
|
+
return summary.record unless tenantless_run?
|
|
59
|
+
|
|
60
|
+
summary.record_per_tenant(tenant_column)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def tenant_column
|
|
64
|
+
PermissionForge.configuration.resolved_tenant_column.to_s
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|