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,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Audit
|
|
5
|
+
# The notification subscribers the engine installs once configuration is
|
|
6
|
+
# final (Audit.install!). Each installer is a callable that receives the
|
|
7
|
+
# configuration and returns the subscriber it installed, or nil when its
|
|
8
|
+
# option is off:
|
|
9
|
+
#
|
|
10
|
+
# Audit::Subscribers.register(:log, ->(config) {
|
|
11
|
+
# ActiveSupport::Notifications.subscribe(/\.permission_forge\z/) { |event| ... } if config.audit_log_subscriber
|
|
12
|
+
# })
|
|
13
|
+
#
|
|
14
|
+
# The log subscriber (config.audit_log_subscriber), the store subscriber
|
|
15
|
+
# and the adapters for the legacy on_rule_bypass / on_field_filtered hooks
|
|
16
|
+
# live in lib/permission_forge/audit/subscribers/ and register here.
|
|
17
|
+
#
|
|
18
|
+
# @api private
|
|
19
|
+
module Subscribers
|
|
20
|
+
@installers = {}
|
|
21
|
+
@installed = {}
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
def register(name, installer)
|
|
25
|
+
@installers[name.to_sym] = installer
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Idempotent: an installer whose subscriber is in place is not called again.
|
|
29
|
+
def install!(configuration = PermissionForge.configuration)
|
|
30
|
+
@installers.each do |name, installer|
|
|
31
|
+
next if @installed.key?(name)
|
|
32
|
+
|
|
33
|
+
subscriber = installer.call(configuration)
|
|
34
|
+
@installed[name] = subscriber if subscriber
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def installed?(name)
|
|
39
|
+
@installed.key?(name.to_sym)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def uninstall!(name)
|
|
43
|
+
subscriber = @installed.delete(name.to_sym)
|
|
44
|
+
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Removes every installed subscriber (tests; a configuration change).
|
|
48
|
+
def reset!
|
|
49
|
+
uninstall!(@installed.keys.first) until @installed.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def names
|
|
53
|
+
@installers.keys
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# The one emitter of audit events.
|
|
5
|
+
#
|
|
6
|
+
# PermissionForge::Audit.record('assignment.revoked', record: assignment, reason: 'left the team')
|
|
7
|
+
# PermissionForge::Audit.instrument('access.denied') { { data: { 'model' => 'Customer', 'id' => 42 } } }
|
|
8
|
+
#
|
|
9
|
+
# `record` stores a change, admin or system event (when auditing is on) and
|
|
10
|
+
# publishes it as the "<event>.permission_forge" notification once the
|
|
11
|
+
# transaction it was recorded in has committed. `instrument` publishes a
|
|
12
|
+
# runtime (access) event and stores it only when its group is listed in
|
|
13
|
+
# `config.audit_runtime_events`; when nobody subscribes and the group is not
|
|
14
|
+
# stored it returns before building anything.
|
|
15
|
+
#
|
|
16
|
+
# Who, where and why come from the audit context (Audit::Context, set by
|
|
17
|
+
# `PermissionForge.with_audit_actor`, `Audit::Context.with` and the
|
|
18
|
+
# controllers), the record (Concerns::RecordsAuditEvents#audit_reference)
|
|
19
|
+
# and the payload (Audit::Recorder lists the keys).
|
|
20
|
+
#
|
|
21
|
+
# What happens when an event cannot be built or stored:
|
|
22
|
+
# - `record` follows config.audit_change_failure_strategy. Under :raise
|
|
23
|
+
# (default) an EventNotRecordedError is raised — out of the change's
|
|
24
|
+
# callback, so the change rolls back — and the open transaction is made
|
|
25
|
+
# to fail at commit (OpenTransaction#before_commit), so a caller that
|
|
26
|
+
# rescues the error inside its own transaction cannot commit the change
|
|
27
|
+
# without its record. Under :report / :log the event is written in a
|
|
28
|
+
# savepoint (a failed INSERT must not abort the caller's transaction on
|
|
29
|
+
# PostgreSQL), the failure is reported and the change goes through.
|
|
30
|
+
# - `instrument` follows config.audit_hook_failure_strategy, always in a
|
|
31
|
+
# savepoint: a denial is never allowed to break the request that was
|
|
32
|
+
# denied.
|
|
33
|
+
#
|
|
34
|
+
# @api public
|
|
35
|
+
module Audit
|
|
36
|
+
# A change could not be recorded (audit_change_failure_strategy = :raise).
|
|
37
|
+
# `cause` is what building or storing the event raised. Raised twice at
|
|
38
|
+
# most: out of the change, and again at the commit of a transaction that
|
|
39
|
+
# rescued it.
|
|
40
|
+
#
|
|
41
|
+
# @api public
|
|
42
|
+
class EventNotRecordedError < StandardError
|
|
43
|
+
# @return [String, nil] the name of the event that could not be written
|
|
44
|
+
attr_reader :event
|
|
45
|
+
|
|
46
|
+
def initialize(message = nil, event: nil, at_commit: false)
|
|
47
|
+
@event = event
|
|
48
|
+
super(message || default_message(event, at_commit))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def default_message(event, at_commit)
|
|
54
|
+
if at_commit
|
|
55
|
+
"the transaction was rolled back at commit: the audit event #{event} of a change inside it could not be written"
|
|
56
|
+
else
|
|
57
|
+
"the audit event #{event} could not be written, so the change was not made"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# How long a missing table is believed before it is looked for again:
|
|
63
|
+
# a worker that started before the migration records once it has run.
|
|
64
|
+
MISSING_TABLE_RECHECK = 60
|
|
65
|
+
|
|
66
|
+
TABLE_CHECK_MUTEX = Mutex.new
|
|
67
|
+
FAILED = Object.new.freeze
|
|
68
|
+
private_constant :FAILED
|
|
69
|
+
|
|
70
|
+
@table_checks = {}
|
|
71
|
+
@warned_tables = {}
|
|
72
|
+
@missing_table_recheck = MISSING_TABLE_RECHECK
|
|
73
|
+
|
|
74
|
+
extend RuntimeEmission
|
|
75
|
+
|
|
76
|
+
class << self
|
|
77
|
+
# Seconds a missing table is believed before it is looked for again.
|
|
78
|
+
attr_accessor :missing_table_recheck
|
|
79
|
+
|
|
80
|
+
# @param event [String] a name from the catalogue (not an access event)
|
|
81
|
+
# @param payload [Hash] see Audit::Recorder
|
|
82
|
+
# @return [AuditEvent, nil] the stored event; nil when auditing is off
|
|
83
|
+
# or the write failed under a non-raising audit_change_failure_strategy
|
|
84
|
+
# @raise [EventCatalog::UnknownEventError, ArgumentError, EventNotRecordedError]
|
|
85
|
+
def record(event, **payload)
|
|
86
|
+
entry = EventCatalog.fetch(event)
|
|
87
|
+
raise ArgumentError, "#{entry.name} is an access event: publish it with Audit.instrument" if entry.runtime?
|
|
88
|
+
|
|
89
|
+
emit(Recorder.new(entry, payload), store: enabled?, strategy: configuration.audit_change_failure_strategy, change: true)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @param event [String] an access event of the catalogue
|
|
93
|
+
# @param payload [Hash] see Audit::Recorder; source defaults to 'runtime'
|
|
94
|
+
# @yieldreturn [Hash] more payload, built only when the event is stored
|
|
95
|
+
# or someone listens — for hot paths:
|
|
96
|
+
# Audit.instrument('field.access') { { record: record, data: { 'hidden' => hidden } } }
|
|
97
|
+
# @return [AuditEvent, nil] the stored event, when its group is stored
|
|
98
|
+
#
|
|
99
|
+
# Published at once, stored now or when the open transaction has ended
|
|
100
|
+
# (committed or rolled back); nothing while this thread is already
|
|
101
|
+
# emitting a runtime event. See Audit::RuntimeEmission.
|
|
102
|
+
def instrument(event, **payload, &)
|
|
103
|
+
entry = EventCatalog.fetch(event)
|
|
104
|
+
raise ArgumentError, "#{entry.name} is a #{entry.category} event: store it with Audit.record" unless entry.runtime?
|
|
105
|
+
|
|
106
|
+
light = Gate.for(entry.name).direct_listeners
|
|
107
|
+
return instrument_light(entry, light, payload, &) if light
|
|
108
|
+
|
|
109
|
+
storing, listening = runtime_plan(entry)
|
|
110
|
+
return unless storing || listening
|
|
111
|
+
|
|
112
|
+
instrument_runtime(entry, payload, store: storing, &)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Whether instrumenting this event would do anything right now: its
|
|
116
|
+
# group is stored, or someone listens — and this thread is not already
|
|
117
|
+
# emitting one. For callers whose payload is expensive to gather.
|
|
118
|
+
#
|
|
119
|
+
# @param event [String] an access event of the catalogue
|
|
120
|
+
# @return [Boolean]
|
|
121
|
+
def instrument?(event)
|
|
122
|
+
entry = EventCatalog.fetch(event)
|
|
123
|
+
entry.runtime? && Gate.for(entry.name).open? && !emitting? &&
|
|
124
|
+
((configuration.store_runtime_group?(entry.runtime_group) && enabled?) || listening?(entry))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Whether events are stored: config.audit_events, and under :auto
|
|
128
|
+
# whether the table exists. A table that exists is remembered for the
|
|
129
|
+
# process; one that does not is looked for again after
|
|
130
|
+
# MISSING_TABLE_RECHECK seconds (reset! forgets at once).
|
|
131
|
+
#
|
|
132
|
+
# @param configuration [Configuration]
|
|
133
|
+
# @return [Boolean]
|
|
134
|
+
def enabled?(configuration = PermissionForge.configuration)
|
|
135
|
+
case configuration.audit_events
|
|
136
|
+
when true then true
|
|
137
|
+
when false then false
|
|
138
|
+
else table_available?
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Gives every event inside the block one correlation id (an outer block's
|
|
143
|
+
# id, else `id`, else a new one).
|
|
144
|
+
#
|
|
145
|
+
# PermissionForge::Audit.correlate { change_set.apply! }
|
|
146
|
+
#
|
|
147
|
+
# @return the value of the block
|
|
148
|
+
def correlate(id = nil, &)
|
|
149
|
+
Context.with(correlation_id: Context[:correlation_id] || id || new_id, &)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Logs the boot warning when :auto finds no table. Called by the engine
|
|
153
|
+
# once the database is reachable.
|
|
154
|
+
def verify_table!
|
|
155
|
+
enabled? if configuration.audit_events == :auto
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Forgets the table check and empties the context of this thread (tests,
|
|
159
|
+
# after a migration in the same process).
|
|
160
|
+
def reset!
|
|
161
|
+
TABLE_CHECK_MUTEX.synchronize do
|
|
162
|
+
@table_checks = {}
|
|
163
|
+
@warned_tables = {}
|
|
164
|
+
end
|
|
165
|
+
Context.reset!
|
|
166
|
+
Runtime.reset!
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Installs the notification subscribers and the executor hook of the
|
|
170
|
+
# gem's execution state (ExecutionState.install!): at the end of every
|
|
171
|
+
# executor run (request, job) whatever the run added to the audit
|
|
172
|
+
# context, to what runtime events remember for one request (Runtime)
|
|
173
|
+
# and to every block-scoped slot is removed — a request or a job ends
|
|
174
|
+
# with them empty. Called by the engine once configuration is final.
|
|
175
|
+
def install!(app)
|
|
176
|
+
Gate.install!
|
|
177
|
+
Subscribers.install!(configuration)
|
|
178
|
+
ExecutionState.install!(app)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @return [String] a fresh id from `config.uuid_generator`
|
|
182
|
+
# @api private
|
|
183
|
+
def new_id
|
|
184
|
+
configuration.uuid_generator.call
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# @param entry [EventCatalog::Entry]
|
|
188
|
+
# @return [Boolean] whether anyone subscribes to the event's notification
|
|
189
|
+
def listening?(entry)
|
|
190
|
+
ActiveSupport::Notifications.notifier.listening?(entry.notification)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def configuration
|
|
196
|
+
PermissionForge.configuration
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# [whether the event is stored, whether anyone listens]. The cheap
|
|
200
|
+
# answers first; the table is consulted only for a group that is stored.
|
|
201
|
+
def runtime_plan(entry)
|
|
202
|
+
return [false, false] unless Gate.for(entry.name).open?
|
|
203
|
+
|
|
204
|
+
storing = configuration.store_runtime_group?(entry.runtime_group)
|
|
205
|
+
listening = listening?(entry)
|
|
206
|
+
return [false, false] unless storing || listening
|
|
207
|
+
|
|
208
|
+
[storing && enabled?, listening]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def emit(recorder, store:, strategy:, change:)
|
|
212
|
+
stored = write(recorder, store: store, strategy: strategy, change: change)
|
|
213
|
+
return if stored.equal?(FAILED)
|
|
214
|
+
|
|
215
|
+
publish(recorder, stored)
|
|
216
|
+
stored
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Builds the event once and stores it, in a savepoint: a failed INSERT
|
|
220
|
+
# aborts the transaction it ran in on PostgreSQL, and the caller's
|
|
221
|
+
# transaction must stay usable — to go on under :report / :log, and to
|
|
222
|
+
# fail at its commit under :raise rather than at its next statement.
|
|
223
|
+
# Whatever fails here — resolving the actor, a payload that cannot be
|
|
224
|
+
# read, the INSERT — follows `strategy`.
|
|
225
|
+
#
|
|
226
|
+
# @return [AuditEvent, nil, FAILED]
|
|
227
|
+
def write(recorder, store:, strategy:, change:)
|
|
228
|
+
recorder.attributes
|
|
229
|
+
return nil unless store
|
|
230
|
+
|
|
231
|
+
PermissionForge::AuditEvent.transaction(requires_new: true) { recorder.store! }
|
|
232
|
+
rescue StandardError => e
|
|
233
|
+
raise_not_recorded(recorder, e) if change && strategy == :raise
|
|
234
|
+
|
|
235
|
+
Failure.handle(e, strategy: strategy, operation: :store, event: recorder.name)
|
|
236
|
+
FAILED
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# The change must not be committed: raise out of its callback, and make
|
|
240
|
+
# the transaction it runs in fail at commit for a caller that rescues.
|
|
241
|
+
def raise_not_recorded(recorder, error)
|
|
242
|
+
OpenTransaction.current&.before_commit do
|
|
243
|
+
raise EventNotRecordedError.new(event: recorder.name, at_commit: true), cause: error
|
|
244
|
+
end
|
|
245
|
+
raise EventNotRecordedError.new(event: recorder.name)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# After the open transaction commits (never after a rollback), else now.
|
|
249
|
+
def publish(recorder, stored)
|
|
250
|
+
return unless listening?(recorder.entry)
|
|
251
|
+
|
|
252
|
+
payload = recorder.notification_payload(stored)
|
|
253
|
+
transaction = OpenTransaction.current
|
|
254
|
+
if transaction
|
|
255
|
+
transaction.after_commit { notify(recorder.entry, payload) }
|
|
256
|
+
else
|
|
257
|
+
notify(recorder.entry, payload)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def notify(entry, payload)
|
|
262
|
+
ActiveSupport::Notifications.instrument(entry.notification, payload)
|
|
263
|
+
rescue StandardError => e
|
|
264
|
+
Failure.handle(e, strategy: configuration.audit_hook_failure_strategy, operation: :publish, event: entry.name)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# A table that was found is remembered without taking the lock. A
|
|
268
|
+
# missing one is believed for MISSING_TABLE_RECHECK seconds; a database
|
|
269
|
+
# that cannot be reached counts as no table and is asked again next time.
|
|
270
|
+
def table_available?
|
|
271
|
+
name = PermissionForge::AuditEvent.table_name
|
|
272
|
+
return true if @table_checks[name] == true
|
|
273
|
+
|
|
274
|
+
TABLE_CHECK_MUTEX.synchronize do
|
|
275
|
+
cached = @table_checks[name]
|
|
276
|
+
return true if cached == true
|
|
277
|
+
return false if cached.is_a?(Numeric) && Process.clock_gettime(Process::CLOCK_MONOTONIC) - cached < missing_table_recheck
|
|
278
|
+
|
|
279
|
+
check_table(name)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def check_table(name)
|
|
284
|
+
if PermissionForge::AuditEvent.table_exists?
|
|
285
|
+
@table_checks[name] = true
|
|
286
|
+
else
|
|
287
|
+
@table_checks[name] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
288
|
+
warn_missing_table(name) unless @warned_tables[name]
|
|
289
|
+
@warned_tables[name] = true
|
|
290
|
+
false
|
|
291
|
+
end
|
|
292
|
+
rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished => e
|
|
293
|
+
Rails.logger&.warn("[PermissionForge] cannot check for the audit table '#{name}': #{e.class}") if defined?(Rails)
|
|
294
|
+
false
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def warn_missing_table(name)
|
|
298
|
+
Rails.logger.warn(BootWarnings.missing_table(name)) if defined?(Rails) && Rails.logger
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# What the engine checks while the host app boots: run (from
|
|
5
|
+
# config.after_initialize) and ensure_tenancy_gem! (from an initializer).
|
|
6
|
+
#
|
|
7
|
+
# The configuration checks need nothing. The database steps are advisory
|
|
8
|
+
# warnings or idempotent work, and they must never abort a boot: a Docker
|
|
9
|
+
# build precompiles assets with no database, a worker can start while the
|
|
10
|
+
# database fails over. The database is pinged once (db_available?); a step
|
|
11
|
+
# that loses it midway is skipped (DATABASE_STEP_ERRORS) and the next step
|
|
12
|
+
# runs. Anything else a step raises (a ConfigurationError) still stops the
|
|
13
|
+
# boot.
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
16
|
+
module BootChecks
|
|
17
|
+
# Errors that mean "the database is not there (yet)": gone, refused, not
|
|
18
|
+
# created, not migrated.
|
|
19
|
+
DATABASE_STEP_ERRORS = [ActiveRecord::ConnectionNotEstablished, ActiveRecord::StatementInvalid].freeze
|
|
20
|
+
|
|
21
|
+
# The boot steps that read the database, in order. Each runs on its
|
|
22
|
+
# own: one that loses the database is skipped (database_step).
|
|
23
|
+
DATABASE_STEPS = {
|
|
24
|
+
unmasked_gdpr_fields: ->(config) { warn_unmasked_gdpr_fields(config) unless config.gdpr_defaults },
|
|
25
|
+
conditional_sets: ->(config) { warn_conditional_sets_without_context(config) },
|
|
26
|
+
join_rules: ->(config) { QueryScopingWarnings.warn_join_rules_not_auto_scoped(config) },
|
|
27
|
+
row_level_security: ->(config) { QueryScopingWarnings.warn_rls_misconfiguration(config) },
|
|
28
|
+
# The missing-table warning, auditing switched off, one shared login.
|
|
29
|
+
audit: ->(config) { Audit::BootWarnings.call(config) },
|
|
30
|
+
# The audit settings in force, when they differ from the last recorded
|
|
31
|
+
# ones. Never in the test environment (it is invoked directly there).
|
|
32
|
+
audit_settings: ->(_config) { Audit::SettingsObserver.call unless Rails.env.test? },
|
|
33
|
+
# Auto-applied masking (MaskedModels), kept in step by MaskingSync.
|
|
34
|
+
masked_attributes: ->(_config) { MaskingSync.boot! }
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# @param config [Configuration]
|
|
39
|
+
def run(config)
|
|
40
|
+
config.validate_gdpr_explicit_choice!
|
|
41
|
+
warn_configuration(config)
|
|
42
|
+
return database_unreachable unless db_available?
|
|
43
|
+
|
|
44
|
+
DATABASE_STEPS.each { |name, step| database_step(name) { step.call(config) } }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# HasTenantScoping silently skips the scope when the gem is absent,
|
|
48
|
+
# which would leave every tenant's permission data visible to every
|
|
49
|
+
# other tenant — refuse to boot instead.
|
|
50
|
+
#
|
|
51
|
+
# @param config [Configuration]
|
|
52
|
+
# @raise [ConfigurationError]
|
|
53
|
+
def ensure_tenancy_gem!(config)
|
|
54
|
+
return unless config.tenant_enabled?
|
|
55
|
+
return if defined?(::ActsAsTenant)
|
|
56
|
+
|
|
57
|
+
raise PermissionForge::ConfigurationError,
|
|
58
|
+
"config.tenant_model is set to '#{config.tenant_model}' but the acts_as_tenant gem is not loaded. " \
|
|
59
|
+
'Add `gem "acts_as_tenant"` to your Gemfile, or set config.tenant_model = nil.'
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Conditional permission sets (session flag / IP / login hours) with a
|
|
63
|
+
# request_context_resolver that returns an empty context evaluate as
|
|
64
|
+
# unconditional (always active) unless conditional_sets_strict_mode is on.
|
|
65
|
+
def warn_conditional_sets_without_context(config)
|
|
66
|
+
# Strict mode denies conditional sets under blank context (fail closed),
|
|
67
|
+
# so the always-active footgun doesn't apply — no warning needed.
|
|
68
|
+
return if config.conditional_sets_strict_mode
|
|
69
|
+
|
|
70
|
+
conditional_sets = PermissionForge::PermissionSet.conditional
|
|
71
|
+
return unless conditional_sets.exists?
|
|
72
|
+
return unless resolved_context(config).empty?
|
|
73
|
+
|
|
74
|
+
Rails.logger.warn '[PermissionForge] SECURITY warning: ' \
|
|
75
|
+
"#{conditional_sets.count} permission set(s) use activation conditions " \
|
|
76
|
+
'(session flag, allowed_ips, or login hours) but request_context_resolver ' \
|
|
77
|
+
'returns empty context. These sets will evaluate as unconditional ' \
|
|
78
|
+
'(ALWAYS ACTIVE) — IP/hours/consent restrictions are NOT enforced. ' \
|
|
79
|
+
'Configure config.request_context_resolver, or set ' \
|
|
80
|
+
'config.conditional_sets_strict_mode = true to fail closed.'
|
|
81
|
+
rescue ActiveRecord::StatementInvalid
|
|
82
|
+
# Tables don't exist yet (pre-migration)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def warn_api_authorization_gap(config)
|
|
86
|
+
return unless config.api_enabled
|
|
87
|
+
return if config.api_require_authorization
|
|
88
|
+
return if config.api_action_authorization && config.api_assignment_authorization
|
|
89
|
+
|
|
90
|
+
Rails.logger&.warn '[PermissionForge] SECURITY warning: API is enabled, ' \
|
|
91
|
+
'config.api_require_authorization is false and ' \
|
|
92
|
+
'api_action_authorization / api_assignment_authorization is not set. ' \
|
|
93
|
+
'api_auth only proves identity — any authenticated caller can create ' \
|
|
94
|
+
'permission sets and assign them (privilege escalation). Configure both ' \
|
|
95
|
+
'authorization lambdas, or remove api_require_authorization = false ' \
|
|
96
|
+
'(the default, true, denies mutations until they are set).'
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# One query for the distinct unmasked names (no record is built), and
|
|
100
|
+
# one more for the resource of the first match.
|
|
101
|
+
def warn_unmasked_gdpr_fields(config)
|
|
102
|
+
patterns = config.gdpr_field_patterns
|
|
103
|
+
return if patterns.blank?
|
|
104
|
+
|
|
105
|
+
unmasked = PermissionForge::ResourceField.where(mask_access: false)
|
|
106
|
+
name = unmasked.distinct.pluck(:name).find { |field_name| gdpr_match?(field_name, patterns) }
|
|
107
|
+
return unless name
|
|
108
|
+
|
|
109
|
+
resource = unmasked.where(name: name).first&.resource
|
|
110
|
+
Rails.logger.warn "[PermissionForge] GDPR warning: field '#{name}' on resource " \
|
|
111
|
+
"'#{resource&.name}' matches GDPR pattern but is not masked. " \
|
|
112
|
+
'Consider enabling gdpr_defaults or masking this field manually.'
|
|
113
|
+
rescue ActiveRecord::StatementInvalid
|
|
114
|
+
# Table doesn't exist yet (pre-migration)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# Connects and pings. `ActiveRecord::Base.connection` alone is not enough:
|
|
120
|
+
# since Rails 7.2 it hands back a connection that has not connected yet,
|
|
121
|
+
# so an unreachable database looked available.
|
|
122
|
+
def db_available?
|
|
123
|
+
PermissionForge::PermissionSet.connection_pool.with_connection(&:verify!)
|
|
124
|
+
true
|
|
125
|
+
rescue ActiveRecord::ActiveRecordError
|
|
126
|
+
false
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def database_step(name)
|
|
130
|
+
yield
|
|
131
|
+
rescue *DATABASE_STEP_ERRORS => e
|
|
132
|
+
Rails.logger&.warn "[PermissionForge] Boot check #{name} skipped: the database could not be read (#{e.class})."
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The boot step did not run: the first request or job that reaches the
|
|
136
|
+
# database applies auto-applied masking (MaskingSync).
|
|
137
|
+
def database_unreachable
|
|
138
|
+
MaskingSync.reset!
|
|
139
|
+
Rails.logger&.warn '[PermissionForge] The database could not be reached at boot; the boot checks that ' \
|
|
140
|
+
'read it (security warnings) were skipped. Auto-applied masking is applied at the first ' \
|
|
141
|
+
'request or job that reaches the database.'
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The boot warnings that only read the configuration.
|
|
145
|
+
def warn_configuration(config)
|
|
146
|
+
warn_memory_cache_in_production(config)
|
|
147
|
+
warn_basic_auth_without_credentials(config)
|
|
148
|
+
warn_api_authorization_gap(config)
|
|
149
|
+
config.tenantless_admin_warning&.then { |message| Rails.logger&.warn(message) }
|
|
150
|
+
QueryScopingWarnings.warn_unscoped_detection_raise_in_production(config)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def warn_memory_cache_in_production(config)
|
|
154
|
+
return unless Rails.env.production? && config.cache_backend == :memory
|
|
155
|
+
|
|
156
|
+
Rails.logger&.warn '[PermissionForge] WARNING: Using in-process MemoryCache in production. ' \
|
|
157
|
+
'Permissions will be inconsistent across Puma workers. ' \
|
|
158
|
+
'Configure config.cache_backend = :rails_cache with Redis, Memcached, or SolidCache.'
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def warn_basic_auth_without_credentials(config)
|
|
162
|
+
return unless config.http_basic_auth_enabled
|
|
163
|
+
return if config.http_basic_auth_user.present? && config.http_basic_auth_password.present?
|
|
164
|
+
|
|
165
|
+
Rails.logger&.warn '[PermissionForge] HTTP Basic Auth is enabled but credentials are not configured. ' \
|
|
166
|
+
'The admin UI will return 401 for all requests. ' \
|
|
167
|
+
'Set config.http_basic_auth_user and config.http_basic_auth_password.'
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def gdpr_match?(field_name, patterns)
|
|
171
|
+
patterns.any? { |regex, _| field_name.match?(regex) }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Taken as the per-read selection takes it: a Hash, or anything with
|
|
175
|
+
# #to_h (a Struct; nil is blank). A resolver that raises gives none.
|
|
176
|
+
def resolved_context(config)
|
|
177
|
+
resolved = config.request_context_resolver.call
|
|
178
|
+
resolved.is_a?(Hash) ? resolved : resolved.to_h
|
|
179
|
+
rescue StandardError
|
|
180
|
+
{}
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Cache
|
|
5
|
+
# The invalidations this process could not get the store to confirm, kept
|
|
6
|
+
# so they can be made when the store answers again. An entry is listed by
|
|
7
|
+
# its stamp key: one per user (PermissionCacheAccessor), one for the
|
|
8
|
+
# managed-actions index (ControllerActions::ManagedIndex).
|
|
9
|
+
#
|
|
10
|
+
# Without it, an entry that should have been removed while the store was
|
|
11
|
+
# unreachable is a cache hit again as soon as the store is back, and
|
|
12
|
+
# stays one until cache_ttl. With it:
|
|
13
|
+
#
|
|
14
|
+
# - a user listed here is not served from the store by this process:
|
|
15
|
+
# the hash is rebuilt (PermissionCacheAccessor#pf_read_shared_permissions);
|
|
16
|
+
# neither is the managed-actions index while it is listed;
|
|
17
|
+
# - the listed invalidations are tried again after the next one the
|
|
18
|
+
# store confirms, and before a hash is read from the store while the
|
|
19
|
+
# list is not empty (Invalidation.retry_owed).
|
|
20
|
+
#
|
|
21
|
+
# This is extra effort on top of the failure report, not a guarantee. The
|
|
22
|
+
# list lives in this process: another process serves the entry as before,
|
|
23
|
+
# a restart forgets the list, and it holds LIMIT entries — the one owed
|
|
24
|
+
# longest is dropped for a new one. An edit to a set whose holders the
|
|
25
|
+
# store fails to invalidate (HolderInvalidation, during an outage) owes
|
|
26
|
+
# one entry per holder: past LIMIT holders only the last LIMIT are kept.
|
|
27
|
+
# For all of those cache_ttl remains the backstop, and every failure was
|
|
28
|
+
# reported when it happened.
|
|
29
|
+
#
|
|
30
|
+
# A pass over the list must not turn an outage — or the recovery from
|
|
31
|
+
# one — into a delay for one request: it tries PER_PASS entries at most
|
|
32
|
+
# (the next confirmed invalidation or store read goes on with the rest),
|
|
33
|
+
# it stops after GIVE_UP_AFTER failed tries, and after a failed try the
|
|
34
|
+
# next pass waits PAUSE seconds. A try that failed moves to the end of the
|
|
35
|
+
# list, so an entry whose key keeps failing cannot hold up the others.
|
|
36
|
+
#
|
|
37
|
+
# @api private
|
|
38
|
+
class Backlog
|
|
39
|
+
LIMIT = 500
|
|
40
|
+
PER_PASS = 25
|
|
41
|
+
GIVE_UP_AFTER = 3
|
|
42
|
+
PAUSE = 5
|
|
43
|
+
|
|
44
|
+
MONOTONIC = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
45
|
+
|
|
46
|
+
# @param clock [#call] seconds, monotonic
|
|
47
|
+
def initialize(clock: MONOTONIC)
|
|
48
|
+
@clock = clock
|
|
49
|
+
@entries = {}
|
|
50
|
+
@mutex = Mutex.new
|
|
51
|
+
@pass = Mutex.new
|
|
52
|
+
@paused_until = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Boolean] lock-free, for the path of every permission read
|
|
56
|
+
def empty?
|
|
57
|
+
@entries.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param stamp_key [String] e.g. "user_1_invalidation"
|
|
61
|
+
# @return [Boolean]
|
|
62
|
+
def include?(stamp_key)
|
|
63
|
+
@mutex.synchronize { @entries.key?(stamp_key) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param user_id [Object, nil] whose invalidation is owed (for the
|
|
67
|
+
# report; nil for the managed-actions index)
|
|
68
|
+
# @param stamp_key [String] where its invalidation stamp lives: what the
|
|
69
|
+
# entry is listed by
|
|
70
|
+
# @param keys [Array<String>] the cache keys that may still hold an entry
|
|
71
|
+
# @return [Boolean] whether this is news: false when the entry was listed
|
|
72
|
+
# with all of these keys already
|
|
73
|
+
def add(user_id:, stamp_key:, keys:)
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
known = @entries.delete(stamp_key)
|
|
76
|
+
keys |= known[:keys] if known
|
|
77
|
+
@entries[stamp_key] = { user_id: user_id, stamp_key: stamp_key, keys: keys }.freeze
|
|
78
|
+
@entries.shift while @entries.size > LIMIT
|
|
79
|
+
known.nil? || keys.size > known[:keys].size
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# One pass over the list, unless one is running or the last one failed
|
|
84
|
+
# less than PAUSE seconds ago.
|
|
85
|
+
#
|
|
86
|
+
# @yieldparam entry [Hash] user_id:, stamp_key:, keys:
|
|
87
|
+
# @yieldreturn [Boolean] whether the store confirmed the invalidation
|
|
88
|
+
# @return [void]
|
|
89
|
+
def try_again(&)
|
|
90
|
+
return if paused? || !@pass.try_lock
|
|
91
|
+
|
|
92
|
+
begin
|
|
93
|
+
pause if pass(&).positive?
|
|
94
|
+
ensure
|
|
95
|
+
@pass.unlock
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [void]
|
|
100
|
+
def clear
|
|
101
|
+
@mutex.synchronize { @entries.clear }
|
|
102
|
+
@paused_until = nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# @return [Integer] the tries that failed
|
|
108
|
+
def pass
|
|
109
|
+
failures = 0
|
|
110
|
+
@mutex.synchronize { @entries.values.first(PER_PASS) }.each do |entry|
|
|
111
|
+
break if failures >= GIVE_UP_AFTER
|
|
112
|
+
|
|
113
|
+
confirmed = yield(entry)
|
|
114
|
+
failures += 1 unless confirmed
|
|
115
|
+
settle(entry, confirmed)
|
|
116
|
+
end
|
|
117
|
+
failures
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Takes a confirmed entry off the list and moves a failed one to its
|
|
121
|
+
# end — unless a new failure of the same entry has replaced it meanwhile.
|
|
122
|
+
def settle(entry, confirmed)
|
|
123
|
+
stamp_key = entry[:stamp_key]
|
|
124
|
+
@mutex.synchronize do
|
|
125
|
+
next unless @entries[stamp_key].equal?(entry)
|
|
126
|
+
|
|
127
|
+
@entries.delete(stamp_key)
|
|
128
|
+
@entries[stamp_key] = entry unless confirmed
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def paused?
|
|
133
|
+
paused_until = @paused_until
|
|
134
|
+
!paused_until.nil? && @clock.call < paused_until
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def pause
|
|
138
|
+
@paused_until = @clock.call + PAUSE
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|