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,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module ControllerActions
|
|
5
|
+
# Decides whether a user may run a controller action. The runtime guard
|
|
6
|
+
# ({PermissionForge::Concerns::ControllerActionGuard}) raises on a denied
|
|
7
|
+
# decision; host apps call {.allowed?} to hide buttons with the same logic.
|
|
8
|
+
#
|
|
9
|
+
# Decision order — the first row that applies wins:
|
|
10
|
+
# 1. the action maps to no Resource (Mapping returns nil) → allow
|
|
11
|
+
# 2. no user → action_guard_nil_user_strategy, else nil_user_strategy → :bypass allows, anything else denies
|
|
12
|
+
# 3. config.action_guard_bypass returns true → allow
|
|
13
|
+
# 4. no Permission Set manages the Resource → unconfigured_action_strategy
|
|
14
|
+
# 5. the action means a CRUD permission → user.can?(crud, resource)
|
|
15
|
+
# 6. custom action the user is granted → allow
|
|
16
|
+
# 7. custom action some Permission Set manages → deny
|
|
17
|
+
# 8. custom action no Permission Set manages → unconfigured_action_strategy
|
|
18
|
+
#
|
|
19
|
+
# A grant (rows 5 and 6) is checked before the index, so a granted request
|
|
20
|
+
# costs no extra cache read; rows 4, 7 and 8 share one. Everything
|
|
21
|
+
# unexpected fails closed: a user object without `can?`, a bypass lambda
|
|
22
|
+
# that raises or returns something other than true, an unknown strategy.
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# Authorizer.call(controller_path: 'customers', action_name: 'export')
|
|
26
|
+
# # => #<struct allowed=false, reason=:action_denied, target=#<struct resource="Customer", ...>>
|
|
27
|
+
# Authorizer.allowed?(controller_path: 'customers', action_name: 'export', user: current_user)
|
|
28
|
+
# # => false
|
|
29
|
+
#
|
|
30
|
+
# @api public
|
|
31
|
+
class Authorizer
|
|
32
|
+
# Every outcome and whether it lets the request through.
|
|
33
|
+
REASONS = {
|
|
34
|
+
unmapped: true, # row 1
|
|
35
|
+
nil_user_bypassed: true, # row 2, the nil-user strategy is :bypass
|
|
36
|
+
nil_user_denied: false, # row 2, any other nil-user strategy
|
|
37
|
+
guard_bypassed: true, # row 3
|
|
38
|
+
unsupported_user: false, # the user object has no can?
|
|
39
|
+
resource_not_managed: true, # row 4, unconfigured_action_strategy = :allow
|
|
40
|
+
resource_not_managed_blocked: false, # row 4, any other strategy
|
|
41
|
+
crud_granted: true, # row 5
|
|
42
|
+
crud_denied: false, # row 5
|
|
43
|
+
action_granted: true, # row 6
|
|
44
|
+
action_denied: false, # row 7
|
|
45
|
+
action_not_managed: true, # row 8, unconfigured_action_strategy = :allow
|
|
46
|
+
action_not_managed_blocked: false # row 8, any other strategy
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# The allowed outcomes that let a request past the Permission Sets, and
|
|
50
|
+
# the rule.bypassed reason each one is published with (the guard does
|
|
51
|
+
# it: Authorizer.allowed? also answers button checks).
|
|
52
|
+
BYPASS_REASONS = {
|
|
53
|
+
nil_user_bypassed: 'nil_user',
|
|
54
|
+
guard_bypassed: 'guard_bypass',
|
|
55
|
+
resource_not_managed: 'no_rules',
|
|
56
|
+
action_not_managed: 'no_rules'
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
# target is the Mapping::Target that was checked, or nil when the action
|
|
60
|
+
# maps to no Resource. Every denied decision carries a target.
|
|
61
|
+
#
|
|
62
|
+
# @api private
|
|
63
|
+
Decision = Struct.new(:allowed, :reason, :target, keyword_init: true) do
|
|
64
|
+
def allowed? = allowed == true
|
|
65
|
+
def denied? = !allowed?
|
|
66
|
+
|
|
67
|
+
# @return [String, nil] the rule.bypassed reason, for an allowed
|
|
68
|
+
# decision that skipped the Permission Sets
|
|
69
|
+
def bypass_reason = BYPASS_REASONS[reason]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param controller_path [String] e.g. 'customers/merges'
|
|
73
|
+
# @param action_name [String, Symbol] e.g. 'create'
|
|
74
|
+
# @param controller_class [Class, nil] the controller, for its
|
|
75
|
+
# `permission_forge_actions` declaration. Defaults to the controller's
|
|
76
|
+
# class, then to the class the path names (`Customers::MergesController`).
|
|
77
|
+
# @param controller [Object, nil] the controller instance, handed to
|
|
78
|
+
# config.action_guard_bypass
|
|
79
|
+
# @param user [Object, nil] defaults to PermissionForge.current_user
|
|
80
|
+
# @param configuration [PermissionForge::Configuration]
|
|
81
|
+
# @param index [#managed_actions] defaults to ManagedIndex
|
|
82
|
+
# @return [Decision]
|
|
83
|
+
def self.call(...) = new(...).call
|
|
84
|
+
|
|
85
|
+
# Same arguments as {.call}.
|
|
86
|
+
#
|
|
87
|
+
# @return [Boolean] whether the user may run the action
|
|
88
|
+
def self.allowed?(...) = call(...).allowed?
|
|
89
|
+
|
|
90
|
+
# What to check, plus the injectable dependencies — all keywords.
|
|
91
|
+
def initialize(controller_path:, action_name:, controller_class: nil, controller: nil, # rubocop:disable Metrics/ParameterLists
|
|
92
|
+
user: PermissionForge.current_user, configuration: PermissionForge.configuration,
|
|
93
|
+
index: ManagedIndex)
|
|
94
|
+
@controller_path = controller_path.to_s
|
|
95
|
+
@action_name = action_name.to_s
|
|
96
|
+
@controller = controller
|
|
97
|
+
# No controller given (a button check): the class the path names.
|
|
98
|
+
@controller_class = controller_class || controller&.class || ControllerActions.controller_class_for(@controller_path)
|
|
99
|
+
@user = user
|
|
100
|
+
@configuration = configuration
|
|
101
|
+
@index = index
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [Decision]
|
|
105
|
+
def call
|
|
106
|
+
@target = Mapping.call(controller_path: @controller_path, action_name: @action_name,
|
|
107
|
+
controller_class: @controller_class, configuration: @configuration)
|
|
108
|
+
return decide(:unmapped) unless @target
|
|
109
|
+
return decide(nil_user_bypass? ? :nil_user_bypassed : :nil_user_denied) unless @user
|
|
110
|
+
return decide(:guard_bypassed) if guard_bypassed?
|
|
111
|
+
return decide(:unsupported_user) unless @user.respond_to?(:can?)
|
|
112
|
+
|
|
113
|
+
authorize
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# Rows 4–8: what the Permission Sets say. A grant is checked first — a
|
|
119
|
+
# user can only be granted something on a managed resource, so the answer
|
|
120
|
+
# is the same and a granted request never reads the index. Everything
|
|
121
|
+
# else costs one index read.
|
|
122
|
+
def authorize
|
|
123
|
+
return decide(@target.crud? ? :crud_granted : :action_granted) if can?(@target.permission)
|
|
124
|
+
|
|
125
|
+
managed_actions = @index.managed_actions(@target.resource)
|
|
126
|
+
return unconfigured(:resource_not_managed) if managed_actions.nil?
|
|
127
|
+
return decide(:crud_denied) if @target.crud?
|
|
128
|
+
return decide(:action_denied) if managed_actions.include?(@target.action)
|
|
129
|
+
|
|
130
|
+
unconfigured(:action_not_managed)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def decide(reason)
|
|
134
|
+
Decision.new(allowed: REASONS.fetch(reason), reason: reason, target: @target)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Nobody configured this resource or action: the strategy decides.
|
|
138
|
+
def unconfigured(reason)
|
|
139
|
+
decide(@configuration.allow_unconfigured_actions? ? reason : :"#{reason}_blocked")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Only an explicit true from can? grants.
|
|
143
|
+
def can?(permission)
|
|
144
|
+
@user.can?(permission, @target.resource) == true
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Only the exact :bypass value lets a request with no user through.
|
|
148
|
+
def nil_user_bypass?
|
|
149
|
+
@configuration.action_guard_nil_user_bypass?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Only an explicit true skips the guard. A raising (or non-callable)
|
|
153
|
+
# bypass counts as "no bypass": the Permission Sets still decide.
|
|
154
|
+
def guard_bypassed?
|
|
155
|
+
bypass = @configuration.action_guard_bypass
|
|
156
|
+
return false if bypass.nil?
|
|
157
|
+
|
|
158
|
+
bypass.call(@user, @controller) == true
|
|
159
|
+
rescue StandardError => e
|
|
160
|
+
log_bypass_error(e)
|
|
161
|
+
false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def log_bypass_error(error)
|
|
165
|
+
return unless defined?(Rails) && Rails.respond_to?(:logger)
|
|
166
|
+
|
|
167
|
+
Rails.logger&.error("[PermissionForge] action_guard_bypass error: #{error.class}: #{error.message}")
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/current_attributes'
|
|
4
|
+
|
|
5
|
+
module PermissionForge
|
|
6
|
+
module ControllerActions
|
|
7
|
+
# Which resources and custom actions any Permission Set manages.
|
|
8
|
+
#
|
|
9
|
+
# "Managed" means a row is in effect — an active row in an active resource
|
|
10
|
+
# of an active Permission Set, granting or muting — in the current tenant
|
|
11
|
+
# (Resource.in_effect / ResourceAction.in_effect). The guard uses it to tell
|
|
12
|
+
# "nobody has configured this" (unconfigured_action_strategy applies) from
|
|
13
|
+
# "configured, and this user was not granted it" (denied).
|
|
14
|
+
#
|
|
15
|
+
# The index is one small hash ({ 'Customer' => ['export', 'merge'] }) kept
|
|
16
|
+
# in PermissionForge.cache, so every process sees an invalidation
|
|
17
|
+
# (InvalidatesManagedActions drops it when a defining record changes).
|
|
18
|
+
#
|
|
19
|
+
# A stale "not managed" lets a request through under the default
|
|
20
|
+
# strategy, so the index is invalidated and rebuilt the way a user's
|
|
21
|
+
# permission hash is — the same code, one definition:
|
|
22
|
+
# - every invalidation leaves a fresh stamp (STAMP_KEY) before it removes
|
|
23
|
+
# the keys, and a build only leaves the index in the store when the
|
|
24
|
+
# stamp it read before reading the rows is still there after storing it
|
|
25
|
+
# (Cache::Invalidation, Cache::Rebuild). A build that raced a commit is
|
|
26
|
+
# removed and made once more; it reads the rows past ActiveRecord's
|
|
27
|
+
# query cache;
|
|
28
|
+
# - a removal the store does not confirm is tried again, reported
|
|
29
|
+
# (Cache::Failure) and owed by this process (Cache::Backlog): until the
|
|
30
|
+
# store confirms it, this process builds the index from the database
|
|
31
|
+
# instead of reading it from the store;
|
|
32
|
+
# - the cached copy lives at most MAX_TTL seconds, whatever cache_ttl is.
|
|
33
|
+
# That bounds what nothing above can see: rows changed behind the
|
|
34
|
+
# callbacks (update_all), and — in the other processes, which do not
|
|
35
|
+
# know what this one owes — an index whose removal the store never
|
|
36
|
+
# confirmed;
|
|
37
|
+
# - a request keeps its own copy (RequestMemo), so many checks in one
|
|
38
|
+
# request cost one cache read. Rails resets it between requests and
|
|
39
|
+
# jobs; it is dropped on this thread by invalidate! and re-read after
|
|
40
|
+
# MAX_TTL in a long-lived process.
|
|
41
|
+
class ManagedIndex
|
|
42
|
+
CACHE_KEY = 'managed_actions'
|
|
43
|
+
|
|
44
|
+
# The invalidation stamp: one for every tenant's index, as a user has one
|
|
45
|
+
# whatever the tenant (an invalidation in another tenant makes a build
|
|
46
|
+
# repeat itself, which costs time, not correctness).
|
|
47
|
+
STAMP_KEY = 'managed_actions_invalidation'
|
|
48
|
+
|
|
49
|
+
# Rebuilding costs two small queries, so the index can afford to be fresh.
|
|
50
|
+
MAX_TTL = 60
|
|
51
|
+
|
|
52
|
+
# The current request's copies: one per cache key, holding the cache
|
|
53
|
+
# version it was read under (a bump replaces it — a job that
|
|
54
|
+
# invalidates many users must not keep one copy per bump).
|
|
55
|
+
class RequestMemo < ActiveSupport::CurrentAttributes
|
|
56
|
+
attribute :indexes
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
# The managed action keys of a resource, in one lookup. Callers that
|
|
61
|
+
# need both answers (the guard) use this instead of the two predicates.
|
|
62
|
+
#
|
|
63
|
+
# @param resource [String, Symbol] resource name (e.g. 'Customer')
|
|
64
|
+
# @return [Array<String>, nil] nil when no Permission Set manages the resource
|
|
65
|
+
def managed_actions(resource)
|
|
66
|
+
entries[resource.to_s]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param resource [String, Symbol] resource name
|
|
70
|
+
# @return [Boolean]
|
|
71
|
+
def managed_resource?(resource)
|
|
72
|
+
!managed_actions(resource).nil?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @param resource [String, Symbol] resource name
|
|
76
|
+
# @param action [String, Symbol] action key (e.g. 'export')
|
|
77
|
+
# @return [Boolean]
|
|
78
|
+
def managed_action?(resource, action)
|
|
79
|
+
Array(managed_actions(resource)).include?(action.to_s)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Drops the index for the ambient tenant, the tenant-less key and —
|
|
83
|
+
# when the caller knows it (a callback on a tenant-scoped record) —
|
|
84
|
+
# that record's tenant, along with this request's copies.
|
|
85
|
+
#
|
|
86
|
+
# A Cache::Invalidation: a fresh stamp first, then every key, each
|
|
87
|
+
# removal confirmed by the store (Cache::Base#expunge). One the store
|
|
88
|
+
# does not confirm — or that raises — is tried again; when it still
|
|
89
|
+
# fails it is reported through Cache::Failure, not raised, and owed
|
|
90
|
+
# (Cache::Backlog): this process does not read the index from the store
|
|
91
|
+
# until the store has confirmed it. Other processes may serve the old
|
|
92
|
+
# index until it expires: MAX_TTL seconds at most.
|
|
93
|
+
#
|
|
94
|
+
# Inside an open transaction the rows this is about are not visible to
|
|
95
|
+
# anybody else yet, and a request that builds the index right now
|
|
96
|
+
# caches it without them. So the drop is repeated once the outermost
|
|
97
|
+
# transaction has committed (Cache::Settlement).
|
|
98
|
+
#
|
|
99
|
+
# @param tenant_id [Object, nil]
|
|
100
|
+
# @return [Boolean] whether the store confirmed every removal
|
|
101
|
+
def invalidate!(tenant_id: nil)
|
|
102
|
+
keys = [cache_key, CACHE_KEY, (tenant_key(tenant_id) if tenant_id)].compact.uniq
|
|
103
|
+
confirmed = PermissionForge::Cache::Invalidation.call(user_id: nil, stamp_key: STAMP_KEY, keys: keys)
|
|
104
|
+
repeat_after_commit(tenant_id)
|
|
105
|
+
confirmed
|
|
106
|
+
ensure
|
|
107
|
+
RequestMemo.indexes = nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def repeat_after_commit(tenant_id)
|
|
113
|
+
transaction = PermissionForge::OpenTransaction.current
|
|
114
|
+
return unless transaction
|
|
115
|
+
|
|
116
|
+
# the tenant that is ambient now: the commit may run after the block that made it so
|
|
117
|
+
tenant_id ||= PermissionForge.ambient_tenant_id
|
|
118
|
+
PermissionForge::Cache::Settlement.for(transaction).add([CACHE_KEY, tenant_id]) { invalidate!(tenant_id: tenant_id) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @return [Hash{String => Array<String>}] frozen
|
|
122
|
+
def entries
|
|
123
|
+
key = cache_key
|
|
124
|
+
memo = (RequestMemo.indexes ||= {})
|
|
125
|
+
version = PermissionForge.permissions_cache_version
|
|
126
|
+
held = memo[key]
|
|
127
|
+
return held[:entries] if held && current?(held, version)
|
|
128
|
+
|
|
129
|
+
index, transaction = load(key)
|
|
130
|
+
memo[key] = { entries: index, version: version, at: Time.now, transaction: transaction }
|
|
131
|
+
index
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# A request's copy is good under the cache version it was read with,
|
|
135
|
+
# for MAX_TTL — and, when it was built inside a transaction, for as
|
|
136
|
+
# long as that transaction is open.
|
|
137
|
+
def current?(held, version)
|
|
138
|
+
held[:version] == version && Time.now - held[:at] < MAX_TTL && !held[:transaction]&.finished?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# An index built inside an open transaction holds rows nobody else can
|
|
142
|
+
# see yet, and that a rollback takes back — at every isolation level.
|
|
143
|
+
# It is not written to the store, and the request's copy of it is
|
|
144
|
+
# dropped when that transaction ends.
|
|
145
|
+
#
|
|
146
|
+
# @return [Array(Hash, PermissionForge::OpenTransaction)] the index, and
|
|
147
|
+
# the open transaction it was built in (nil when it came from the
|
|
148
|
+
# store or was built outside one)
|
|
149
|
+
def load(key)
|
|
150
|
+
cached = read_shared(key)
|
|
151
|
+
return [normalize(cached), nil] unless cached.nil?
|
|
152
|
+
|
|
153
|
+
transaction = PermissionForge::OpenTransaction.current
|
|
154
|
+
[transaction ? build : store_built(key), transaction]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The cached index — unless this process still owes the store its
|
|
158
|
+
# removal (Cache::Backlog): the entry may be the very one that should
|
|
159
|
+
# be gone.
|
|
160
|
+
def read_shared(key)
|
|
161
|
+
PermissionForge.cache.read(key) unless PermissionForge::Cache::Invalidation.owed?(STAMP_KEY)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Builds the index and leaves it in the store only when no
|
|
165
|
+
# invalidation overtook the build (Cache::Rebuild).
|
|
166
|
+
#
|
|
167
|
+
# @return [Hash] the index as built, whether or not the store kept it
|
|
168
|
+
def store_built(key)
|
|
169
|
+
PermissionForge::Cache::Rebuild.call(key: key, stamp_key: STAMP_KEY, user_id: nil, build: -> { [build, ttl] }).first
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def ttl
|
|
173
|
+
[PermissionForge.configuration.cache_ttl, MAX_TTL].compact.min
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# String keys (the cache symbolizes hash keys on read), frozen so a
|
|
177
|
+
# request's shared copy cannot be changed by a caller.
|
|
178
|
+
def normalize(index)
|
|
179
|
+
index.to_h { |resource, actions| [resource.to_s, Array(actions).map(&:to_s).freeze] }.freeze
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Reads the rows outside ActiveRecord's query cache: inside a request
|
|
183
|
+
# it would answer a repeated build with the rows the first one read.
|
|
184
|
+
#
|
|
185
|
+
# @return [Hash{String => Array<String>}] frozen
|
|
186
|
+
def build
|
|
187
|
+
PermissionForge::Resource.uncached do
|
|
188
|
+
index = PermissionForge::Resource.in_effect.distinct.pluck(:name).index_with { [] }
|
|
189
|
+
action_pairs.each { |resource, action| index[resource] << action }
|
|
190
|
+
normalize(index)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def action_pairs
|
|
195
|
+
PermissionForge::ResourceAction.in_effect.distinct
|
|
196
|
+
.pluck(PermissionForge::Resource.arel_table[:name],
|
|
197
|
+
PermissionForge::ResourceAction.arel_table[:name])
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Mirrors PermissionCacheAccessor#permission_cache_key.
|
|
201
|
+
def cache_key
|
|
202
|
+
config = PermissionForge.configuration
|
|
203
|
+
tenant = ActsAsTenant.current_tenant if config.tenant_enabled? && defined?(ActsAsTenant)
|
|
204
|
+
tenant ? tenant_key(tenant.id) : CACHE_KEY
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def tenant_key(tenant_id)
|
|
208
|
+
"tenant_#{tenant_id}_#{CACHE_KEY}"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module ControllerActions
|
|
5
|
+
# Resolves a controller action to the Resource and action key that govern
|
|
6
|
+
# it. The runtime guard and the admin's controller catalog both go through
|
|
7
|
+
# here, so the admin lists exactly what the guard checks.
|
|
8
|
+
#
|
|
9
|
+
# Resolution order, for the resource and for the action key independently:
|
|
10
|
+
# 1. the controller's own `permission_forge_actions` declaration
|
|
11
|
+
# 2. config.controller_action_resolver
|
|
12
|
+
# 3. convention — controller name → model, action name → key
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# Mapping.call(controller_path: 'api/v1/customers', action_name: 'export')
|
|
16
|
+
# # => #<struct resource="Customer", action="export", crud=nil>
|
|
17
|
+
# Mapping.call(controller_path: 'customers', action_name: 'index')
|
|
18
|
+
# # => #<struct resource="Customer", action="index", crud=:read>
|
|
19
|
+
class Mapping
|
|
20
|
+
# crud is the CRUD permission the action means (:read, :create, :edit,
|
|
21
|
+
# :delete), or nil for a custom action checked by its key.
|
|
22
|
+
Target = Struct.new(:resource, :action, :crud, keyword_init: true) do
|
|
23
|
+
def crud? = !crud.nil?
|
|
24
|
+
|
|
25
|
+
# What to ask PermissionedUser#can? for.
|
|
26
|
+
def permission = crud || action
|
|
27
|
+
|
|
28
|
+
# Whether the admin and the API can give this action a row: a CRUD
|
|
29
|
+
# flag, or a key that is a valid ResourceAction name. An action whose
|
|
30
|
+
# name is not a valid key (`exportCSV`) is still mapped, so the guard
|
|
31
|
+
# checks it like any other — with no row for it, that means
|
|
32
|
+
# unconfigured_action_strategy — instead of waving it through.
|
|
33
|
+
def manageable? = crud? || action.match?(KEY_FORMAT)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.call(...) = new(...).call
|
|
37
|
+
|
|
38
|
+
# @param conventions [Hash, nil] a caller-owned memo of controller_path =>
|
|
39
|
+
# conventional resource, for callers that map many actions at once (the
|
|
40
|
+
# admin's controller catalog). The guard passes none.
|
|
41
|
+
def initialize(controller_path:, action_name:, controller_class: nil, configuration: PermissionForge.configuration,
|
|
42
|
+
conventions: nil)
|
|
43
|
+
@controller_path = controller_path.to_s
|
|
44
|
+
@action_name = action_name.to_s
|
|
45
|
+
@controller_class = controller_class
|
|
46
|
+
@configuration = configuration
|
|
47
|
+
@conventions = conventions
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Target, nil] nil when the action is excluded, unmapped or
|
|
51
|
+
# explicitly unmanaged
|
|
52
|
+
# @raise [PermissionForge::ConfigurationError] when a declaration or the
|
|
53
|
+
# resolver names a resource or key that is not a valid name
|
|
54
|
+
def call
|
|
55
|
+
return nil if unmanaged?
|
|
56
|
+
|
|
57
|
+
resource = resource_name
|
|
58
|
+
return nil unless resource
|
|
59
|
+
|
|
60
|
+
key = explicit_key || @action_name
|
|
61
|
+
crud = crud_for(key)
|
|
62
|
+
Target.new(resource: resource, action: key, crud: crud) unless crud && skip_rest_action?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Excluded controller, or the host resolver said "never managed".
|
|
68
|
+
def unmanaged?
|
|
69
|
+
@configuration.controller_excluded?(@controller_path) || resolved == false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def declared
|
|
73
|
+
@declared ||= if @controller_class.respond_to?(:pf_action_guard_options)
|
|
74
|
+
@controller_class.pf_action_guard_options || EMPTY_OPTIONS
|
|
75
|
+
else
|
|
76
|
+
EMPTY_OPTIONS
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The host resolver's answer: [resource, key], false, or nil. A raising
|
|
81
|
+
# resolver propagates, and so does an answer of any other shape —
|
|
82
|
+
# treating either as "unmanaged" would fail open.
|
|
83
|
+
def resolved
|
|
84
|
+
return @resolved if defined?(@resolved)
|
|
85
|
+
|
|
86
|
+
answer = @configuration.controller_action_resolver&.call(@controller_path, @action_name)
|
|
87
|
+
unless answer.nil? || answer == false || answer.is_a?(Array)
|
|
88
|
+
raise PermissionForge::ConfigurationError,
|
|
89
|
+
"#{@controller_path}##{@action_name}: controller_action_resolver must return " \
|
|
90
|
+
"[resource, key], false or nil, got #{answer.inspect}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
@resolved = answer
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def resolved_pair
|
|
97
|
+
resolved.is_a?(Array) ? resolved : []
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Declarations are validated when they are made (normalize_options);
|
|
101
|
+
# resolver answers are validated here.
|
|
102
|
+
def resource_name
|
|
103
|
+
declared[:resource] || resolved_part(0, RESOURCE_FORMAT, 'Resource name') || conventional_resource
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A key the controller or the resolver chose, as opposed to the action name.
|
|
107
|
+
def explicit_key
|
|
108
|
+
return @explicit_key if defined?(@explicit_key)
|
|
109
|
+
|
|
110
|
+
@explicit_key = declared[:actions][@action_name] || resolved_part(1, KEY_FORMAT, 'action key')
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def resolved_part(index, format, kind)
|
|
114
|
+
value = resolved_pair[index]&.to_s.presence
|
|
115
|
+
value && valid!(value, format, kind)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# A resolver answer that cannot be used is a configuration bug. Raising
|
|
119
|
+
# fails closed; treating it as "unmapped" would leave the action unchecked.
|
|
120
|
+
def valid!(value, format, kind)
|
|
121
|
+
return value if value.match?(format)
|
|
122
|
+
|
|
123
|
+
raise PermissionForge::ConfigurationError,
|
|
124
|
+
"#{@controller_path}##{@action_name}: #{value.inspect} is not a valid #{kind}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# An explicit key means CRUD only when it is one of can?'s CRUD words
|
|
128
|
+
# (`actions: { approve: :edit }`); an action name also counts when it is
|
|
129
|
+
# a REST action.
|
|
130
|
+
def crud_for(key)
|
|
131
|
+
return CRUD_KEYS[key] if explicit_key
|
|
132
|
+
|
|
133
|
+
REST_ACTIONS[key] || CRUD_KEYS[key]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def skip_rest_action?
|
|
137
|
+
declared[:rest_actions] == false && explicit_key.nil? && REST_ACTIONS.key?(@action_name)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def conventional_resource
|
|
141
|
+
return resolve_convention unless @conventions
|
|
142
|
+
|
|
143
|
+
@conventions.fetch(@controller_path) { @conventions[@controller_path] = resolve_convention }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# 'api/v1/customers' → Api::V1::Customer, then Customer.
|
|
147
|
+
def resolve_convention
|
|
148
|
+
candidates = [@controller_path.classify, @controller_path.split('/').last.to_s.classify].uniq
|
|
149
|
+
candidates.each do |candidate|
|
|
150
|
+
klass = model_class(candidate)
|
|
151
|
+
return klass.name if klass
|
|
152
|
+
end
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# safe_constantize answers nil for a missing constant. A model that
|
|
157
|
+
# exists but fails to load raises, and that propagates: swallowing it
|
|
158
|
+
# would turn the controller into "unmapped", which the guard allows.
|
|
159
|
+
def model_class(name)
|
|
160
|
+
return nil unless name.match?(RESOURCE_FORMAT)
|
|
161
|
+
|
|
162
|
+
klass = name.safe_constantize
|
|
163
|
+
klass if klass.is_a?(Class) && klass < ActiveRecord::Base && !klass.abstract_class?
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# Controller actions as permissions: which Resource and action key a
|
|
5
|
+
# controller action means (Mapping), whether any Permission Set manages it
|
|
6
|
+
# (ManagedIndex), and whether a user may run it (Authorizer).
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
module ControllerActions
|
|
10
|
+
# Format of a ResourceAction name, and so of every action key.
|
|
11
|
+
KEY_FORMAT = /\A[a-z][a-z0-9_]{0,62}\z/
|
|
12
|
+
|
|
13
|
+
# A Resource name: a Ruby constant path (the one definition is
|
|
14
|
+
# PermissionForge::RESOURCE_NAME_FORMAT; the Resource model is autoloaded
|
|
15
|
+
# later than this file).
|
|
16
|
+
RESOURCE_FORMAT = PermissionForge::RESOURCE_NAME_FORMAT
|
|
17
|
+
|
|
18
|
+
# The before_action the guard concern installs.
|
|
19
|
+
GUARD_CALLBACK = :pf_authorize_action!
|
|
20
|
+
|
|
21
|
+
# Controller action names that mean a CRUD permission on the resource.
|
|
22
|
+
REST_ACTIONS = {
|
|
23
|
+
'index' => :read, 'show' => :read,
|
|
24
|
+
'new' => :create, 'create' => :create,
|
|
25
|
+
'edit' => :edit, 'update' => :edit,
|
|
26
|
+
'destroy' => :delete
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# Action keys PermissionedUser#can? answers from the CRUD flags instead of
|
|
30
|
+
# the resource's custom actions — can?'s own table, so the two cannot drift.
|
|
31
|
+
CRUD_KEYS = PermissionForge::Concerns::ResourcePermissionChecker::CRUD_ACTIONS.transform_keys(&:to_s).freeze
|
|
32
|
+
|
|
33
|
+
EMPTY_OPTIONS = { resource: nil, actions: {}.freeze, rest_actions: true }.freeze
|
|
34
|
+
|
|
35
|
+
# Normalizes a controller's `permission_forge_actions` declaration into the
|
|
36
|
+
# frozen shape Mapping reads. The only constructor of that shape, so it is
|
|
37
|
+
# also where a declaration is validated.
|
|
38
|
+
#
|
|
39
|
+
# @param resource [String, Symbol, Class, nil]
|
|
40
|
+
# @param actions [Hash] controller action name => action key
|
|
41
|
+
# @param rest_actions [Boolean] false leaves index/show/new/... unmanaged
|
|
42
|
+
# @return [Hash] { resource: String|nil, actions: { String => String }, rest_actions: Boolean }
|
|
43
|
+
# @raise [ArgumentError] when the resource or a key is not a valid name
|
|
44
|
+
def self.normalize_options(resource: nil, actions: {}, rest_actions: true)
|
|
45
|
+
options = {
|
|
46
|
+
resource: resource_name(resource),
|
|
47
|
+
actions: actions.to_h.to_h { |name, key| [name.to_s, key.to_s] }.freeze,
|
|
48
|
+
rest_actions: rest_actions != false
|
|
49
|
+
}.freeze
|
|
50
|
+
validate_options!(options)
|
|
51
|
+
options
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Adds a declaration to the one a controller already has — its own earlier
|
|
55
|
+
# call, or an inherited one. A given resource or rest_actions replaces;
|
|
56
|
+
# actions are merged. Declaring twice therefore never drops the resource
|
|
57
|
+
# the first call named (which would leave the controller unchecked).
|
|
58
|
+
#
|
|
59
|
+
# @param current [Hash] a normalized declaration
|
|
60
|
+
# @return [Hash] a normalized, frozen declaration
|
|
61
|
+
def self.merge_options(current, **declaration)
|
|
62
|
+
rest_actions = declaration.fetch(:rest_actions, nil)
|
|
63
|
+
given = normalize_options(**declaration, rest_actions: rest_actions.nil? ? current[:rest_actions] : rest_actions)
|
|
64
|
+
{ resource: given[:resource] || current[:resource],
|
|
65
|
+
actions: current[:actions].merge(given[:actions]).freeze,
|
|
66
|
+
rest_actions: given[:rest_actions] }.freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A declaration the mapping could not use fails here, when the controller
|
|
70
|
+
# loads. Left alone, a typo (`resource: 'customer'`, `actions: { create:
|
|
71
|
+
# 'Merge' }`) would map the action to nothing and leave it unchecked.
|
|
72
|
+
def self.validate_options!(options)
|
|
73
|
+
resource = options[:resource]
|
|
74
|
+
if resource && !resource.match?(RESOURCE_FORMAT)
|
|
75
|
+
raise ArgumentError, "permission_forge_actions: #{resource.inspect} is not a Resource name " \
|
|
76
|
+
"(expected a model name such as 'Customer')"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
invalid = options[:actions].values.grep_v(KEY_FORMAT)
|
|
80
|
+
return if invalid.empty?
|
|
81
|
+
|
|
82
|
+
raise ArgumentError, "permission_forge_actions: invalid action key #{invalid.map(&:inspect).join(', ')} " \
|
|
83
|
+
'(expected lowercase letters, digits and _, starting with a letter)'
|
|
84
|
+
end
|
|
85
|
+
private_class_method :validate_options!
|
|
86
|
+
|
|
87
|
+
# 'api/v1/customers' → Api::V1::CustomersController, the way the router
|
|
88
|
+
# names it; nil when there is no such class.
|
|
89
|
+
#
|
|
90
|
+
# @param controller_path [String]
|
|
91
|
+
# @return [Class, nil]
|
|
92
|
+
def self.controller_class_for(controller_path)
|
|
93
|
+
"#{controller_path}_controller".camelize.safe_constantize
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Whether the controller runs the guard: it includes the concern and has
|
|
97
|
+
# not removed the callback with a blanket skip_before_action. (A skip
|
|
98
|
+
# limited with only:/except: still counts as guarded.)
|
|
99
|
+
#
|
|
100
|
+
# @param controller_class [Class, nil]
|
|
101
|
+
# @return [Boolean]
|
|
102
|
+
# @raise [PermissionForge::ConfigurationError] when the controller's
|
|
103
|
+
# before_action chain cannot be read (`_process_action_callbacks`, a
|
|
104
|
+
# Rails internal): the answer is not guessed either way
|
|
105
|
+
def self.guarded?(controller_class)
|
|
106
|
+
return false unless controller_class.respond_to?(:pf_action_guard_options)
|
|
107
|
+
unless controller_class.respond_to?(:_process_action_callbacks)
|
|
108
|
+
raise PermissionForge::ConfigurationError,
|
|
109
|
+
"PermissionForge cannot read the before_action chain of #{controller_class} (_process_action_callbacks, " \
|
|
110
|
+
"ActionPack #{ActionPack.version}): this version of the gem does not support that Rails version"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
controller_class._process_action_callbacks.any? { |callback| callback.filter == GUARD_CALLBACK }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.resource_name(resource)
|
|
117
|
+
name = resource.is_a?(Module) ? resource.name : resource
|
|
118
|
+
name.to_s.presence
|
|
119
|
+
end
|
|
120
|
+
private_class_method :resource_name
|
|
121
|
+
end
|
|
122
|
+
end
|