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,230 @@
|
|
|
1
|
+
module PermissionForge
|
|
2
|
+
# Labels and links for audit rows (Person → Audit, Permission Set → Audit,
|
|
3
|
+
# CSV) and for audit events (the audit log, the timelines, an event).
|
|
4
|
+
#
|
|
5
|
+
# @api private
|
|
6
|
+
module AuditHelper
|
|
7
|
+
# Every category has a glyph and a word (never colour alone).
|
|
8
|
+
CATEGORY_GLYPHS = { 'change' => '✎', 'admin' => '⚑', 'system' => '⚙', 'access' => '◉' }.freeze
|
|
9
|
+
CATEGORY_VARIANTS = { 'change' => :changed, 'admin' => :warn, 'system' => :default, 'access' => :mask }.freeze
|
|
10
|
+
# The message Audit::EventQuery gives a filter that needs a value (Rails' own).
|
|
11
|
+
QUERY_BLANK_MESSAGE = 'errors.messages.blank'.freeze
|
|
12
|
+
# [record label, where] per audited type; evaluated in the view context.
|
|
13
|
+
AUDIT_LABELS = {
|
|
14
|
+
'permission_set' => ->(record) { [record.name, nil] },
|
|
15
|
+
'group' => ->(record) { [record.name, nil] },
|
|
16
|
+
'resource' => ->(record) { [pf_resource_label(record), record.permission_set&.name] },
|
|
17
|
+
'field' => ->(record) { [record.label.presence || record.name, pf_audit_resource_where(record.resource)] },
|
|
18
|
+
'action' => ->(record) { [record.label.presence || record.name, pf_audit_resource_where(record.resource)] },
|
|
19
|
+
'rule' => ->(record) { [record.name, pf_audit_resource_where(record.resource)] },
|
|
20
|
+
'assignment' => ->(record) { [pf_person_name(record.user), record.permission_set&.name] },
|
|
21
|
+
'group_membership' => ->(record) { [pf_person_name(record.user), record.permission_set_group&.name] }
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# Link target per audited type; evaluated in the view context.
|
|
25
|
+
AUDIT_PATHS = {
|
|
26
|
+
'permission_set' => ->(record) { permission_forge.permission_set_path(record) },
|
|
27
|
+
'resource' => ->(record) { permission_forge.permission_set_resource_path(record.permission_set_id, record.name) },
|
|
28
|
+
'field' => ->(record) { pf_audit_nested_path(record.resource, :field, record.name) },
|
|
29
|
+
'action' => ->(record) { pf_audit_nested_path(record.resource, :action, record.id) },
|
|
30
|
+
'rule' => ->(record) { pf_audit_nested_path(record.resource, :rule, record.id) },
|
|
31
|
+
'assignment' => ->(record) { permission_forge.person_path(record.user_id) },
|
|
32
|
+
'group' => ->(record) { permission_forge.group_path(record) },
|
|
33
|
+
'group_membership' => ->(record) { permission_forge.group_path(record.permission_set_group_id) }
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
def pf_audit_type_label(type)
|
|
37
|
+
t("permission_forge.ui.audit.types.#{type}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# [type label, record label, where] — also used for CSV export.
|
|
41
|
+
def pf_audit_describe(entry)
|
|
42
|
+
record = entry.record
|
|
43
|
+
label, where = pf_audit_label_and_where(entry.type, record)
|
|
44
|
+
[pf_audit_type_label(entry.type), label, where]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def pf_audit_label_and_where(type, record)
|
|
48
|
+
pf_audit_lookup(AUDIT_LABELS, type, record)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def pf_audit_resource_where(resource)
|
|
52
|
+
return nil unless resource
|
|
53
|
+
|
|
54
|
+
"#{resource.permission_set&.name} › #{pf_resource_label(resource)}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def pf_audit_path(entry)
|
|
58
|
+
pf_audit_lookup(AUDIT_PATHS, entry.type, entry.record)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Runs the registry entry for `type` in the view context; nil for unknown types.
|
|
62
|
+
def pf_audit_lookup(registry, type, record)
|
|
63
|
+
handler = registry[type]
|
|
64
|
+
instance_exec(record, &handler) if handler
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def pf_audit_nested_path(resource, kind, key)
|
|
68
|
+
return nil unless resource
|
|
69
|
+
|
|
70
|
+
case kind
|
|
71
|
+
when :field then permission_forge.permission_set_resource_field_path(resource.permission_set_id, resource.name, key)
|
|
72
|
+
when :action then permission_forge.permission_set_resource_action_path(resource.permission_set_id, resource.name, key)
|
|
73
|
+
when :rule then permission_forge.edit_permission_set_resource_rule_path(resource.permission_set_id, resource.name, key)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def pf_audit_who_when(person, time, relative: false)
|
|
78
|
+
t('permission_forge.ui.audit.who_when', who: pf_person_name(person),
|
|
79
|
+
when: relative ? pf_relative(time) : pf_date(time))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# "Who · when" of an entry's last edit: the latest change event's actor
|
|
83
|
+
# and time when there is one, else updated_by / updated_at.
|
|
84
|
+
def pf_audit_edited(entry)
|
|
85
|
+
return pf_audit_who_when(entry.updated_by, entry.updated_at, relative: true) unless entry.last_event
|
|
86
|
+
|
|
87
|
+
t('permission_forge.ui.audit.who_when', who: pf_audit_actor_name(entry.last_event), when: pf_relative(entry.updated_at))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The record's last edit as an event (who really made it), or nil when
|
|
91
|
+
# the audit log has none for it or the record was edited after it.
|
|
92
|
+
# Uses the page's preloaded lookup when it covers the record.
|
|
93
|
+
def pf_audit_last_change(record)
|
|
94
|
+
lookup = @pf_last_changes if @pf_last_changes&.covers?(record)
|
|
95
|
+
(lookup || PermissionForge::Admin::LastChanges.new([record])).last_edit(record)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# One lookup for every record whose footer the page shows (two queries
|
|
99
|
+
# per record type instead of two per footer). Nothing while the audit
|
|
100
|
+
# trail is off: the footers then say so and ask nothing.
|
|
101
|
+
def pf_audit_preload_last_changes(records)
|
|
102
|
+
return unless PermissionForge.configuration.audit_trail_enabled
|
|
103
|
+
|
|
104
|
+
@pf_last_changes = PermissionForge::Admin::LastChanges.new(records)
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# [created by, last edited by] names for the audit-trail CSV.
|
|
109
|
+
def pf_audit_people(entry)
|
|
110
|
+
edited_by = entry.last_event ? pf_audit_actor_name(entry.last_event) : pf_person_name(entry.updated_by)
|
|
111
|
+
[pf_person_name(entry.created_by), edited_by]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# --- audit events ---------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
# Who acted, as recorded: the name snapshot, with what kind of actor it
|
|
117
|
+
# was when that is not a person ("ops (password prompt)", "API client").
|
|
118
|
+
def pf_audit_actor_name(event)
|
|
119
|
+
type = event.actor_type.to_s
|
|
120
|
+
label = event.actor_label.presence
|
|
121
|
+
return t("permission_forge.audit_log.actor_named.#{type}", name: label, default: label) if label
|
|
122
|
+
return t('permission_forge.audit_log.person_id', id: event.actor_id) if type == 'user' && event.actor_id.present?
|
|
123
|
+
|
|
124
|
+
pf_audit_actor_type_label(type)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def pf_audit_actor_type_label(type)
|
|
128
|
+
t("permission_forge.audit_log.actor_types.#{type}", default: type.to_s.humanize)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def pf_audit_category_label(category)
|
|
132
|
+
t("permission_forge.audit_log.categories.#{category}", default: category.to_s.humanize)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def pf_audit_source_label(source)
|
|
136
|
+
t("permission_forge.audit_log.sources.#{source}", default: source.to_s.humanize)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def pf_audit_category_badge(category)
|
|
140
|
+
pf_badge("#{CATEGORY_GLYPHS.fetch(category.to_s, '•')} #{pf_audit_category_label(category)}",
|
|
141
|
+
variant: CATEGORY_VARIANTS.fetch(category.to_s, :default))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def pf_audit_event_path(event, **)
|
|
145
|
+
permission_forge.audit_event_path(event.uuid, **)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The audit log, filtered (only the filters given).
|
|
149
|
+
def pf_audit_log_path(**filters)
|
|
150
|
+
permission_forge.audit_events_path(filters.compact_blank)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The person an event is about: the label an assignment's or a
|
|
154
|
+
# membership's event kept (their name then), else the user when they
|
|
155
|
+
# still exist, else their id.
|
|
156
|
+
def pf_audit_person_name(event, lookups)
|
|
157
|
+
id = event.subject_user_id
|
|
158
|
+
return nil if id.blank?
|
|
159
|
+
|
|
160
|
+
return event.record_label if pf_audit_person_record?(event) && event.record_label.present?
|
|
161
|
+
|
|
162
|
+
user = lookups.person(id)
|
|
163
|
+
return pf_person_name(user) if user
|
|
164
|
+
|
|
165
|
+
t('permission_forge.audit_log.person_id', id: id)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def pf_audit_person_record?(event)
|
|
169
|
+
%w[PermissionForge::PermissionSetUser PermissionForge::PermissionSetGroupUser].include?(event.record_type)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# The Permission Set's name when the event happened (AuditLookups), or
|
|
173
|
+
# what the event says about it.
|
|
174
|
+
def pf_audit_set_name(event, lookups)
|
|
175
|
+
return nil if event.permission_set_id.blank? && event.permission_set_uuid.blank?
|
|
176
|
+
return event.record_label if event.record_type == 'PermissionForge::PermissionSet' && event.record_label.present?
|
|
177
|
+
|
|
178
|
+
name = lookups.permission_set_name(event)
|
|
179
|
+
return name if name
|
|
180
|
+
|
|
181
|
+
t('permission_forge.audit_log.deleted_set', id: event.permission_set_id || event.permission_set_uuid)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def pf_audit_group_name(event, lookups)
|
|
185
|
+
name = lookups.group_name(event)
|
|
186
|
+
return name if name
|
|
187
|
+
|
|
188
|
+
id = PermissionForge::Admin::AuditLookups.group_id(event)
|
|
189
|
+
id ? t('permission_forge.audit_log.deleted_group', id: id) : t('permission_forge.audit_log.unknown_group')
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# What the event is about: its label snapshot, else type and id.
|
|
193
|
+
def pf_audit_record_name(event)
|
|
194
|
+
return event.record_label if event.record_label.present?
|
|
195
|
+
return '' if event.record_type.blank?
|
|
196
|
+
|
|
197
|
+
[event.record_type.to_s.demodulize.titleize, event.record_id || event.record_uuid].compact.join(' #')
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# A value of a before/after or snapshot table: text as it is, nothing as
|
|
201
|
+
# a dash-word, anything else as JSON.
|
|
202
|
+
def pf_audit_value(value)
|
|
203
|
+
case value
|
|
204
|
+
when nil then t('permission_forge.audit_log.no_value')
|
|
205
|
+
when String then value.empty? ? t('permission_forge.audit_log.empty_text') : value
|
|
206
|
+
when true, false then t("permission_forge.audit_log.boolean.#{value}")
|
|
207
|
+
when Numeric then value.to_s
|
|
208
|
+
else ActiveSupport::JSON.encode(value)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# One line per refused filter, in the admin's own words (EventQuery's
|
|
213
|
+
# messages are Rails' errors.messages, which a host may not translate):
|
|
214
|
+
# a value that is needed, or the value that is not valid.
|
|
215
|
+
def pf_audit_filter_error(name, messages, value)
|
|
216
|
+
field = t("permission_forge.audit_events.index.filters.#{name}", default: name.to_s.humanize)
|
|
217
|
+
scope = 'permission_forge.audit_events.index.invalid'
|
|
218
|
+
return t("#{scope}.blank", field: field) if Array(messages).include?(I18n.t(QUERY_BLANK_MESSAGE))
|
|
219
|
+
return t("#{scope}.invalid", field: field) if value.blank?
|
|
220
|
+
|
|
221
|
+
t("#{scope}.invalid_value", field: field, value: value.truncate(80))
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The first scalar of a filter parameter (a list or a hash shows nothing).
|
|
225
|
+
def pf_audit_filter_value(filters, key)
|
|
226
|
+
value = filters[key]
|
|
227
|
+
value.is_a?(String) ? value : nil
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# One plain-language sentence per audit event ("Ana gave Bo the Permission
|
|
5
|
+
# Set Sales.").
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module AuditSentencesHelper
|
|
9
|
+
AUDIT_SENTENCES = 'permission_forge.audit_log.sentences'
|
|
10
|
+
|
|
11
|
+
# Events whose sentence depends on what they recorded: event name →
|
|
12
|
+
# a block (run in the view) that returns the sentence, or nil for the
|
|
13
|
+
# event's own one. Adding a variant is one entry here plus its key.
|
|
14
|
+
AUDIT_SENTENCE_VARIANTS = {
|
|
15
|
+
# Names the fields that were hidden or masked; a read with none of
|
|
16
|
+
# them is the plain "read X." sentence.
|
|
17
|
+
'field.access' => lambda { |event, parts|
|
|
18
|
+
fields = pf_audit_limited_fields(event)
|
|
19
|
+
next if fields.empty?
|
|
20
|
+
|
|
21
|
+
t('permission_forge.audit_log.sentence_variants.field_access_limited', **parts, fields: fields.join(', '))
|
|
22
|
+
},
|
|
23
|
+
# A revocation of an assignment that had already ended (the clean-up).
|
|
24
|
+
'assignment.revoked' => lambda { |event, parts|
|
|
25
|
+
ended = pf_audit_expired_at(event)
|
|
26
|
+
next unless ended
|
|
27
|
+
|
|
28
|
+
t('permission_forge.audit_log.sentence_variants.assignment_revoked_expired', **parts, date: pf_date(ended))
|
|
29
|
+
}
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# One sentence per event, from the locale key named after the event
|
|
33
|
+
# (permission_forge.audit_log.sentences.<event>): a new event needs one
|
|
34
|
+
# key in each locale, and an event without one (a host's own) still reads
|
|
35
|
+
# as "<actor>: <event name>". Interpolations: actor, record, set, person,
|
|
36
|
+
# group, event, model (a runtime event's model).
|
|
37
|
+
def pf_audit_sentence(event, lookups)
|
|
38
|
+
parts = pf_audit_sentence_parts(event, lookups)
|
|
39
|
+
variant = AUDIT_SENTENCE_VARIANTS[event.event]
|
|
40
|
+
variant &&= instance_exec(event, parts, &variant)
|
|
41
|
+
return variant if variant
|
|
42
|
+
|
|
43
|
+
fallback = t("#{AUDIT_SENTENCES}.fallback", **parts)
|
|
44
|
+
sentence = t("#{AUDIT_SENTENCES}.#{event.event}", **parts, default: fallback)
|
|
45
|
+
sentence.is_a?(String) ? sentence : fallback
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The hidden and masked field names a field.access event lists.
|
|
49
|
+
def pf_audit_limited_fields(event)
|
|
50
|
+
data = event.data || {}
|
|
51
|
+
%w[hidden masked].flat_map { |key| Array(data[key]) }.map(&:to_s).uniq
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# When the revoked assignment had ended, if it had ended before the
|
|
55
|
+
# revocation (its snapshot's expires_at).
|
|
56
|
+
def pf_audit_expired_at(event)
|
|
57
|
+
snapshot = event.data.is_a?(Hash) ? event.data['snapshot'] : nil
|
|
58
|
+
ended = pf_audit_time(snapshot['expires_at']) if snapshot.is_a?(Hash)
|
|
59
|
+
ended if ended && event.occurred_at && ended <= event.occurred_at
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def pf_audit_time(value)
|
|
63
|
+
Time.zone.parse(value.to_s) if value.present?
|
|
64
|
+
rescue ArgumentError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def pf_audit_sentence_parts(event, lookups)
|
|
69
|
+
unknown = t('permission_forge.audit_log.unknown')
|
|
70
|
+
{
|
|
71
|
+
actor: pf_audit_actor_name(event), record: pf_audit_record_name(event).presence || unknown,
|
|
72
|
+
set: pf_audit_set_name(event, lookups) || unknown, person: pf_audit_person_name(event, lookups) || unknown,
|
|
73
|
+
group: pf_audit_person_record?(event) ? pf_audit_group_name(event, lookups) : unknown, event: event.event,
|
|
74
|
+
model: pf_audit_model_name(event).presence || unknown
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The model a runtime event is about: data['model'], else the record type.
|
|
79
|
+
def pf_audit_model_name(event)
|
|
80
|
+
model = event.data.is_a?(Hash) ? event.data['model'] : nil
|
|
81
|
+
(model.is_a?(String) ? model : event.record_type).to_s.demodulize
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
module PermissionForge
|
|
2
|
+
# Badges, Permission Set chips, field level badges and mask previews.
|
|
3
|
+
# Every class is pf- prefixed; no inline styles (strict-CSP friendly).
|
|
4
|
+
#
|
|
5
|
+
# @api private
|
|
6
|
+
module BadgesHelper
|
|
7
|
+
LEVEL_GLYPHS = { 'hidden' => '∅', 'masked' => '••', 'read' => '◉', 'edit' => '✎', 'leave' => '–',
|
|
8
|
+
'mask' => '••', 'hide' => '⊘', 'muted' => '⊘', 'none' => '–' }.freeze
|
|
9
|
+
|
|
10
|
+
MASK_SAMPLES = [
|
|
11
|
+
[/email/, 'james.walker@acme.com'],
|
|
12
|
+
[/iban|bank|account|card/, 'DE89 3704 0044 0532 0130 00'],
|
|
13
|
+
[/phone|mobile|fax|tel/, '+383 44 123 456'],
|
|
14
|
+
[/cents|salary|amount|price|value/, '4 200,00 €'],
|
|
15
|
+
[/national|ssn|social/, '1234567890'],
|
|
16
|
+
[/birth|dob/, '1988-04-17'],
|
|
17
|
+
[/vat|tax/, 'XK810123456'],
|
|
18
|
+
[/address|street|city|zip|postal/, 'Rruga Agim Ramadani 15']
|
|
19
|
+
].freeze
|
|
20
|
+
DEFAULT_MASK_SAMPLE = 'James Walker'.freeze
|
|
21
|
+
|
|
22
|
+
CHIP_PREFIXES = { muting: '⊘ ', inactive: '○ ' }.freeze
|
|
23
|
+
CHIP_SUFFIX_KEYS = { inactive: 'badges.off', expired: 'badges.expired' }.freeze
|
|
24
|
+
|
|
25
|
+
# --- badges ---------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
# Options: title:, href:, square: (bool), extra: (additional classes).
|
|
28
|
+
def pf_badge(text, variant: :default, **opts)
|
|
29
|
+
classes = ['pf-badge', ("pf-badge--#{variant}" unless variant.to_s == 'default'),
|
|
30
|
+
('pf-badge--square' if opts[:square]), opts[:extra]].compact.join(' ')
|
|
31
|
+
content = safe_join(Array(text))
|
|
32
|
+
href = opts[:href]
|
|
33
|
+
return link_to(content, href, class: classes, title: opts[:title]) if href
|
|
34
|
+
|
|
35
|
+
content_tag(:span, content, class: classes, title: opts[:title])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def pf_lock_badge(title: pf_ui('locked.title'))
|
|
39
|
+
pf_badge([pf_lock_icon, pf_ui('badges.locked')], title: title)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def pf_off_badge
|
|
43
|
+
content_tag(:span, "○ #{pf_ui('badges.off')}", class: 'pf-badge pf-badge--off')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def pf_fail_open?
|
|
47
|
+
!PermissionForge.configuration.conditional_sets_strict_mode
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def pf_fail_open_badge(long: false)
|
|
51
|
+
text = long ? pf_ui('badges.fail_open_long') : pf_ui('badges.fail_open')
|
|
52
|
+
pf_badge("⚠ #{text}", variant: :failopen, href: pf_setting_path('conditional_sets_strict_mode'),
|
|
53
|
+
title: pf_ui('badges.fail_open_title'))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def pf_set_badges(permission_set)
|
|
57
|
+
badges = []
|
|
58
|
+
badges << pf_badge("⊘ #{pf_ui('badges.muting')}", variant: :muting) if permission_set.is_muting
|
|
59
|
+
if permission_set.conditional?
|
|
60
|
+
badges << pf_badge("◷ #{pf_ui('badges.conditional')}", variant: :conditional,
|
|
61
|
+
title: pf_condition_sentence(permission_set))
|
|
62
|
+
badges << pf_fail_open_badge if pf_fail_open?
|
|
63
|
+
end
|
|
64
|
+
badges << pf_lock_badge if pf_locked_record?(permission_set)
|
|
65
|
+
safe_join(badges, ' ')
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def pf_locked_record?(record)
|
|
69
|
+
record.respond_to?(:is_editable) && record.is_editable == false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Permission Set chip. state: :normal / :inactive / :expired (auto from set).
|
|
73
|
+
def pf_set_chip(permission_set, large: false, href: :auto, expired: false)
|
|
74
|
+
state = pf_set_chip_state(permission_set, expired)
|
|
75
|
+
classes = ['pf-chip', ('pf-chip--lg' if large), ("pf-chip--#{state}" unless state == :normal)].compact.join(' ')
|
|
76
|
+
body = pf_set_chip_body(permission_set, state)
|
|
77
|
+
href = permission_forge.permission_set_path(permission_set) if href == :auto
|
|
78
|
+
href ? link_to(body, href, class: classes) : content_tag(:span, body, class: classes)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def pf_set_chip_state(permission_set, expired)
|
|
82
|
+
return :expired if expired
|
|
83
|
+
return :inactive unless permission_set.is_active
|
|
84
|
+
|
|
85
|
+
permission_set.is_muting ? :muting : :normal
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def pf_set_chip_body(permission_set, state)
|
|
89
|
+
suffix_key = CHIP_SUFFIX_KEYS[state]
|
|
90
|
+
safe_join([content_tag(:span, "#{CHIP_PREFIXES[state]}#{permission_set.name}"),
|
|
91
|
+
(content_tag(:span, pf_ui(suffix_key), class: 'pf-chip__suffix') if suffix_key)].compact, ' ')
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Effective/configured field level badge ("•• Masked · j***@acme.com").
|
|
95
|
+
def pf_level_badge(level, strategy: nil, sample_for: nil, label: nil)
|
|
96
|
+
level = level.to_s
|
|
97
|
+
text = label || pf_level_label(level)
|
|
98
|
+
if %w[masked mask].include?(level) && strategy.present?
|
|
99
|
+
text = "#{text} (#{pf_mask_strategy_label(strategy)})"
|
|
100
|
+
text = "#{text} · #{pf_mask_preview(sample_for, strategy)}" if sample_for
|
|
101
|
+
end
|
|
102
|
+
content_tag(:span, "#{LEVEL_GLYPHS.fetch(level, '–')} #{text}", class: "pf-level-badge pf-level-badge--#{level}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def pf_level_label(level)
|
|
106
|
+
pf_ui("levels.#{level}")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def pf_mask_strategy_label(strategy)
|
|
110
|
+
t("permission_forge.ui.mask_strategies.#{strategy}", default: strategy.to_s)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def pf_mask_strategies
|
|
114
|
+
PermissionForge::Services::MaskValue.strategies.keys.map(&:to_s)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def pf_custom_mask_strategy?(strategy)
|
|
118
|
+
!PermissionForge::Services::MaskValue::BUILT_IN_STRATEGIES.key?(strategy.to_s)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def pf_mask_sample(column)
|
|
122
|
+
name = column.to_s
|
|
123
|
+
MASK_SAMPLES.find { |pattern, _| name.match?(pattern) }&.last || DEFAULT_MASK_SAMPLE
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Runs the real MaskValue strategy on a sample value, so previews always
|
|
127
|
+
# match what people will see.
|
|
128
|
+
def pf_mask_preview(column, strategy)
|
|
129
|
+
PermissionForge::Services::MaskValue.call(pf_mask_sample(column), strategy: strategy.to_s)
|
|
130
|
+
rescue StandardError
|
|
131
|
+
'••••••••'
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
module PermissionForge
|
|
2
|
+
# One plain-language line per ChangeSet change (the "Review and save" list).
|
|
3
|
+
#
|
|
4
|
+
# @api private
|
|
5
|
+
module ChangesHelper
|
|
6
|
+
# ChangeSet::Change#kind => the helper that words it. A new kind is one
|
|
7
|
+
# entry here plus its helper.
|
|
8
|
+
CHANGE_DESCRIBERS = {
|
|
9
|
+
set_attr: :pf_set_attr_change, resource_flag: :pf_resource_flag_change, resource_text: :pf_resource_text_change,
|
|
10
|
+
field_level: :pf_field_level_change, new_field: :pf_field_level_change, field_attr: :pf_field_attr_change,
|
|
11
|
+
action: :pf_action_change, new_action: :pf_new_action_change
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def pf_change_description(change)
|
|
15
|
+
describer = CHANGE_DESCRIBERS[change.kind]
|
|
16
|
+
send(describer, change) if describer
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def pf_resource_text_change(change)
|
|
20
|
+
pf_ui('changes.resource_text', resource: pf_resource_label(change.record),
|
|
21
|
+
attribute: pf_ui("changes.attributes.#{change.attribute}"))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def pf_action_change(change)
|
|
25
|
+
muting = change.resource&.permission_set&.is_muting
|
|
26
|
+
key = if muting then change.to ? 'changes.action_muted' : 'changes.action_unmuted'
|
|
27
|
+
else change.to ? 'changes.action_on' : 'changes.action_off'
|
|
28
|
+
end
|
|
29
|
+
pf_ui(key, resource: pf_resource_label(change.resource), action: change.record.display_name)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A discovered controller action added to the set, followed by what that
|
|
33
|
+
# means for everyone: "Customer: Export added, permitted (this makes it
|
|
34
|
+
# managed: ...)".
|
|
35
|
+
def pf_new_action_change(change)
|
|
36
|
+
resource = change.resource
|
|
37
|
+
action = change.record
|
|
38
|
+
key = if resource.permission_set.is_muting then change.to ? 'changes.action_new_muted' : 'changes.action_new_unmuted'
|
|
39
|
+
else change.to ? 'changes.action_new_on' : 'changes.action_new_off'
|
|
40
|
+
end
|
|
41
|
+
text = pf_ui(key, resource: pf_resource_label(resource), action: action.display_name)
|
|
42
|
+
"#{text} #{pf_action_managed_note(resource, action.name)}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The first row for an action makes it managed for everyone; later rows
|
|
46
|
+
# join the sets that already manage it.
|
|
47
|
+
def pf_action_managed_note(resource, key)
|
|
48
|
+
return pf_ui('changes.action_managed_later') unless resource.in_effect?
|
|
49
|
+
|
|
50
|
+
across = pf_action_across(resource.name)
|
|
51
|
+
return pf_ui('changes.action_managed_first') unless across.managed?(key)
|
|
52
|
+
|
|
53
|
+
sets = across.permitting_sets(key).map(&:name)
|
|
54
|
+
sets.any? ? pf_ui('changes.action_managed_already', sets: sets.join(', ')) : pf_ui('changes.action_managed_nobody')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# One lookup per resource name per page.
|
|
58
|
+
def pf_action_across(resource_name)
|
|
59
|
+
(@pf_action_across ||= {})[resource_name] ||= PermissionForge::Admin::ActionAcrossSets.new(resource_name)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def pf_set_attr_change(change)
|
|
63
|
+
case change.attribute
|
|
64
|
+
when 'is_active' then pf_ui(change.to ? 'changes.set_on' : 'changes.set_off')
|
|
65
|
+
when 'permission_set_group_id'
|
|
66
|
+
group = change.to && PermissionForge::PermissionSetGroup.find_by(id: change.to)
|
|
67
|
+
group ? pf_ui('changes.set_group', group: group.name) : pf_ui('changes.set_no_group')
|
|
68
|
+
else pf_ui('changes.set_text', attribute: pf_ui("changes.attributes.#{change.attribute}"))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def pf_resource_flag_change(change)
|
|
73
|
+
key = change.to ? 'changes.flag_on' : 'changes.flag_off'
|
|
74
|
+
key = "#{key}_muting" if change.resource&.permission_set&.is_muting
|
|
75
|
+
text = pf_ui(key, resource: pf_resource_label(change.resource), flag: pf_flag_name(change.attribute))
|
|
76
|
+
change.cascaded ? "#{text} #{pf_ui('changes.cascaded')}" : text
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def pf_flag_name(flag)
|
|
80
|
+
pf_ui("flags_short.#{flag}")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def pf_field_level_change(change)
|
|
84
|
+
from_level, = Array(change.from)
|
|
85
|
+
args = { resource: pf_resource_label(change.resource), field: pf_change_field_name(change.record),
|
|
86
|
+
to: pf_change_level_text(change.to) }
|
|
87
|
+
text = if from_level
|
|
88
|
+
pf_ui('changes.field_level', **args, from: pf_level_label(from_level))
|
|
89
|
+
else
|
|
90
|
+
pf_ui('changes.field_new', **args)
|
|
91
|
+
end
|
|
92
|
+
change.cascaded ? "#{text} #{pf_ui('changes.cascaded_field')}" : text
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# "Masked (email)" for [level, strategy].
|
|
96
|
+
def pf_change_level_text(level_and_strategy)
|
|
97
|
+
level, strategy = Array(level_and_strategy)
|
|
98
|
+
text = pf_level_label(level)
|
|
99
|
+
%w[masked mask].include?(level) && strategy ? "#{text} (#{pf_mask_strategy_label(strategy)})" : text
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def pf_change_field_name(field)
|
|
103
|
+
field.label.presence || field.name
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def pf_field_attr_change(change)
|
|
107
|
+
field = change.record
|
|
108
|
+
if change.attribute == 'is_active'
|
|
109
|
+
return pf_ui(change.to ? 'changes.field_on' : 'changes.field_off', resource: pf_resource_label(change.resource),
|
|
110
|
+
field: field.label.presence || field.name)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
pf_ui('changes.field_text', resource: pf_resource_label(change.resource), field: field.name,
|
|
114
|
+
attribute: pf_ui("changes.attributes.#{change.attribute}"))
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module PermissionForge
|
|
2
|
+
# Person labels, avatars and assignment expiry/review cells.
|
|
3
|
+
#
|
|
4
|
+
# @api private
|
|
5
|
+
module PeopleHelper
|
|
6
|
+
def pf_person(user)
|
|
7
|
+
@pf_person_labels ||= {}
|
|
8
|
+
@pf_person_labels[[user.class.name, user.id]] ||= PermissionForge::Admin::PersonLabel.new(user)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def pf_person_name(user)
|
|
12
|
+
user ? pf_person(user).name : pf_ui('audit.system')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def pf_avatar(user, size: nil)
|
|
16
|
+
classes = ['pf-avatar', ("pf-avatar--#{size}" if size)].compact.join(' ')
|
|
17
|
+
content_tag(:span, pf_person(user).initials, class: classes, 'aria-hidden': true)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Avatar + name + email block.
|
|
21
|
+
def pf_person_cell(user, href: nil)
|
|
22
|
+
label = pf_person(user)
|
|
23
|
+
body = safe_join([pf_avatar(user),
|
|
24
|
+
content_tag(:span, class: 'pf-stack pf-stack--tight pf-ellipsis') do
|
|
25
|
+
safe_join([content_tag(:span, label.name, class: 'pf-person-name pf-ellipsis'),
|
|
26
|
+
(content_tag(:span, label.secondary, class: 'pf-person-email pf-ellipsis') if label.secondary)].compact)
|
|
27
|
+
end])
|
|
28
|
+
href ? link_to(body, href, class: 'pf-cell-person') : content_tag(:span, body, class: 'pf-cell-person')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# --- assignments --------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
# [text, css tone class] for an assignment end date.
|
|
34
|
+
def pf_expiry(expires_at)
|
|
35
|
+
days = PermissionForge::Admin::ExpiryPresets.days_left(expires_at)
|
|
36
|
+
return [pf_ui('expiry.none'), 'pf-text-2'] if days.nil?
|
|
37
|
+
return [pf_ui('expiry.expired', date: pf_date(expires_at, year: false)), 'pf-text-3'] if expires_at < Time.current
|
|
38
|
+
return [pf_ui('expiry.today'), 'pf-text-warn'] if days.zero?
|
|
39
|
+
return [pf_ui('expiry.ends_in', count: days, date: pf_date(expires_at, year: false)), 'pf-text-warn'] if days <= 7
|
|
40
|
+
|
|
41
|
+
[pf_ui('expiry.until', date: pf_date(expires_at, year: false)), 'pf-text-2']
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def pf_expiry_tag(expires_at)
|
|
45
|
+
text, tone = pf_expiry(expires_at)
|
|
46
|
+
content_tag(:span, text, class: tone)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# "✓ Reviewed by Ana on 3 Oct 2026" / "– Never reviewed"; a warning
|
|
50
|
+
# (⚠, warn tone) when the assignment is due for review (pf_stale?).
|
|
51
|
+
def pf_reviewed(assignment)
|
|
52
|
+
stale = pf_stale?(assignment)
|
|
53
|
+
reviewed = pf_review_known?(assignment) && assignment.reviewed_at.present?
|
|
54
|
+
glyph = content_tag(:span, pf_review_glyph(stale, reviewed), 'aria-hidden': true)
|
|
55
|
+
content_tag(:span, safe_join([glyph, pf_review_text(assignment)], ' '), class: "pf-fs-12 #{stale ? 'pf-text-warn' : 'pf-text-3'}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def pf_review_glyph(stale, reviewed)
|
|
59
|
+
return '⚠' if stale
|
|
60
|
+
|
|
61
|
+
reviewed ? '✓' : '–'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# "Reviewed by <person> on <date>", "Reviewed on <date>" (no person
|
|
65
|
+
# recorded, e.g. a password-prompt login), or "Never reviewed". Without
|
|
66
|
+
# the review columns (upgrade not run) the last update counts, as before.
|
|
67
|
+
def pf_review_text(assignment)
|
|
68
|
+
return pf_ui('assignment.reviewed', when: pf_relative(assignment.last_reviewed_at)) unless pf_review_known?(assignment)
|
|
69
|
+
|
|
70
|
+
reviewed_at = assignment.reviewed_at
|
|
71
|
+
return pf_ui('assignment.never_reviewed') if reviewed_at.nil?
|
|
72
|
+
|
|
73
|
+
reviewer = pf_reviewer(assignment)
|
|
74
|
+
return pf_ui('assignment.reviewed_on', date: pf_date(reviewed_at)) unless reviewer
|
|
75
|
+
|
|
76
|
+
pf_ui('assignment.reviewed_by', who: pf_person_name(reviewer), date: pf_date(reviewed_at))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def pf_review_known?(assignment)
|
|
80
|
+
assignment.class.respond_to?(:review_supported?) && assignment.class.review_supported?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The person who last reviewed the assignment (memoized per page), or nil.
|
|
84
|
+
def pf_reviewer(assignment)
|
|
85
|
+
id = assignment.try(:reviewed_by_id)
|
|
86
|
+
return nil if id.blank?
|
|
87
|
+
|
|
88
|
+
@pf_reviewers ||= {}
|
|
89
|
+
return @pf_reviewers[id.to_s] if @pf_reviewers.key?(id.to_s)
|
|
90
|
+
|
|
91
|
+
@pf_reviewers[id.to_s] = PermissionForge.configuration.user_model.find_by(id: id)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Who granted an assignment: the actor of its assignment.granted event
|
|
95
|
+
# when the audit log has one (an HTTP Basic login or an API client stamps
|
|
96
|
+
# no created_by), else created_by. `granters` is an Admin::LastChanges
|
|
97
|
+
# over the assignments of the page (events: %w[assignment.granted]).
|
|
98
|
+
def pf_granted_by(assignment, granters = nil)
|
|
99
|
+
event = granters && granters[assignment]
|
|
100
|
+
event ? pf_audit_actor_name(event) : pf_person_name(assignment.try(:created_by))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def pf_granters(assignments)
|
|
104
|
+
PermissionForge::Admin::LastChanges.new(assignments, events: %w[assignment.granted])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def pf_stale?(assignment)
|
|
108
|
+
assignment.expires_at.nil? &&
|
|
109
|
+
assignment.last_reviewed_at < PermissionForge.configuration.admin_stale_assignment_days.to_i.days.ago
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Calendar date (not time) for each expiry preset, for labels.
|
|
113
|
+
def pf_expiry_preset_date(key)
|
|
114
|
+
date = PermissionForge::Admin::ExpiryPresets.date_for(key)
|
|
115
|
+
date ? pf_date(date) : nil
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|