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,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Audit
|
|
5
|
+
# The per-request (per-thread, or per-fiber under Rails' fiber isolation:
|
|
6
|
+
# ExecutionState) store of what every event recorded inside a block
|
|
7
|
+
# carries: the actor, the source, the request id and IP, a reason, and a
|
|
8
|
+
# correlation id.
|
|
9
|
+
#
|
|
10
|
+
# Audit::Context.with(source: 'admin_ui', request_id: request.request_id, ip: request.remote_ip) do
|
|
11
|
+
# ...
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Blocks nest: an inner value overrides the outer one for the block, a
|
|
15
|
+
# nil leaves the outer one in place, and the outer context is restored
|
|
16
|
+
# when the block ends — also when it raises. Nothing is ever set outside
|
|
17
|
+
# a block, so nothing can leak to the next request; as a second line of
|
|
18
|
+
# defence the end of every executor run empties a context that was empty
|
|
19
|
+
# when the run started (ExecutionState::RunHook): a request or a job ends
|
|
20
|
+
# with none, and a block that wraps the run keeps its own
|
|
21
|
+
# (with_audit_actor around an inline job keeps its actor after the job).
|
|
22
|
+
#
|
|
23
|
+
# @api public
|
|
24
|
+
module Context
|
|
25
|
+
KEY = :pf_audit_context
|
|
26
|
+
TRANSACTION_KEY = :pf_audit_transaction_correlation
|
|
27
|
+
SLOTS = %i[actor source request_id ip reason correlation_id].freeze
|
|
28
|
+
EMPTY = {}.freeze
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# @return [Hash] frozen; empty when nothing is set
|
|
32
|
+
def current
|
|
33
|
+
state[KEY] || EMPTY
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param slot [Symbol] one of SLOTS
|
|
37
|
+
# @return [Object, nil] the value of that slot in the current context
|
|
38
|
+
def [](slot)
|
|
39
|
+
current[slot]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param slots [Hash] actor: (an Actor, a user or a Hash), source:,
|
|
43
|
+
# request_id:, ip:, reason:, correlation_id:
|
|
44
|
+
# @return the value of the block
|
|
45
|
+
# @raise [ArgumentError] for an unknown slot, actor type or source
|
|
46
|
+
def with(**slots, &)
|
|
47
|
+
ExecutionState.with(KEY, current.merge(normalize(slots)).freeze, &)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Empties the context of this thread (tests, the end of a request).
|
|
51
|
+
def reset!
|
|
52
|
+
state.delete(KEY)
|
|
53
|
+
state.delete(TRANSACTION_KEY)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# One id per outermost open transaction (OpenTransaction), minted the
|
|
57
|
+
# first time an event asks for it inside that transaction: a cascade
|
|
58
|
+
# delete or a Review-and-save then shares one id.
|
|
59
|
+
#
|
|
60
|
+
# @return [String, nil] nil when no transaction is open
|
|
61
|
+
def transaction_correlation_id
|
|
62
|
+
transaction = OpenTransaction.current
|
|
63
|
+
return unless transaction
|
|
64
|
+
|
|
65
|
+
handle, id = state[TRANSACTION_KEY]
|
|
66
|
+
return id if handle && !handle.finished?
|
|
67
|
+
|
|
68
|
+
id = Audit.new_id
|
|
69
|
+
state[TRANSACTION_KEY] = [transaction, id]
|
|
70
|
+
id
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def state
|
|
76
|
+
ExecutionState
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def normalize(slots)
|
|
80
|
+
slots.each_with_object({}) do |(slot, value), out|
|
|
81
|
+
raise ArgumentError, "unknown audit context slot #{slot.inspect} (slots: #{SLOTS.join(', ')})" unless SLOTS.include?(slot)
|
|
82
|
+
next if value.nil?
|
|
83
|
+
|
|
84
|
+
out[slot] = cast(slot, value)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def cast(slot, value)
|
|
89
|
+
case slot
|
|
90
|
+
when :actor then Actor.coerce(value)
|
|
91
|
+
when :source then Source.validate!(value)
|
|
92
|
+
else value.to_s
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Audit
|
|
5
|
+
# Records an event that must outlive the transaction it was noticed in:
|
|
6
|
+
# a failed sign-in, a refused change. Those are evidence of something
|
|
7
|
+
# that did NOT happen, so a rollback of whatever the caller had open must
|
|
8
|
+
# not take them with it.
|
|
9
|
+
#
|
|
10
|
+
# Audit::Detached.record('admin.lock_refused', record: set)
|
|
11
|
+
#
|
|
12
|
+
# No transaction open (OpenTransaction): written at once, like
|
|
13
|
+
# Audit.record. Inside one: written once that transaction has ended,
|
|
14
|
+
# committed or rolled back (a savepoint that rolls back hands it to the
|
|
15
|
+
# transaction around it). Who, where, the request and the reason are
|
|
16
|
+
# taken at the moment of the call.
|
|
17
|
+
#
|
|
18
|
+
# @api private
|
|
19
|
+
module Detached
|
|
20
|
+
class << self
|
|
21
|
+
# @param event [String] an admin, change or system event of the catalogue
|
|
22
|
+
# @param payload [Hash] see Audit::Recorder
|
|
23
|
+
# @return [AuditEvent, nil] the stored event; nil when it was deferred
|
|
24
|
+
# or auditing is off
|
|
25
|
+
def record(event, **payload)
|
|
26
|
+
transaction = OpenTransaction.current(PermissionForge::AuditEvent)
|
|
27
|
+
return Audit.record(event, **payload) unless transaction
|
|
28
|
+
|
|
29
|
+
defer(transaction, event, pinned(payload))
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Who, where and the tenant of the moment of the call: a tenant
|
|
36
|
+
# switched (or lost) before the transaction ends does not move it.
|
|
37
|
+
def pinned(payload)
|
|
38
|
+
{ actor: Actor.current, source: Source.current, ambient_tenant_id: PermissionForge.ambient_tenant_id }.merge(payload)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# OpenTransaction#after_end: once, when the outermost transaction has
|
|
42
|
+
# ended either way (a savepoint that rolls back hands it on).
|
|
43
|
+
def defer(transaction, event, payload)
|
|
44
|
+
context = Context.current
|
|
45
|
+
transaction.after_end { Context.with(**context) { write_later(event, payload) } }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Runs from Rails' commit or rollback of the caller's transaction:
|
|
49
|
+
# raising there would replace the caller's own exception, or fail a
|
|
50
|
+
# transaction that has already committed. Nothing can be kept from
|
|
51
|
+
# happening any more, so a failure is reported (or logged) and never
|
|
52
|
+
# raised, whatever the strategy.
|
|
53
|
+
def write_later(event, payload)
|
|
54
|
+
Audit.record(event, **payload)
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
strategy = PermissionForge.configuration.audit_hook_failure_strategy
|
|
57
|
+
Failure.handle(e, strategy: strategy == :raise ? :report : strategy, operation: :store, event: event)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Audit
|
|
5
|
+
# The registry of audit events: name → category, and for runtime (access)
|
|
6
|
+
# events the group that `config.audit_runtime_events` switches storage on
|
|
7
|
+
# for. Adding an event is one `register` call; an event that is not here
|
|
8
|
+
# cannot be recorded or published (a typo fails loudly, not silently).
|
|
9
|
+
#
|
|
10
|
+
# Notification name: "<event>.permission_forge".
|
|
11
|
+
#
|
|
12
|
+
# @api public
|
|
13
|
+
class EventCatalog
|
|
14
|
+
# @api private
|
|
15
|
+
class UnknownEventError < ArgumentError; end
|
|
16
|
+
|
|
17
|
+
# `notification` is computed once: Audit.instrument asks for it on hot paths.
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
20
|
+
Entry = Struct.new(:name, :category, :runtime_group, :notification, keyword_init: true) do
|
|
21
|
+
# Runtime events are published by Audit.instrument and stored per group.
|
|
22
|
+
def runtime? = category == 'access'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
CATEGORIES = %w[change admin system access].freeze
|
|
26
|
+
RUNTIME_GROUPS = PermissionForge::Configuration::AuditSettings::RUNTIME_GROUPS
|
|
27
|
+
|
|
28
|
+
REGISTRY_MUTEX = Mutex.new
|
|
29
|
+
|
|
30
|
+
@entries = {}
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# @param name [String, Symbol] e.g. 'assignment.revoked'
|
|
34
|
+
# @param category [String] change, admin, system or access
|
|
35
|
+
# @param runtime_group [Symbol, nil] required for access events, refused
|
|
36
|
+
# for the others
|
|
37
|
+
# @return [Entry]
|
|
38
|
+
def register(name, category:, runtime_group: nil)
|
|
39
|
+
entry = Entry.new(name: name.to_s, category: category.to_s, runtime_group: runtime_group&.to_sym,
|
|
40
|
+
notification: "#{name}.permission_forge").freeze
|
|
41
|
+
validate!(entry)
|
|
42
|
+
REGISTRY_MUTEX.synchronize { @entries[entry.name] = entry }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Removes an event from the catalog (for tests that register their own).
|
|
46
|
+
# @param name [String, Symbol]
|
|
47
|
+
def unregister(name)
|
|
48
|
+
REGISTRY_MUTEX.synchronize { @entries.delete(name.to_s) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @raise [UnknownEventError] for a name that is not registered
|
|
52
|
+
def fetch(name)
|
|
53
|
+
@entries[name.to_s] || raise(UnknownEventError, "unknown audit event #{name.inspect}: register it with " \
|
|
54
|
+
'PermissionForge::Audit::EventCatalog.register')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def registered?(name)
|
|
58
|
+
@entries.key?(name.to_s)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Hash{String => Entry}] a copy
|
|
62
|
+
def entries
|
|
63
|
+
@entries.dup.freeze
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Array<String>] the names of every registered event
|
|
67
|
+
def names
|
|
68
|
+
@entries.keys
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def validate!(entry)
|
|
74
|
+
raise ArgumentError, "audit event category must be one of #{CATEGORIES.join(', ')} (got #{entry.category.inspect})" \
|
|
75
|
+
unless CATEGORIES.include?(entry.category)
|
|
76
|
+
|
|
77
|
+
if entry.runtime?
|
|
78
|
+
raise ArgumentError, "access event #{entry.name} needs a runtime group (#{RUNTIME_GROUPS.join(', ')})" \
|
|
79
|
+
unless RUNTIME_GROUPS.include?(entry.runtime_group)
|
|
80
|
+
elsif entry.runtime_group
|
|
81
|
+
raise ArgumentError, "#{entry.name} is a #{entry.category} event: only access events have a runtime group"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# --- the catalogue of the first release ------------------------------------------
|
|
87
|
+
|
|
88
|
+
%w[permission_set resource resource_field resource_action permission_set_rule permission_set_group]
|
|
89
|
+
.product(%w[created updated destroyed]).each { |model, verb| register("#{model}.#{verb}", category: 'change') }
|
|
90
|
+
|
|
91
|
+
%w[permission_set.cloned permission_set_group.cloned assignment.granted assignment.changed assignment.revoked assignment.reviewed
|
|
92
|
+
group_membership.added group_membership.changed group_membership.removed field.sensitive_acknowledged]
|
|
93
|
+
.each { |name| register(name, category: 'change') }
|
|
94
|
+
|
|
95
|
+
%w[erasure.performed consent.withdrawn cleanup.expired_assignments cache.cleared audit.purged audit.settings_observed
|
|
96
|
+
report.generated].each { |name| register(name, category: 'system') }
|
|
97
|
+
|
|
98
|
+
%w[admin.effective_access_viewed audit.viewed audit.exported admin.auth_failed api.auth_failed admin.lock_refused]
|
|
99
|
+
.each { |name| register(name, category: 'admin') }
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
'access.denied' => :denied, 'action.denied' => :denied,
|
|
103
|
+
'rule.bypassed' => :bypass, 'query.unscoped' => :bypass,
|
|
104
|
+
'field.access' => :field_read,
|
|
105
|
+
'params.filtered' => :params_filtered, 'write.masked_skipped' => :params_filtered,
|
|
106
|
+
'permissions.rebuilt' => :cache, 'permissions.invalidated' => :cache
|
|
107
|
+
}.each { |name, group| register(name, category: 'access', runtime_group: group) }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Audit
|
|
5
|
+
# Turns filter parameters (the API endpoint, the HTML audit log) into a
|
|
6
|
+
# relation of audit events. Knows nothing of controllers.
|
|
7
|
+
# Every value reaches the database as a bound value through `where`
|
|
8
|
+
# (never interpolated); a value that is not a plain string, a timestamp
|
|
9
|
+
# that is not ISO 8601, or a name that is not in the catalogue is an
|
|
10
|
+
# error naming the parameter, not a silently empty result.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
class EventQuery
|
|
14
|
+
class Invalid < StandardError
|
|
15
|
+
attr_reader :errors
|
|
16
|
+
|
|
17
|
+
# @param errors [Hash{Symbol => Array<String>}] parameter => messages
|
|
18
|
+
def initialize(errors)
|
|
19
|
+
@errors = errors
|
|
20
|
+
super(errors.inspect)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
DATE_ONLY = /\A(\d{4})-(\d{2})-(\d{2})\z/
|
|
25
|
+
TIMESTAMP = /\A(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(\.\d{1,9})?)?(Z|[+-]\d{2}(?::?\d{2})?)?\z/
|
|
26
|
+
|
|
27
|
+
# @param params [ActionController::Parameters, Hash] from to event category actor_type actor_id
|
|
28
|
+
# subject_user_id permission_set record_type record_id correlation_id (others are ignored)
|
|
29
|
+
# @param scope [ActiveRecord::Relation] the events visible to the caller
|
|
30
|
+
# @return [ActiveRecord::Relation] newest first
|
|
31
|
+
# @raise [Invalid] with #errors, parameter => messages
|
|
32
|
+
def self.call(params, scope: PermissionForge::AuditEvent.all)
|
|
33
|
+
new(params, scope).call
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(params, scope)
|
|
37
|
+
@params = params
|
|
38
|
+
@scope = scope
|
|
39
|
+
@errors = Hash.new { |hash, key| hash[key] = [] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [ActiveRecord::Relation] newest first
|
|
43
|
+
# @raise [Invalid]
|
|
44
|
+
def call
|
|
45
|
+
filter_times
|
|
46
|
+
filter_events
|
|
47
|
+
filter_inclusion(:category, PermissionForge::AuditEvent::CATEGORIES)
|
|
48
|
+
filter_inclusion(:actor_type, PermissionForge::AuditEvent::ACTOR_TYPES)
|
|
49
|
+
%i[actor_id subject_user_id correlation_id record_type].each { |name| filter_equal(name) }
|
|
50
|
+
filter_permission_set
|
|
51
|
+
filter_record
|
|
52
|
+
raise Invalid, @errors.to_h if @errors.any?
|
|
53
|
+
|
|
54
|
+
@scope.newest_first
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def invalid(name)
|
|
60
|
+
problem(name, :invalid)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Rails' own validation messages (errors.messages.<kind>), like the
|
|
64
|
+
# 422 bodies of the other endpoints.
|
|
65
|
+
def problem(name, kind)
|
|
66
|
+
@errors[name] << I18n.t("errors.messages.#{kind}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A parameter's value as a String, nil when absent; an array or hash
|
|
70
|
+
# (`?actor_id[]=1`, `?actor_id[a]=1`) is invalid.
|
|
71
|
+
def value(name)
|
|
72
|
+
raw = @params[name]
|
|
73
|
+
return nil if raw.nil? || raw == ''
|
|
74
|
+
return raw if plain?(raw)
|
|
75
|
+
|
|
76
|
+
invalid(name)
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def plain?(raw)
|
|
81
|
+
raw.is_a?(String) && !raw.include?("\0")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def filter_equal(name)
|
|
85
|
+
found = value(name)
|
|
86
|
+
@scope = @scope.where(name => found) if found
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def filter_inclusion(name, allowed)
|
|
90
|
+
found = value(name)
|
|
91
|
+
return unless found
|
|
92
|
+
|
|
93
|
+
allowed.include?(found) ? @scope = @scope.where(name => found) : invalid(name)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def filter_events
|
|
97
|
+
raw = @params[:event]
|
|
98
|
+
return if raw.nil? || raw == ''
|
|
99
|
+
|
|
100
|
+
names = event_names(raw)
|
|
101
|
+
return invalid(:event) unless names&.all? { |name| PermissionForge::Audit::EventCatalog.registered?(name) }
|
|
102
|
+
|
|
103
|
+
@scope = @scope.where(event: names.uniq)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# nil when anything in the list is not a plain, non-blank string.
|
|
107
|
+
def event_names(raw)
|
|
108
|
+
items = Array.wrap(raw)
|
|
109
|
+
return nil unless items.all? { |item| plain?(item) }
|
|
110
|
+
|
|
111
|
+
names = items.flat_map { |item| item.split(',').map(&:strip) }
|
|
112
|
+
names.any?(&:blank?) ? nil : names
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def filter_times
|
|
116
|
+
from = parse_time(:from, end_of_day: false)
|
|
117
|
+
to = parse_time(:to, end_of_day: true)
|
|
118
|
+
@scope = @scope.where(PermissionForge::AuditEvent.arel_table[:occurred_at].gteq(from)) if from
|
|
119
|
+
@scope = @scope.where(PermissionForge::AuditEvent.arel_table[:occurred_at].lteq(to)) if to
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# ISO 8601, read in UTC unless the text carries an offset; an impossible
|
|
123
|
+
# calendar date or clock time is invalid (never rolled over).
|
|
124
|
+
def parse_time(name, end_of_day:)
|
|
125
|
+
text = value(name)
|
|
126
|
+
return nil unless text
|
|
127
|
+
|
|
128
|
+
time = DATE_ONLY.match(text) ? parse_day(text, end_of_day) : parse_timestamp(text)
|
|
129
|
+
invalid(name) unless time
|
|
130
|
+
time
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def parse_day(text, end_of_day)
|
|
134
|
+
year, month, day = text.scan(/\d+/).map(&:to_i)
|
|
135
|
+
return nil unless year.positive? && Date.valid_date?(year, month, day)
|
|
136
|
+
|
|
137
|
+
moment = Time.utc(year, month, day)
|
|
138
|
+
end_of_day ? moment.end_of_day : moment
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def parse_timestamp(text)
|
|
142
|
+
match = TIMESTAMP.match(text)
|
|
143
|
+
offset = match && utc_offset(match[8])
|
|
144
|
+
return nil unless offset
|
|
145
|
+
|
|
146
|
+
year, month, day, hour, minute = match.captures.first(5).map(&:to_i)
|
|
147
|
+
second = Rational("#{match[6] || 0}#{match[7]}")
|
|
148
|
+
return nil unless real_moment?([year, month, day], hour, minute, second)
|
|
149
|
+
|
|
150
|
+
Time.new(year, month, day, hour, minute, second, offset)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def real_moment?(date, hour, minute, second)
|
|
154
|
+
date.first.positive? && Date.valid_date?(*date) && hour < 24 && minute < 60 && second < 60
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# "UTC" for Z or no offset; "+HH:MM" otherwise; nil when out of range.
|
|
158
|
+
def utc_offset(text)
|
|
159
|
+
return 'UTC' if text.nil? || text == 'Z'
|
|
160
|
+
|
|
161
|
+
sign, hours, minutes = text.match(/([+-])(\d{2}):?(\d{2})?/).captures
|
|
162
|
+
return nil unless hours.to_i < 24 && minutes.to_i < 60
|
|
163
|
+
|
|
164
|
+
format('%<sign>s%<hours>s:%<minutes>s', sign: sign, hours: hours, minutes: minutes || '00')
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# A numeric value is the id, anything else the uuid: one indexed column each.
|
|
168
|
+
def filter_permission_set
|
|
169
|
+
found = value(:permission_set)
|
|
170
|
+
return unless found
|
|
171
|
+
|
|
172
|
+
@scope = if found.match?(/\A\d{1,18}\z/)
|
|
173
|
+
@scope.where(permission_set_id: found.to_i)
|
|
174
|
+
else
|
|
175
|
+
@scope.where(permission_set_uuid: found)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# record_type alone, or with record_id or record_uuid (each one column, so
|
|
180
|
+
# (record_type, record_id, occurred_at) serves the first). Either id needs
|
|
181
|
+
# record_type.
|
|
182
|
+
def filter_record
|
|
183
|
+
ids = { record_id: value(:record_id), record_uuid: value(:record_uuid) }.compact
|
|
184
|
+
return if ids.empty?
|
|
185
|
+
|
|
186
|
+
return problem(:record_type, :blank) if @params[:record_type].blank?
|
|
187
|
+
|
|
188
|
+
@scope = @scope.where(ids)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Audit
|
|
5
|
+
# What happens when an audit write, a notification subscriber or a hook
|
|
6
|
+
# fails, per the configured strategy:
|
|
7
|
+
# :raise — the error propagates (a change event then rolls the change back)
|
|
8
|
+
# :report — logged at error level and handed to Rails.error as handled;
|
|
9
|
+
# execution continues
|
|
10
|
+
# :log — logged only
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
module Failure
|
|
14
|
+
SOURCE = 'permission_forge.audit'
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# @param error [Exception]
|
|
18
|
+
# @param strategy [Symbol] :raise, :report or :log
|
|
19
|
+
# @param operation [Symbol] :store or :publish
|
|
20
|
+
# @param details [Hash] event: and anything else worth knowing
|
|
21
|
+
# @return [nil]
|
|
22
|
+
def handle(error, strategy:, operation:, **details)
|
|
23
|
+
raise error if strategy == :raise
|
|
24
|
+
|
|
25
|
+
log(error, operation, details)
|
|
26
|
+
report(error, operation, details) if strategy == :report
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def log(error, operation, details)
|
|
33
|
+
return unless defined?(Rails) && Rails.logger
|
|
34
|
+
|
|
35
|
+
what = details.map { |key, value| "#{key}=#{value}" }.join(' ')
|
|
36
|
+
Rails.logger.error("[PermissionForge] audit #{operation} failed (#{what}) — #{error.class}: #{error.message}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def report(error, operation, details)
|
|
40
|
+
return unless defined?(Rails) && Rails.respond_to?(:error)
|
|
41
|
+
|
|
42
|
+
Rails.error.report(error, handled: true, severity: :error, source: SOURCE, context: { operation: operation, **details })
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Audit
|
|
5
|
+
# field.access — one event per record read, listing the fields that were
|
|
6
|
+
# hidden, masked, unconfigured or bypassed (no user), and the fields read
|
|
7
|
+
# in the clear (`passed_fields`, with their count in `passed`).
|
|
8
|
+
#
|
|
9
|
+
# A read is the outermost guarded access to one record: a getter, `[]` /
|
|
10
|
+
# `read_attribute`, `attributes`, `serializable_hash` (`as_json`,
|
|
11
|
+
# `to_json`), a virtual or store reader. While it runs, every decision
|
|
12
|
+
# AttributeAccessGuard makes about that record goes into one Frame, kept
|
|
13
|
+
# in a fiber-local ExecutionState slot (never on the record), and the
|
|
14
|
+
# event is published when the read returns. A read nested inside another record's
|
|
15
|
+
# read (an association serialized with `include:`) gets its own frame and
|
|
16
|
+
# event. Three getters called one after the other are three reads, so
|
|
17
|
+
# three events: the gem cannot know when a view or serializer is done
|
|
18
|
+
# with a record. FiltersResponseAttributes publishes one per hash it
|
|
19
|
+
# filters.
|
|
20
|
+
#
|
|
21
|
+
# Nothing is collected unless the event is stored (config.audit_runtime_events
|
|
22
|
+
# includes :field_read) or someone listens: the read then costs one
|
|
23
|
+
# Audit::Gate check. When the legacy on_field_filtered hook is the only
|
|
24
|
+
# listener and nothing is stored, no event is built either: each decision
|
|
25
|
+
# goes straight to the hook (Direct), as before the audit event log. The frame records every decision, passed ones too —
|
|
26
|
+
# the legacy on_field_filtered hook is called for each of them
|
|
27
|
+
# (payload[:objects][:decisions]); the stored row keeps the field names
|
|
28
|
+
# of each kind of decision (never a value). The names are gathered only
|
|
29
|
+
# when the event is built.
|
|
30
|
+
#
|
|
31
|
+
# @api private
|
|
32
|
+
module FieldAccess
|
|
33
|
+
EVENT = 'field.access'
|
|
34
|
+
# The ExecutionState slot of the read in progress (its Frame).
|
|
35
|
+
KEY = :pf_audit_field_access
|
|
36
|
+
|
|
37
|
+
# One AttributeAccessGuard decision. `hook` is false for a decision the
|
|
38
|
+
# legacy on_field_filtered hook was never called for (an unconfigured
|
|
39
|
+
# field in FiltersResponseAttributes, a response filtered without user).
|
|
40
|
+
Decision = Struct.new(:resource_name, :field_name, :action, :user, :hook)
|
|
41
|
+
|
|
42
|
+
LISTED = %i[hidden masked unconfigured bypassed].freeze
|
|
43
|
+
|
|
44
|
+
ENTRY = EventCatalog.fetch(EVENT)
|
|
45
|
+
# Asked on every guarded read.
|
|
46
|
+
GATE = Gate.for(EVENT)
|
|
47
|
+
|
|
48
|
+
# The decisions of one read.
|
|
49
|
+
class Frame
|
|
50
|
+
attr_reader :owner
|
|
51
|
+
|
|
52
|
+
# @param owner [Object] what is read: a record, or the Hash a
|
|
53
|
+
# response filter works on
|
|
54
|
+
# @param resource_name [String]
|
|
55
|
+
# @param record [ActiveRecord::Base, nil] the record, when there is one
|
|
56
|
+
# @param record_id [Object, nil] the id, when there is no record
|
|
57
|
+
def initialize(owner, resource_name, record = nil, record_id = nil)
|
|
58
|
+
@owner = owner
|
|
59
|
+
@resource_name = resource_name.to_s
|
|
60
|
+
@record = record
|
|
61
|
+
@record_id = record_id
|
|
62
|
+
@decisions = []
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [void]
|
|
66
|
+
def add(resource_name, field_name, action, user, hook: true)
|
|
67
|
+
return if Audit.emitting?
|
|
68
|
+
|
|
69
|
+
@decisions << Decision.new(resource_name.is_a?(Symbol) ? resource_name.name : resource_name.to_s, field_name, action, user, hook)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Publishes the read's event (nothing when no field was decided).
|
|
73
|
+
def emit
|
|
74
|
+
return if @decisions.empty?
|
|
75
|
+
|
|
76
|
+
decisions = @decisions.freeze
|
|
77
|
+
# the legacy hook alone needs only the decisions (Gate#direct_listeners)
|
|
78
|
+
light = GATE.direct_listeners
|
|
79
|
+
return Audit.emit_light(ENTRY, light, { event: EVENT, data: nil, objects: objects(decisions) }) if light
|
|
80
|
+
|
|
81
|
+
Audit.instrument(EVENT) { payload(decisions) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def payload(decisions)
|
|
87
|
+
user = decisions.first.user
|
|
88
|
+
reference = @record ? { record: @record } : { record_type: @resource_name, record_id: @record_id }
|
|
89
|
+
reference.merge(subject_user_id: Runtime.user_id(user), data: data(decisions), objects: objects(decisions))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def objects(decisions)
|
|
93
|
+
{ user: decisions.first.user, record: @record, decisions: decisions }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def data(decisions)
|
|
97
|
+
lists = LISTED.to_h { |action| [action.to_s, []] }
|
|
98
|
+
passed = []
|
|
99
|
+
decisions.each { |decision| add_name(decision.action == :passed ? passed : lists[decision.action.to_s], decision) }
|
|
100
|
+
{ 'model' => @resource_name }.merge(lists).merge('passed' => passed.size, 'passed_fields' => passed)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def add_name(names, decision)
|
|
104
|
+
name = decision.field_name.to_s
|
|
105
|
+
names << name if names && !names.include?(name)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Where decisions go when the legacy on_field_filtered hook is the only
|
|
110
|
+
# listener and nothing is stored (Gate#direct_listeners): straight to
|
|
111
|
+
# the hook, one call per decision as before the audit event log — no
|
|
112
|
+
# frame, no event. Re-entrancy guarded like any emission.
|
|
113
|
+
module Direct
|
|
114
|
+
def self.add(resource_name, field_name, action, user, hook: true)
|
|
115
|
+
return unless hook
|
|
116
|
+
|
|
117
|
+
listeners = GATE.direct_listeners
|
|
118
|
+
Audit.emit_decision(listeners, resource_name, field_name, action, user) if listeners
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
class << self
|
|
123
|
+
# Runs the block as one read of `owner`, and publishes its event.
|
|
124
|
+
# Yields the frame to record decisions in, or nil when nobody listens.
|
|
125
|
+
# A read inside a read of the same owner joins it.
|
|
126
|
+
#
|
|
127
|
+
# @param owner [Object] the record (or Hash) being read
|
|
128
|
+
# @param resource_name [String]
|
|
129
|
+
# @param record [Object, nil] the record, for the event's reference
|
|
130
|
+
# @param record_id [Object, nil] the id when there is no record
|
|
131
|
+
# @return the block's value
|
|
132
|
+
def read(owner, resource_name, record: nil, record_id: nil)
|
|
133
|
+
return yield(nil) unless GATE.open?
|
|
134
|
+
|
|
135
|
+
outer = ExecutionState[KEY]
|
|
136
|
+
return yield(outer) if outer&.owner.equal?(owner)
|
|
137
|
+
return yield(Audit.emitting? ? nil : Direct) if GATE.direct_listeners
|
|
138
|
+
return yield(nil) unless Audit.instrument?(EVENT)
|
|
139
|
+
|
|
140
|
+
frame = Frame.new(owner, resource_name, record, record_id)
|
|
141
|
+
run(frame) { yield(frame) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Fields a serialization leaves out because they are hidden
|
|
145
|
+
# (attribute_names_for_serialization): decided without the guard, so
|
|
146
|
+
# the legacy hook was never called for them. They join the read of
|
|
147
|
+
# `owner` in progress, if any.
|
|
148
|
+
#
|
|
149
|
+
# @return [void]
|
|
150
|
+
def omitted(owner, resource_name, names, user)
|
|
151
|
+
frame = ExecutionState[KEY]
|
|
152
|
+
return unless frame&.owner.equal?(owner)
|
|
153
|
+
|
|
154
|
+
names.each { |name| frame.add(resource_name, name, :hidden, user, hook: false) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# A decision made outside any read (AttributeAccessGuard called by
|
|
158
|
+
# the host app): it joins the read in progress, or is a read of its own.
|
|
159
|
+
#
|
|
160
|
+
# @return [void]
|
|
161
|
+
def note(resource_name, field_name, action, user)
|
|
162
|
+
return unless GATE.open?
|
|
163
|
+
|
|
164
|
+
frame = ExecutionState[KEY]
|
|
165
|
+
return frame.add(resource_name, field_name, action, user) if frame
|
|
166
|
+
return Direct.add(resource_name, field_name, action, user) if GATE.direct_listeners && !Audit.emitting?
|
|
167
|
+
return unless Audit.instrument?(EVENT)
|
|
168
|
+
|
|
169
|
+
single = Frame.new(nil, resource_name)
|
|
170
|
+
single.add(resource_name, field_name, action, user)
|
|
171
|
+
single.emit
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
# The frame is the read in progress while the block runs; the outer
|
|
177
|
+
# read (or none) is put back before the event is published.
|
|
178
|
+
def run(frame, &)
|
|
179
|
+
result = ExecutionState.with(KEY, frame, &)
|
|
180
|
+
frame.emit
|
|
181
|
+
result
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|