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,337 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Concerns
|
|
5
|
+
# Enforces row-level permission rules on including models.
|
|
6
|
+
#
|
|
7
|
+
# When a current user is set via {PermissionForge.current_user}, `find_by` and
|
|
8
|
+
# `find_by!` apply the user's cached permission set rules before returning a
|
|
9
|
+
# record, and `permission_scoped` returns a rule-constrained relation you can
|
|
10
|
+
# chain (`.where`, `.first`, `.find`, `.count`, …). If view_all or modify_all
|
|
11
|
+
# is enabled for the resource, rules are bypassed entirely.
|
|
12
|
+
#
|
|
13
|
+
# Level 1 — ENFORCE (config.auto_query_scoping = :filter_only): a
|
|
14
|
+
# default_scope applies the current user's FILTER-type rules to every
|
|
15
|
+
# relation built on the model (`where`, `all`, `find`, `first`, `count`,
|
|
16
|
+
# `pluck`, association traversal). Join-type rules are NOT auto-applied and
|
|
17
|
+
# still require `permission_scoped`. Denied rows are silently filtered
|
|
18
|
+
# (`find` raises RecordNotFound); attributed denials (NotAuthorizedError
|
|
19
|
+
# with rule messages) remain the job of `find_by` / `permission_scoped`.
|
|
20
|
+
#
|
|
21
|
+
# With auto-scoping :off (default), `find` (by primary key), bare `where`,
|
|
22
|
+
# `first`, `all`, and association traversal are NOT auto-scoped — use
|
|
23
|
+
# `find_by` / `find_by!` for single-record lookups, or start from
|
|
24
|
+
# `Model.permission_scoped` to carry the rules through a query chain.
|
|
25
|
+
# Level 2 (config.unscoped_query_detection) can log or raise on such
|
|
26
|
+
# unscoped queries to help find and prevent them.
|
|
27
|
+
#
|
|
28
|
+
# Escape hatches: `PermissionForge.without_permission_scope { ... }`
|
|
29
|
+
# (block-scoped, all levels) and `self.permission_forge_auto_scope = false`
|
|
30
|
+
# (per-model opt-out of Level 1).
|
|
31
|
+
#
|
|
32
|
+
# Query building is delegated to {PermissionForge::Services::PermissionRuleApplier}.
|
|
33
|
+
#
|
|
34
|
+
# @example
|
|
35
|
+
# class Customer < ApplicationRecord
|
|
36
|
+
# include PermissionForge::Concerns::PermissionScopedFindable
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# Customer.find_by(id: 1) # rules applied; nil if denied/missing
|
|
40
|
+
# Customer.find_by!(id: 1) # rules applied; raises if denied/missing
|
|
41
|
+
# Customer.permission_scoped.where(active: true).order(:name) # rules carried through
|
|
42
|
+
#
|
|
43
|
+
# @api public
|
|
44
|
+
module PermissionScopedFindable
|
|
45
|
+
extend ActiveSupport::Concern
|
|
46
|
+
|
|
47
|
+
include AutoPermissionScoping
|
|
48
|
+
|
|
49
|
+
# SQL annotation marking a query as permission-scoped. Level 2 detection
|
|
50
|
+
# treats any protected-table statement carrying this marker as compliant.
|
|
51
|
+
SCOPED_ANNOTATION = 'pf_scoped'
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
# Names of models that include this concern. Level 2 detection resolves
|
|
55
|
+
# the protected table set from this registry.
|
|
56
|
+
#
|
|
57
|
+
# @return [Set<String>]
|
|
58
|
+
def protected_model_names
|
|
59
|
+
@protected_model_names ||= Set.new
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Table names for all protected models, resolved lazily so that
|
|
63
|
+
# table_name_prefix configuration and class reloading can't produce
|
|
64
|
+
# stale names.
|
|
65
|
+
#
|
|
66
|
+
# @return [Array<String>]
|
|
67
|
+
def protected_table_names
|
|
68
|
+
protected_model_names.filter_map do |model_name|
|
|
69
|
+
model_name.safe_constantize&.table_name
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
included do
|
|
75
|
+
PermissionScopedFindable.protected_model_names << name if name
|
|
76
|
+
|
|
77
|
+
# Per-model opt-out of Level 1 auto-scoping.
|
|
78
|
+
class_attribute :permission_forge_auto_scope, default: true, instance_accessor: false
|
|
79
|
+
|
|
80
|
+
# Level 1 — ENFORCE. Installed unconditionally at include time; every
|
|
81
|
+
# enable/off decision happens INSIDE the block at query time (config is
|
|
82
|
+
# read per relation build), so initializer-vs-model load order can never
|
|
83
|
+
# leave a model silently unscoped. With auto_query_scoping :off the
|
|
84
|
+
# block early-returns after two cheap reads.
|
|
85
|
+
default_scope { pf_auto_permission_scope }
|
|
86
|
+
|
|
87
|
+
class << self
|
|
88
|
+
# `find_by` constrained by the current user's row-level rules.
|
|
89
|
+
# @return [ActiveRecord::Base, nil] nil for a missing record
|
|
90
|
+
# @raise [NotAuthorizedError] when a rule denies an existing record
|
|
91
|
+
def find_by(*args)
|
|
92
|
+
find_by_with_permission(*args)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# `find_by!` constrained by the current user's row-level rules.
|
|
96
|
+
# @raise [ActiveRecord::RecordNotFound] for a missing record
|
|
97
|
+
# @raise [NotAuthorizedError] when a rule denies an existing record
|
|
98
|
+
def find_by!(*args)
|
|
99
|
+
find_by_with_permission(*args) || raise(ActiveRecord::RecordNotFound)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Raised when a row-level rule denies an existing record (find_by /
|
|
105
|
+
# find_by!) or there is no user under nil_user_strategy :raise.
|
|
106
|
+
#
|
|
107
|
+
# `user` is the user the rules were applied for, `record_id` the id of
|
|
108
|
+
# the record that was denied. `record` is nil when the gem raises: the
|
|
109
|
+
# row the user may not see is not loaded to be handed out with the
|
|
110
|
+
# error. All three are optional keywords; `NotAuthorizedError.new(details)`
|
|
111
|
+
# and `raise NotAuthorizedError, details` work as before.
|
|
112
|
+
#
|
|
113
|
+
# `details` keeps whatever was raised with (the gem raises the Array of
|
|
114
|
+
# rule failure messages; a Hash with :message for a missing user).
|
|
115
|
+
# `message`, `to_s` and `full_message` are always a String, as every
|
|
116
|
+
# error reporter and `rescue_from` handler expects: the failure
|
|
117
|
+
# messages joined, the :message of a Hash, or the class name.
|
|
118
|
+
#
|
|
119
|
+
# @api public
|
|
120
|
+
class NotAuthorizedError < StandardError
|
|
121
|
+
# Why the lookup was refused.
|
|
122
|
+
# @return [Hash, nil]
|
|
123
|
+
attr_reader :details, :user, :record, :record_id
|
|
124
|
+
|
|
125
|
+
NO_DETAILS = Object.new.freeze
|
|
126
|
+
READERS = %i[user record record_id].freeze
|
|
127
|
+
private_constant :NO_DETAILS, :READERS
|
|
128
|
+
|
|
129
|
+
# The String a details value reads as.
|
|
130
|
+
def self.text_for(details)
|
|
131
|
+
case details
|
|
132
|
+
when String then details
|
|
133
|
+
when Array then details.map { |item| text_for(item) }.join(', ')
|
|
134
|
+
when Hash then hash_text(details)
|
|
135
|
+
else details&.to_s
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The :message of a Hash, else the Hash without the user, record and
|
|
140
|
+
# record_id keywords (a user's inspect must not land in an error
|
|
141
|
+
# tracker), else nothing.
|
|
142
|
+
def self.hash_text(details)
|
|
143
|
+
text = text_for(details[:message] || details['message'])
|
|
144
|
+
return text if text
|
|
145
|
+
|
|
146
|
+
rest = details.except(*READERS)
|
|
147
|
+
rest.to_s unless rest.empty?
|
|
148
|
+
end
|
|
149
|
+
private_class_method :hash_text
|
|
150
|
+
|
|
151
|
+
# Keywords other than user:, record: and record_id: are details, as
|
|
152
|
+
# they always were (`new(message: 'x')`); without a positional
|
|
153
|
+
# argument every keyword given stays in details too, so a host's
|
|
154
|
+
# `new(record: r, message: 'x')` reads as before.
|
|
155
|
+
def initialize(details = NO_DETAILS, **keywords)
|
|
156
|
+
@user, @record, @record_id = keywords.values_at(:user, :record, :record_id)
|
|
157
|
+
if details.equal?(NO_DETAILS)
|
|
158
|
+
details = keywords
|
|
159
|
+
elsif !(extra = keywords.except(*READERS)).empty?
|
|
160
|
+
raise ArgumentError, "unknown keyword(s): #{extra.keys.map(&:inspect).join(', ')}"
|
|
161
|
+
end
|
|
162
|
+
@details = details
|
|
163
|
+
super(self.class.text_for(details))
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
class_methods do
|
|
168
|
+
def find_by_with_permission(*args)
|
|
169
|
+
raise ArgumentError, 'wrong number of arguments (given 0, expected 1+)' if args.empty?
|
|
170
|
+
|
|
171
|
+
host_suspended = AutoPermissionScoping.host_suspended?
|
|
172
|
+
# The entire body runs with auto-scoping suspended: every internal
|
|
173
|
+
# probe (rule application, the deny_or_missing existence check, the
|
|
174
|
+
# final take) must see the un-auto-scoped relation — otherwise a
|
|
175
|
+
# rule-denied row would look "missing" under Level 1 and find_by!
|
|
176
|
+
# would raise RecordNotFound instead of NotAuthorizedError.
|
|
177
|
+
pf_internally_unscoped do
|
|
178
|
+
current_user = PermissionForge.current_user
|
|
179
|
+
|
|
180
|
+
next handle_nil_user(*args, host_suspended: host_suspended) unless current_user
|
|
181
|
+
|
|
182
|
+
rules = pf_applicable_rules(current_user, 'find_by')
|
|
183
|
+
next tenant_safe_find_by(*args) unless rules
|
|
184
|
+
|
|
185
|
+
query, failure_messages = PermissionForge::Services::PermissionRuleApplier.call(
|
|
186
|
+
model_class: self, rules: rules, args: args, current_user: current_user
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
next deny_or_missing(failure_messages, current_user, *args) if failure_messages.any?
|
|
190
|
+
|
|
191
|
+
query.where(*args).take
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Returns a relation constrained by the current user's row-level rules, so
|
|
196
|
+
# any further chaining (`.where`, `.first`, `.find`, `.count`) stays scoped.
|
|
197
|
+
# Honors nil_user_strategy: :bypass -> all, :deny -> none, :raise -> raises.
|
|
198
|
+
# view_all/modify_all and the no-rules case return the unconstrained relation.
|
|
199
|
+
#
|
|
200
|
+
# Built with auto-scoping suspended (rules are applied here explicitly —
|
|
201
|
+
# including join-type rules — so the Level 1 filter-only scope must not
|
|
202
|
+
# stack on top). The relation is resolved eagerly inside that window and
|
|
203
|
+
# annotated as scoped, so lazy execution later stays correct and Level 2
|
|
204
|
+
# detection recognizes it.
|
|
205
|
+
#
|
|
206
|
+
# @return [ActiveRecord::Relation]
|
|
207
|
+
def permission_scoped
|
|
208
|
+
host_suspended = AutoPermissionScoping.host_suspended?
|
|
209
|
+
pf_internally_unscoped do
|
|
210
|
+
build_permission_scope(host_suspended).annotate(SCOPED_ANNOTATION)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
# The gem's own suspension of Level 1 (the queries find_by and
|
|
217
|
+
# permission_scoped make while they apply the rules themselves): not
|
|
218
|
+
# reported as a scope_suspended bypass, which is about the host's
|
|
219
|
+
# PermissionForge.without_permission_scope.
|
|
220
|
+
def pf_internally_unscoped(&)
|
|
221
|
+
AutoPermissionScoping.internally_suspended(&)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def build_permission_scope(host_suspended)
|
|
225
|
+
current_user = PermissionForge.current_user
|
|
226
|
+
return permission_scoped_without_user(host_suspended) unless current_user
|
|
227
|
+
|
|
228
|
+
rules = pf_applicable_rules(current_user, 'permission_scoped')
|
|
229
|
+
return all unless rules
|
|
230
|
+
|
|
231
|
+
PermissionForge::Services::PermissionRuleApplier.scope(
|
|
232
|
+
model_class: self, rules: rules, current_user: current_user
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Distinguishes "denied by a rule" from "no such record". find_by must honor
|
|
237
|
+
# its nil contract for a genuinely missing record (matching stock Rails) and
|
|
238
|
+
# only raise NotAuthorizedError when the record exists but rules deny it.
|
|
239
|
+
def deny_or_missing(failure_messages, current_user, *args)
|
|
240
|
+
relation = tenant_safe_relation.where(*args)
|
|
241
|
+
return nil unless relation.exists?
|
|
242
|
+
|
|
243
|
+
record_id = pf_denied_record_id(relation)
|
|
244
|
+
PermissionForge::Audit::Runtime.denied(
|
|
245
|
+
PermissionForge::Audit::Runtime::DENIED, reason: :rule, model: self, via: 'find_by',
|
|
246
|
+
user: current_user, record_id: record_id,
|
|
247
|
+
data: { 'messages' => failure_messages }
|
|
248
|
+
)
|
|
249
|
+
raise NotAuthorizedError.new(failure_messages, user: current_user, record_id: record_id)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# The id of the row the rules denied, for access.denied and the error:
|
|
253
|
+
# read past the select, order and distinct of the relation (as exists?
|
|
254
|
+
# reads it). Not read for a table without a single-column key, nor
|
|
255
|
+
# under a grouping default scope.
|
|
256
|
+
def pf_denied_record_id(relation)
|
|
257
|
+
key = primary_key
|
|
258
|
+
return unless key.is_a?(String) && relation.group_values.empty?
|
|
259
|
+
|
|
260
|
+
relation.except(:select, :distinct, :order).pick(key)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def permission_scoped_without_user(host_suspended)
|
|
264
|
+
return all if pf_suspended_by_host?(host_suspended, 'permission_scoped')
|
|
265
|
+
return none if pf_nil_user_strategy('permission_scoped') == :deny
|
|
266
|
+
|
|
267
|
+
Rails.logger&.warn('[PermissionForge] current_user is nil — permission_scoped returns unscoped relation')
|
|
268
|
+
all
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def handle_nil_user(*args, host_suspended:)
|
|
272
|
+
return tenant_safe_find_by(*args) if pf_suspended_by_host?(host_suspended, 'find_by')
|
|
273
|
+
return nil if pf_nil_user_strategy('find_by') == :deny
|
|
274
|
+
|
|
275
|
+
Rails.logger&.warn('[PermissionForge] current_user is nil — bypassing row-level rules')
|
|
276
|
+
tenant_safe_find_by(*args)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Inside the host's PermissionForge.without_permission_scope block,
|
|
280
|
+
# nobody signed in is trusted code that opted out in so many words: no
|
|
281
|
+
# rules, whatever nil_user_strategy says (that strategy catches code
|
|
282
|
+
# that forgot to set a user). Published as rule.bypassed,
|
|
283
|
+
# scope_suspended.
|
|
284
|
+
def pf_suspended_by_host?(host_suspended, via)
|
|
285
|
+
return false unless host_suspended
|
|
286
|
+
|
|
287
|
+
PermissionForge::Audit::Runtime.bypassed(:scope_suspended, model: self, via: via)
|
|
288
|
+
true
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# nil_user_strategy, reported: :raise raises, a denial (access.denied)
|
|
292
|
+
# is :deny, anything else lets the query past the rules
|
|
293
|
+
# (rule.bypassed) and is :bypass.
|
|
294
|
+
def pf_nil_user_strategy(via)
|
|
295
|
+
strategy = PermissionForge.configuration.nil_user_strategy
|
|
296
|
+
unless %i[raise deny].include?(strategy)
|
|
297
|
+
PermissionForge::Audit::Runtime.bypassed(:nil_user, model: self, via: via)
|
|
298
|
+
return :bypass
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
PermissionForge::Audit::Runtime.denied(PermissionForge::Audit::Runtime::DENIED, reason: :nil_user, model: self, via: via)
|
|
302
|
+
return :deny if strategy == :deny
|
|
303
|
+
|
|
304
|
+
raise NotAuthorizedError, { message: I18n.t('permission_forge.errors.no_authenticated_user', default: 'No authenticated user') }
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# The user's rules for this model — those of its own Resource entry
|
|
308
|
+
# and of its STI ancestors' (ResourceChain) — or nil when none apply:
|
|
309
|
+
# view_all or modify_all on every entry that has rules, or no rules —
|
|
310
|
+
# which is reported as rule.bypassed (and, for view_all / modify_all,
|
|
311
|
+
# to the legacy on_rule_bypass hook: Audit::Subscribers::LegacyHooks::RuleBypass).
|
|
312
|
+
def pf_applicable_rules(current_user, via)
|
|
313
|
+
rules, reason = PermissionForge::ResourceChain.rules(current_user, PermissionForge::ResourceChain.names(self))
|
|
314
|
+
return rules unless reason
|
|
315
|
+
|
|
316
|
+
PermissionForge::Audit::Runtime.bypassed(reason, model: self, via: via, user: current_user)
|
|
317
|
+
nil
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# where(...).take, which is what a relation's find_by does: never a
|
|
321
|
+
# relation's find_by, which a Rails could route back through this
|
|
322
|
+
# class's find_by (the override) and round in circles.
|
|
323
|
+
def tenant_safe_find_by(*args)
|
|
324
|
+
tenant_safe_relation.where(*args).take
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# where(nil) preserves the model's default_scope (e.g. a soft-delete
|
|
328
|
+
# `default_scope { where(deleted_at: nil) }`) AND acts_as_tenant's tenant
|
|
329
|
+
# scope — unlike `unscoped`, which drops ALL scopes and would resurrect
|
|
330
|
+
# soft-deleted / cross-tenant rows through PermissionForge's find_by.
|
|
331
|
+
def tenant_safe_relation
|
|
332
|
+
where(nil)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Concerns
|
|
5
|
+
# Mixin for user models providing permission checks, cache access, and field masking.
|
|
6
|
+
#
|
|
7
|
+
# Include this concern in your User model to enable permission-aware behavior:
|
|
8
|
+
#
|
|
9
|
+
# class User < ApplicationRecord
|
|
10
|
+
# include PermissionForge::Concerns::PermissionedUser
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# This adds associations to permission sets/groups and provides methods to check
|
|
14
|
+
# CRUD access, custom actions, and field-level masking for any resource.
|
|
15
|
+
#
|
|
16
|
+
# Composed of 3 focused sub-concerns:
|
|
17
|
+
# - PermissionCacheAccessor — cache read/write, shared hash navigation
|
|
18
|
+
# - ResourcePermissionChecker — CRUD checks, generic can?, hash accessors
|
|
19
|
+
# - FieldMaskChecker — field-level mask checks
|
|
20
|
+
#
|
|
21
|
+
# @api public
|
|
22
|
+
module PermissionedUser
|
|
23
|
+
extend ActiveSupport::Concern
|
|
24
|
+
|
|
25
|
+
include PermissionCacheAccessor
|
|
26
|
+
include ResourcePermissionChecker
|
|
27
|
+
include FieldMaskChecker
|
|
28
|
+
|
|
29
|
+
# `dependent: :destroy` on the :through associations makes Rails
|
|
30
|
+
# destroy the JOIN rows (assignments, memberships) with their
|
|
31
|
+
# callbacks when a set or group is removed from the collection
|
|
32
|
+
# (`delete`, `ids=`, `=`) — an audit event and a cache invalidation
|
|
33
|
+
# each. It never destroys the Permission Set or group itself.
|
|
34
|
+
# AuditedCollection does the same for `clear` / `delete_all`.
|
|
35
|
+
included do
|
|
36
|
+
has_many :permission_set_users, class_name: 'PermissionForge::PermissionSetUser', foreign_key: :user_id,
|
|
37
|
+
dependent: :destroy, extend: AuditedCollection
|
|
38
|
+
has_many :permission_sets, through: :permission_set_users, class_name: 'PermissionForge::PermissionSet',
|
|
39
|
+
dependent: :destroy, extend: AuditedCollection
|
|
40
|
+
has_many :permission_set_group_users, class_name: 'PermissionForge::PermissionSetGroupUser',
|
|
41
|
+
foreign_key: :user_id, dependent: :destroy, extend: AuditedCollection
|
|
42
|
+
has_many :permission_set_groups, through: :permission_set_group_users,
|
|
43
|
+
class_name: 'PermissionForge::PermissionSetGroup',
|
|
44
|
+
dependent: :destroy, extend: AuditedCollection
|
|
45
|
+
|
|
46
|
+
# Declared after the associations, so it runs after their
|
|
47
|
+
# `dependent: :destroy`: those reach the ambient tenant's rows only.
|
|
48
|
+
before_destroy :pf_destroy_permission_rows_in_other_tenants
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# The user's assignments and memberships in the other tenants go with
|
|
54
|
+
# the user too (a reused id must not inherit them): each through its
|
|
55
|
+
# callbacks — an audit event and a cache invalidation in its own
|
|
56
|
+
# tenant — with that tenant ambient, so its set or group is found.
|
|
57
|
+
# With no tenant ambient the associations above reached every row.
|
|
58
|
+
def pf_destroy_permission_rows_in_other_tenants
|
|
59
|
+
config = PermissionForge.configuration
|
|
60
|
+
return unless config.tenant_enabled? && defined?(::ActsAsTenant) && ::ActsAsTenant.current_tenant
|
|
61
|
+
|
|
62
|
+
[PermissionForge::PermissionSetUser, PermissionForge::PermissionSetGroupUser].each do |model|
|
|
63
|
+
model.unscoped.where(user_id: id).find_each do |row|
|
|
64
|
+
pf_destroy_in_own_tenant(row, row.public_send(config.tenant_association_name))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A row without a tenant (older than tenancy, or an optional tenant
|
|
70
|
+
# association) is destroyed with no tenant ambient, not the caller's.
|
|
71
|
+
def pf_destroy_in_own_tenant(row, tenant)
|
|
72
|
+
return ::ActsAsTenant.without_tenant { row.destroy! } if tenant.nil?
|
|
73
|
+
|
|
74
|
+
PermissionForge.with_ambient_tenant(tenant) { row.destroy! }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Concerns
|
|
5
|
+
# The permission hash a user instance keeps for itself, and what retires
|
|
6
|
+
# it (PermissionCacheAccessor#permissions_hash).
|
|
7
|
+
#
|
|
8
|
+
# A memo is good for one cache key and one
|
|
9
|
+
# PermissionForge.permissions_cache_version: any invalidation or rebuild
|
|
10
|
+
# in the process retires every memo, which then reads the store again.
|
|
11
|
+
#
|
|
12
|
+
# An instance that rebuilt or invalidated inside a transaction whose
|
|
13
|
+
# hashes are withheld from the cache (OpenTransaction#withhold?) is tied
|
|
14
|
+
# to that transaction:
|
|
15
|
+
# - what it built there is good while the transaction it was built in
|
|
16
|
+
# has not been rolled back and the outermost one is still open;
|
|
17
|
+
# - until the outermost one has ended it does not read the store at
|
|
18
|
+
# all — the entry there holds the rows as they were before the
|
|
19
|
+
# transaction. A memo that was retired, or dropped by a rolled-back
|
|
20
|
+
# savepoint, is built again from the transaction's rows.
|
|
21
|
+
# Once the transaction has ended, the instance reads the store like any
|
|
22
|
+
# other.
|
|
23
|
+
#
|
|
24
|
+
# The memo is one frozen object (Memo), stored and read with one instance
|
|
25
|
+
# variable: an instance read on several threads at once never pairs the
|
|
26
|
+
# key or version of one thread's memo with another thread's hash (for
|
|
27
|
+
# another tenant, or from before an invalidation). The transaction is
|
|
28
|
+
# also the tie that keeps the instance away from the store (a Memo
|
|
29
|
+
# without a hash when there is none). `served` is nil for a user without
|
|
30
|
+
# conditional permission sets, and otherwise what the hash was selected
|
|
31
|
+
# by (ConditionalSets::Served): the accessor checks it against the
|
|
32
|
+
# request context on every read (Memo#for_context).
|
|
33
|
+
# The memo marshals with the user (an OpenTransaction dumps as one that
|
|
34
|
+
# has finished).
|
|
35
|
+
#
|
|
36
|
+
# The version is per process: an instance kept across requests learns
|
|
37
|
+
# only of the invalidations made in its own process. And an invalidation
|
|
38
|
+
# that lands after a rebuild's stamp check, before the rebuilding
|
|
39
|
+
# instance memoizes, is seen by that instance only at the next
|
|
40
|
+
# invalidation or rebuild in the process (docs/architecture.md).
|
|
41
|
+
#
|
|
42
|
+
# @api private
|
|
43
|
+
module PermissionsMemo
|
|
44
|
+
# The cache key the hash is for, the
|
|
45
|
+
# PermissionForge.permissions_cache_version it is good for, the open
|
|
46
|
+
# transaction it was built in when it was withheld from the cache (or
|
|
47
|
+
# nil), the hash, what selected it (nil without conditional sets), and
|
|
48
|
+
# when the soonest assignment that went into it ends (epoch seconds;
|
|
49
|
+
# nil: none ends).
|
|
50
|
+
Memo = Data.define(:key, :version, :transaction, :permissions, :served, :expires_at) do
|
|
51
|
+
def initialize(expires_at: nil, **)
|
|
52
|
+
super
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Whether the memo still serves reads for `key`: it holds a hash, for
|
|
56
|
+
# that key, under the version as it is now, no assignment that went
|
|
57
|
+
# into it has ended, and — for a hash built inside a transaction —
|
|
58
|
+
# that transaction still stands and this thread works on its
|
|
59
|
+
# connection (OpenTransaction#visible?).
|
|
60
|
+
def serves?(key)
|
|
61
|
+
return false unless permissions && self.key == key && version == PermissionForge.permissions_cache_version
|
|
62
|
+
return false if expires_at && Time.now.to_i >= expires_at
|
|
63
|
+
|
|
64
|
+
transaction.nil? || transaction.visible?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The memo as the request context (yielded for, only when the user
|
|
68
|
+
# holds conditional sets) selects it: this one, a copy that remembers
|
|
69
|
+
# the context when it selects the same variant, or nil when it
|
|
70
|
+
# selects another (ConditionalSets::Served#reselect).
|
|
71
|
+
#
|
|
72
|
+
# @return [Memo, nil]
|
|
73
|
+
def for_context
|
|
74
|
+
return self unless served
|
|
75
|
+
|
|
76
|
+
reselected = served.reselect(yield)
|
|
77
|
+
return unless reselected
|
|
78
|
+
|
|
79
|
+
reselected.equal?(served) ? self : with(served: reselected)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# No hash, no tie.
|
|
84
|
+
EMPTY_MEMO = Memo.new(key: nil, version: nil, transaction: nil, permissions: nil, served: nil, expires_at: nil)
|
|
85
|
+
|
|
86
|
+
# Drops the memo alongside AR's own attribute/association reload, so a
|
|
87
|
+
# reloaded record never serves a permissions snapshot older than its
|
|
88
|
+
# attributes. (Inside a transaction it is tied to, the next read builds
|
|
89
|
+
# the hash again.)
|
|
90
|
+
def reload(*)
|
|
91
|
+
pf_clear_permissions_memo
|
|
92
|
+
super
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# The memo, while it is still the one for `key`, no invalidation or
|
|
98
|
+
# rebuild in this process has retired it, and — for a hash built inside
|
|
99
|
+
# a transaction — that transaction still stands and this thread works
|
|
100
|
+
# on its connection (Memo#serves?). The memo is read once: the fields
|
|
101
|
+
# of two memos are never mixed.
|
|
102
|
+
#
|
|
103
|
+
# @return [Memo, nil]
|
|
104
|
+
def pf_memoized_permissions(key)
|
|
105
|
+
memo = pf_kept_permissions_memo
|
|
106
|
+
memo if memo&.serves?(key)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The memo this instance keeps — nil for anything else under the
|
|
110
|
+
# variable: ActiveRecord's 6.1 marshalling format dumps instance
|
|
111
|
+
# variables, so a user cached by an earlier version of the gem brings
|
|
112
|
+
# back an Array, which is no memo. The one reader of the variable.
|
|
113
|
+
#
|
|
114
|
+
# @return [Memo, nil]
|
|
115
|
+
def pf_kept_permissions_memo
|
|
116
|
+
memo = @_pf_permissions_memo
|
|
117
|
+
memo if memo.is_a?(Memo)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A memo tied to a transaction still open on another connection stays:
|
|
121
|
+
# the thread in that transaction keeps what it built from its rows.
|
|
122
|
+
#
|
|
123
|
+
# @param memo [Memo]
|
|
124
|
+
# @return [void]
|
|
125
|
+
def pf_memoize_permissions(memo)
|
|
126
|
+
return if pf_kept_permissions_memo&.transaction&.held_elsewhere?(memo.transaction)
|
|
127
|
+
|
|
128
|
+
@_pf_permissions_memo = memo
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Drops the hash; the tie to a transaction stays (see
|
|
132
|
+
# pf_withholding_permissions?).
|
|
133
|
+
def pf_clear_permissions_memo
|
|
134
|
+
pf_withhold_permissions_in(pf_kept_permissions_memo&.transaction, keep: false)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Ties the instance to a transaction in which it invalidated: from here
|
|
138
|
+
# on the store may hold what another request rebuilt from the rows as
|
|
139
|
+
# they still are before the commit. A memo it keeps is good only while
|
|
140
|
+
# that transaction stands.
|
|
141
|
+
def pf_withhold_permissions_in(transaction, keep: true)
|
|
142
|
+
memo = (pf_kept_permissions_memo if keep) || EMPTY_MEMO
|
|
143
|
+
@_pf_permissions_memo = (memo.with(transaction: transaction) if transaction || memo.permissions)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Whether the instance has to stay away from the store: the transaction
|
|
147
|
+
# it is tied to has not ended, and this thread works in it (another
|
|
148
|
+
# connection reads committed rows, as the store holds). Once it has
|
|
149
|
+
# ended, memo and tie are dropped.
|
|
150
|
+
def pf_withholding_permissions?
|
|
151
|
+
tie = pf_kept_permissions_memo&.transaction
|
|
152
|
+
return false unless tie
|
|
153
|
+
return tie.own_connection? unless tie.finished?
|
|
154
|
+
|
|
155
|
+
@_pf_permissions_memo = nil
|
|
156
|
+
false
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|