permission_forge 0.14.1 → 0.15.0
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 +4 -4
- data/CHANGELOG.md +2372 -0
- data/README.md +76 -25
- data/app/controllers/concerns/permission_forge/api/crud_actions.rb +3 -2
- data/app/controllers/concerns/permission_forge/api/input_shapes.rb +200 -0
- data/app/controllers/concerns/permission_forge/api/pagination.rb +12 -5
- data/app/controllers/concerns/permission_forge/api/response_renderer.rb +37 -1
- data/app/controllers/concerns/permission_forge/api_authentication.rb +13 -2
- data/app/controllers/concerns/permission_forge/cache_confirmation.rb +42 -11
- data/app/controllers/concerns/permission_forge/change_review.rb +42 -0
- data/app/controllers/concerns/permission_forge/copy_refusal.rb +39 -0
- data/app/controllers/concerns/permission_forge/param_shapes.rb +207 -7
- data/app/controllers/concerns/permission_forge/save_notice.rb +69 -0
- data/app/controllers/permission_forge/api/audit_events_controller.rb +1 -1
- data/app/controllers/permission_forge/api/base_controller.rb +163 -12
- data/app/controllers/permission_forge/api/dashboard_controller.rb +29 -4
- data/app/controllers/permission_forge/api/permission_set_group_users_controller.rb +5 -2
- data/app/controllers/permission_forge/api/permission_set_groups_controller.rb +5 -4
- data/app/controllers/permission_forge/api/permission_set_rules_controller.rb +7 -5
- data/app/controllers/permission_forge/api/permission_set_users_controller.rb +80 -19
- data/app/controllers/permission_forge/api/permission_sets_controller.rb +12 -6
- data/app/controllers/permission_forge/api/resource_actions_controller.rb +1 -1
- data/app/controllers/permission_forge/api/resource_fields_controller.rb +3 -4
- data/app/controllers/permission_forge/api/resources_controller.rb +26 -12
- data/app/controllers/permission_forge/api/unmatched_routes_controller.rb +20 -0
- data/app/controllers/permission_forge/application_controller.rb +39 -5
- data/app/controllers/permission_forge/assignments_controller.rb +36 -34
- data/app/controllers/permission_forge/bulk_assignments_controller.rb +8 -4
- data/app/controllers/permission_forge/field_lookups_controller.rb +1 -1
- data/app/controllers/permission_forge/maintenance_controller.rb +6 -2
- data/app/controllers/permission_forge/people_controller.rb +14 -14
- data/app/controllers/permission_forge/permission_set_editor.rb +20 -6
- data/app/controllers/permission_forge/permission_set_group_users_controller.rb +3 -1
- data/app/controllers/permission_forge/permission_set_groups_controller.rb +23 -9
- data/app/controllers/permission_forge/permission_set_rules_controller.rb +20 -10
- data/app/controllers/permission_forge/permission_sets_controller.rb +23 -24
- data/app/controllers/permission_forge/resource_actions_controller.rb +11 -6
- data/app/controllers/permission_forge/resource_fields_controller.rb +37 -19
- data/app/controllers/permission_forge/resources_controller.rb +12 -11
- data/app/frontend/permission_forge/pf_admin.css +99 -17
- data/app/frontend/permission_forge/pf_admin.js +36 -0
- data/app/helpers/permission_forge/application_helper.rb +22 -72
- data/app/helpers/permission_forge/audit_attribution_helper.rb +77 -0
- data/app/helpers/permission_forge/audit_helper.rb +57 -119
- data/app/helpers/permission_forge/audit_records_helper.rb +103 -0
- data/app/helpers/permission_forge/audit_sentences_helper.rb +56 -2
- data/app/helpers/permission_forge/badges_helper.rb +74 -17
- data/app/helpers/permission_forge/changes_helper.rb +24 -6
- data/app/helpers/permission_forge/conditions_helper.rb +119 -0
- data/app/helpers/permission_forge/labels_helper.rb +134 -0
- data/app/helpers/permission_forge/people_helper.rb +11 -19
- data/app/helpers/permission_forge/rule_problems_helper.rb +73 -0
- data/app/helpers/permission_forge/sentences_helper.rb +119 -98
- data/app/helpers/permission_forge/settings_helper.rb +2 -2
- data/app/helpers/permission_forge/ui_helper.rb +65 -4
- data/app/models/permission_forge/audit_event.rb +5 -1
- data/app/models/permission_forge/permission_set.rb +208 -18
- data/app/models/permission_forge/permission_set_group.rb +10 -4
- data/app/models/permission_forge/permission_set_group_user.rb +3 -1
- data/app/models/permission_forge/permission_set_rule.rb +254 -17
- data/app/models/permission_forge/permission_set_user.rb +38 -3
- data/app/models/permission_forge/resource.rb +18 -8
- data/app/models/permission_forge/resource_action.rb +15 -6
- data/app/models/permission_forge/resource_field.rb +97 -11
- data/app/views/permission_forge/assignments/confirm_destroy.html.erb +4 -4
- data/app/views/permission_forge/assignments/new.html.erb +10 -6
- data/app/views/permission_forge/audit_events/index.html.erb +1 -1
- data/app/views/permission_forge/audit_events/show.html.erb +16 -9
- data/app/views/permission_forge/bulk_assignments/confirm_revoke.html.erb +3 -3
- data/app/views/permission_forge/bulk_assignments/new.html.erb +3 -3
- data/app/views/permission_forge/field_lookups/show.html.erb +2 -2
- data/app/views/permission_forge/home/_broken_rules_card.html.erb +35 -0
- data/app/views/permission_forge/home/_conditions_card.html.erb +28 -0
- data/app/views/permission_forge/home/_ending_card.html.erb +11 -5
- data/app/views/permission_forge/home/_expired_card.html.erb +1 -1
- data/app/views/permission_forge/home/_mask_strategies_card.html.erb +27 -0
- data/app/views/permission_forge/home/_privacy_card.html.erb +21 -7
- data/app/views/permission_forge/home/_rules_card.html.erb +1 -1
- data/app/views/permission_forge/home/_secret_rules_card.html.erb +33 -0
- data/app/views/permission_forge/home/_stale_card.html.erb +7 -5
- data/app/views/permission_forge/home/_tidy_card.html.erb +1 -1
- data/app/views/permission_forge/home/index.html.erb +5 -6
- data/app/views/permission_forge/people/_access_tab.html.erb +26 -19
- data/app/views/permission_forge/people/_audit_tab.html.erb +8 -8
- data/app/views/permission_forge/people/_effective_field_source.html.erb +13 -7
- data/app/views/permission_forge/people/_effective_tab.html.erb +16 -15
- data/app/views/permission_forge/people/_effective_why.html.erb +20 -16
- data/app/views/permission_forge/people/_why_item.html.erb +1 -1
- data/app/views/permission_forge/people/confirm_remove_all.html.erb +3 -3
- data/app/views/permission_forge/people/erasure_preview.html.erb +1 -1
- data/app/views/permission_forge/permission_set_groups/confirm_destroy.html.erb +3 -3
- data/app/views/permission_forge/permission_set_groups/index.html.erb +3 -3
- data/app/views/permission_forge/permission_set_groups/show.html.erb +28 -19
- data/app/views/permission_forge/permission_set_rules/_builder.html.erb +35 -15
- data/app/views/permission_forge/permission_set_rules/_preview.html.erb +17 -0
- data/app/views/permission_forge/permission_set_rules/confirm_destroy.html.erb +3 -3
- data/app/views/permission_forge/permission_set_rules/preview.html.erb +12 -0
- data/app/views/permission_forge/permission_sets/_assignments_tab.html.erb +37 -22
- data/app/views/permission_forge/permission_sets/_audit_tab.html.erb +4 -4
- data/app/views/permission_forge/permission_sets/_condition_problems.html.erb +15 -0
- data/app/views/permission_forge/permission_sets/_conditions_tab.html.erb +14 -8
- data/app/views/permission_forge/permission_sets/_header.html.erb +6 -5
- data/app/views/permission_forge/permission_sets/_records_summary.html.erb +2 -2
- data/app/views/permission_forge/permission_sets/_resources_tab.html.erb +4 -4
- data/app/views/permission_forge/permission_sets/confirm_destroy.html.erb +4 -4
- data/app/views/permission_forge/permission_sets/confirm_switch_mode.html.erb +3 -3
- data/app/views/permission_forge/permission_sets/index.html.erb +6 -6
- data/app/views/permission_forge/permission_sets/new.html.erb +2 -2
- data/app/views/permission_forge/permission_sets/review.html.erb +13 -3
- data/app/views/permission_forge/permission_sets/show.html.erb +7 -5
- data/app/views/permission_forge/resource_actions/_across_sets.html.erb +7 -10
- data/app/views/permission_forge/resource_actions/_effect.html.erb +5 -5
- data/app/views/permission_forge/resource_actions/_form.html.erb +1 -1
- data/app/views/permission_forge/resource_actions/_managed_note.html.erb +5 -3
- data/app/views/permission_forge/resource_actions/confirm_destroy.html.erb +1 -1
- data/app/views/permission_forge/resource_actions/new.html.erb +1 -1
- data/app/views/permission_forge/resource_actions/show.html.erb +2 -2
- data/app/views/permission_forge/resource_fields/_explain.html.erb +19 -15
- data/app/views/permission_forge/resource_fields/show.html.erb +21 -12
- data/app/views/permission_forge/resources/_actions_tab.html.erb +4 -4
- data/app/views/permission_forge/resources/_fields_tab.html.erb +17 -9
- data/app/views/permission_forge/resources/_rules_tab.html.erb +9 -6
- data/app/views/permission_forge/resources/_sync.html.erb +8 -5
- data/app/views/permission_forge/resources/confirm_destroy.html.erb +3 -3
- data/app/views/permission_forge/resources/new.html.erb +3 -2
- data/app/views/permission_forge/resources/show.html.erb +6 -6
- data/app/views/permission_forge/settings/_row.html.erb +2 -2
- data/app/views/permission_forge/settings/show.html.erb +10 -1
- data/app/views/permission_forge/shared/_across_sets.html.erb +8 -9
- data/app/views/permission_forge/shared/_audit_events.html.erb +2 -2
- data/app/views/permission_forge/shared/_audit_footer.html.erb +13 -2
- data/app/views/permission_forge/shared/_crud_cell.html.erb +6 -6
- data/app/views/permission_forge/shared/_mode_banners.html.erb +1 -1
- data/app/views/permission_forge/shared/_savebar.html.erb +4 -2
- data/config/locales/permission_forge.en.yml +480 -69
- data/config/locales/permission_forge.mk.yml +499 -79
- data/config/locales/permission_forge.sq.yml +509 -80
- data/config/routes.rb +38 -23
- data/lib/generators/permission_forge/install/install_generator.rb +21 -0
- data/lib/generators/permission_forge/install/templates/initializer.rb.tt +35 -34
- data/lib/generators/permission_forge/install/templates/migration.rb.tt +5 -5
- data/lib/permission_forge/activation_conditions.rb +200 -0
- data/lib/permission_forge/admin/action_label.rb +33 -0
- data/lib/permission_forge/admin/action_rows.rb +3 -1
- data/lib/permission_forge/admin/actor_label.rb +52 -0
- data/lib/permission_forge/admin/attention_items.rb +152 -31
- data/lib/permission_forge/admin/audit_log.rb +4 -7
- data/lib/permission_forge/admin/audit_trail.rb +33 -17
- data/lib/permission_forge/admin/bulk_people_matcher.rb +32 -14
- data/lib/permission_forge/admin/chain_entries.rb +42 -0
- data/lib/permission_forge/admin/change_set.rb +12 -1
- data/lib/permission_forge/admin/change_set_parts/base.rb +5 -4
- data/lib/permission_forge/admin/change_set_parts/context.rb +3 -1
- data/lib/permission_forge/admin/change_set_parts/field_edit.rb +4 -1
- data/lib/permission_forge/admin/change_set_parts/field_levels.rb +1 -1
- data/lib/permission_forge/admin/change_set_parts/new_actions.rb +1 -1
- data/lib/permission_forge/admin/change_set_parts/new_fields.rb +38 -11
- data/lib/permission_forge/admin/change_set_parts/resource_edit.rb +17 -1
- data/lib/permission_forge/admin/change_set_parts/resource_flags.rb +1 -1
- data/lib/permission_forge/admin/change_set_parts/set_attrs.rb +4 -0
- data/lib/permission_forge/admin/change_set_parts/values.rb +10 -4
- data/lib/permission_forge/admin/column_fields.rb +22 -21
- data/lib/permission_forge/admin/conditions_form.rb +47 -15
- data/lib/permission_forge/admin/controller_catalog.rb +42 -14
- data/lib/permission_forge/admin/editable_lock.rb +83 -3
- data/lib/permission_forge/admin/effective_access.rb +147 -48
- data/lib/permission_forge/admin/effective_fields.rb +75 -67
- data/lib/permission_forge/admin/error_sentence.rb +47 -0
- data/lib/permission_forge/admin/expiry_presets.rb +17 -0
- data/lib/permission_forge/admin/exports/holdings.rb +6 -3
- data/lib/permission_forge/admin/field_across_sets.rb +7 -4
- data/lib/permission_forge/admin/field_chain.rb +109 -0
- data/lib/permission_forge/admin/field_detail.rb +24 -11
- data/lib/permission_forge/admin/field_label.rb +109 -0
- data/lib/permission_forge/admin/field_level.rb +4 -68
- data/lib/permission_forge/admin/field_provenance.rb +132 -0
- data/lib/permission_forge/admin/field_rows.rb +26 -11
- data/lib/permission_forge/admin/last_changes.rb +14 -0
- data/lib/permission_forge/admin/list_sentence.rb +32 -0
- data/lib/permission_forge/admin/mask_strategy_choices.rb +57 -0
- data/lib/permission_forge/admin/model_catalog.rb +83 -39
- data/lib/permission_forge/admin/model_label.rb +66 -0
- data/lib/permission_forge/admin/people_candidates.rb +21 -9
- data/lib/permission_forge/admin/people_directory.rb +23 -5
- data/lib/permission_forge/admin/permission_set_factory.rb +6 -15
- data/lib/permission_forge/admin/person_label.rb +105 -16
- data/lib/permission_forge/admin/prefill.rb +92 -0
- data/lib/permission_forge/admin/record_name.rb +45 -0
- data/lib/permission_forge/admin/rule_builder.rb +77 -3
- data/lib/permission_forge/admin/rule_form.rb +44 -10
- data/lib/permission_forge/admin/rule_form_input.rb +27 -11
- data/lib/permission_forge/admin/rule_notes.rb +43 -0
- data/lib/permission_forge/admin/rule_preview.rb +96 -0
- data/lib/permission_forge/admin/sensitive_columns.rb +10 -62
- data/lib/permission_forge/admin/settings_catalog.rb +64 -12
- data/lib/permission_forge/admin/state_label.rb +97 -0
- data/lib/permission_forge/admin_method_override.rb +44 -0
- data/lib/permission_forge/api/schema.rb +322 -0
- data/lib/permission_forge/api/serializers/base_serializer.rb +87 -1
- data/lib/permission_forge/api/serializers/permission_set_group_user_serializer.rb +1 -1
- data/lib/permission_forge/api/serializers/permission_set_rule_serializer.rb +1 -1
- data/lib/permission_forge/api/serializers/permission_set_serializer.rb +5 -2
- data/lib/permission_forge/api/serializers/permission_set_user_serializer.rb +1 -1
- data/lib/permission_forge/api/serializers/resource_action_serializer.rb +1 -1
- data/lib/permission_forge/api/serializers/resource_field_serializer.rb +1 -1
- data/lib/permission_forge/api/serializers/resource_serializer.rb +1 -1
- data/lib/permission_forge/api_authorizations.rb +59 -0
- data/lib/permission_forge/api_only_admin.rb +81 -0
- data/lib/permission_forge/audit/actor_codes.rb +32 -0
- data/lib/permission_forge/audit/actor_resolver.rb +1 -2
- data/lib/permission_forge/audit/boot_warnings.rb +29 -9
- data/lib/permission_forge/audit/event_catalog.rb +16 -6
- data/lib/permission_forge/audit/event_query.rb +3 -3
- data/lib/permission_forge/audit/reason_codes.rb +25 -0
- data/lib/permission_forge/audit/runtime.rb +9 -2
- data/lib/permission_forge/audit/settings_observer.rb +2 -2
- data/lib/permission_forge/audit/unmasked_without_user.rb +169 -0
- data/lib/permission_forge/audit.rb +24 -10
- data/lib/permission_forge/boot_checks.rb +180 -63
- data/lib/permission_forge/cache/base.rb +10 -0
- data/lib/permission_forge/cache/null_cache.rb +5 -0
- data/lib/permission_forge/cache/outcome.rb +119 -0
- data/lib/permission_forge/cache/rails_cache.rb +6 -0
- data/lib/permission_forge/cache/report.rb +150 -0
- data/lib/permission_forge/cache/user_keys.rb +8 -4
- data/lib/permission_forge/cache_warnings.rb +140 -0
- data/lib/permission_forge/concerns/auto_permission_scoping.rb +19 -2
- data/lib/permission_forge/concerns/has_tenant_scoping.rb +1 -1
- data/lib/permission_forge/concerns/has_uuid.rb +1 -1
- data/lib/permission_forge/concerns/keeps_copied_values.rb +48 -0
- data/lib/permission_forge/concerns/masked_attributes/alias_access.rb +12 -2
- data/lib/permission_forge/concerns/masked_attributes/virtual_readers.rb +6 -4
- data/lib/permission_forge/concerns/masked_attributes.rb +150 -34
- data/lib/permission_forge/concerns/normalizes_label.rb +78 -0
- data/lib/permission_forge/concerns/permission_cache_accessor.rb +3 -3
- data/lib/permission_forge/concerns/permission_scoped_findable.rb +121 -29
- data/lib/permission_forge/concerns/permissioned_user.rb +1 -1
- data/lib/permission_forge/concerns/records_audit_events.rb +13 -6
- data/lib/permission_forge/concerns/requires_name.rb +37 -0
- data/lib/permission_forge/concerns/validates_column_lengths.rb +24 -0
- data/lib/permission_forge/concerns/validates_dependency.rb +5 -2
- data/lib/permission_forge/concerns/validates_required_columns.rb +74 -0
- data/lib/permission_forge/conditional_sets.rb +13 -6
- data/lib/permission_forge/configuration/admin_ui_settings.rb +18 -4
- data/lib/permission_forge/configuration/audit_settings.rb +2 -1
- data/lib/permission_forge/configuration/controller_action_settings.rb +5 -4
- data/lib/permission_forge/configuration/masked_attributes_settings.rb +73 -19
- data/lib/permission_forge/configuration/presets.rb +20 -11
- data/lib/permission_forge/configuration/query_scoping_settings.rb +55 -0
- data/lib/permission_forge/configuration/strategy_values.rb +2 -1
- data/lib/permission_forge/configuration.rb +41 -18
- data/lib/permission_forge/controller_actions/managed_index.rb +8 -7
- data/lib/permission_forge/database_reach.rb +93 -0
- data/lib/permission_forge/deprecation.rb +1 -1
- data/lib/permission_forge/docs/api_samples.rb +250 -0
- data/lib/permission_forge/docs/configuration_reference.rb +172 -0
- data/lib/permission_forge/encrypted_attributes.rb +150 -0
- data/lib/permission_forge/engine.rb +46 -14
- data/lib/permission_forge/execution_state/restore.rb +74 -0
- data/lib/permission_forge/execution_state.rb +8 -1
- data/lib/permission_forge/field_level.rb +123 -0
- data/lib/permission_forge/field_names.rb +145 -0
- data/lib/permission_forge/field_variants.rb +96 -0
- data/lib/permission_forge/generated_text.rb +95 -0
- data/lib/permission_forge/inspection_filters.rb +83 -0
- data/lib/permission_forge/managed_fields.rb +66 -0
- data/lib/permission_forge/masked_models.rb +25 -19
- data/lib/permission_forge/masking_sync.rb +100 -25
- data/lib/permission_forge/model_bindings.rb +30 -19
- data/lib/permission_forge/model_lookup.rb +42 -0
- data/lib/permission_forge/operators/encrypted_alias.rb +67 -0
- data/lib/permission_forge/operators/registry.rb +6 -0
- data/lib/permission_forge/operators/rule_column.rb +242 -27
- data/lib/permission_forge/operators/rule_path.rb +179 -0
- data/lib/permission_forge/operators/rule_problem.rb +309 -0
- data/lib/permission_forge/operators/rule_sources.rb +96 -0
- data/lib/permission_forge/operators/value_casters/postgresql_types.rb +237 -0
- data/lib/permission_forge/operators/value_casters/temporal.rb +9 -20
- data/lib/permission_forge/operators/value_casters.rb +61 -13
- data/lib/permission_forge/operators/value_typing.rb +83 -7
- data/lib/permission_forge/query_scoping_warnings.rb +22 -17
- data/lib/permission_forge/rake_audit.rb +5 -4
- data/lib/permission_forge/rake_dry_run.rb +32 -0
- data/lib/permission_forge/rake_output.rb +189 -0
- data/lib/permission_forge/record_read_access.rb +51 -0
- data/lib/permission_forge/resource_chain.rb +14 -2
- data/lib/permission_forge/schema_read/ordered_types.rb +72 -0
- data/lib/permission_forge/schema_read.rb +335 -0
- data/lib/permission_forge/secret_columns.rb +49 -0
- data/lib/permission_forge/sensitive_columns.rb +114 -0
- data/lib/permission_forge/services/attribute_access_guard.rb +22 -14
- data/lib/permission_forge/services/bulk_permission_set_assignment.rb +16 -8
- data/lib/permission_forge/services/concerns/clonable.rb +9 -0
- data/lib/permission_forge/services/concerns/settles_cache.rb +58 -0
- data/lib/permission_forge/services/consent_withdrawal_handler.rb +37 -19
- data/lib/permission_forge/services/data_subject_erasure.rb +83 -45
- data/lib/permission_forge/services/expired_assignment_cleaner.rb +62 -25
- data/lib/permission_forge/services/extra_field_defaults.rb +1 -1
- data/lib/permission_forge/services/field_permission_merger.rb +44 -17
- data/lib/permission_forge/services/filters_response_attributes.rb +20 -5
- data/lib/permission_forge/services/gdpr_field_auditor/text_report.rb +33 -5
- data/lib/permission_forge/services/gdpr_field_auditor.rb +68 -48
- data/lib/permission_forge/services/host_key_guard.rb +8 -5
- data/lib/permission_forge/services/mask_value.rb +72 -11
- data/lib/permission_forge/services/permission_cache_clearer.rb +36 -15
- data/lib/permission_forge/services/permission_hash_builder.rb +48 -19
- data/lib/permission_forge/services/permission_rule_applier.rb +131 -68
- data/lib/permission_forge/services/permission_set_cloner.rb +32 -8
- data/lib/permission_forge/services/permission_set_condition_evaluator.rb +38 -36
- data/lib/permission_forge/services/permission_set_group_cloner.rb +2 -1
- data/lib/permission_forge/services/permits_attributes/result.rb +31 -0
- data/lib/permission_forge/services/permits_attributes.rb +48 -27
- data/lib/permission_forge/services/resource_field_generator/row_builder.rb +96 -0
- data/lib/permission_forge/services/resource_field_generator.rb +71 -42
- data/lib/permission_forge/services/resource_with_fields_builder.rb +2 -1
- data/lib/permission_forge/services/rls_operator_sql.rb +83 -24
- data/lib/permission_forge/services/rls_policy_generator.rb +83 -39
- data/lib/permission_forge/services/rls_read_access_sql.rb +131 -0
- data/lib/permission_forge/services/security_audit/action_check.rb +49 -5
- data/lib/permission_forge/services/security_audit/assignment_check.rb +2 -1
- data/lib/permission_forge/services/security_audit/audit_trail_check.rb +1 -3
- data/lib/permission_forge/services/security_audit/conditions_check.rb +92 -0
- data/lib/permission_forge/services/security_audit/config_check.rb +41 -18
- data/lib/permission_forge/services/security_audit/integrity_check.rb +71 -4
- data/lib/permission_forge/services/security_audit/masking_without_user.rb +66 -0
- data/lib/permission_forge/services/security_audit/personal_data_check.rb +7 -11
- data/lib/permission_forge/services/security_audit/rule_check.rb +180 -50
- data/lib/permission_forge/services/security_audit/text_report.rb +14 -2
- data/lib/permission_forge/services/security_audit/unmanaged_actions.rb +145 -0
- data/lib/permission_forge/services/security_audit/user_model_check.rb +4 -3
- data/lib/permission_forge/services/security_audit.rb +16 -4
- data/lib/permission_forge/services/stale_assignment_auditor.rb +8 -5
- data/lib/permission_forge/timestamp_fields.rb +60 -0
- data/lib/permission_forge/unique_conflict.rb +117 -0
- data/lib/permission_forge/version.rb +1 -1
- data/lib/permission_forge/virtual_fields/discovery.rb +2 -1
- data/lib/permission_forge/virtual_fields.rb +15 -5
- data/lib/permission_forge/warnings.rb +263 -0
- data/lib/permission_forge/web_request.rb +248 -0
- data/lib/permission_forge.rb +54 -0
- data/lib/tasks/permission_forge.rake +26 -6
- data/lib/tasks/permission_forge_audit_events.rake +8 -4
- data/lib/tasks/permission_forge_cache.rake +12 -12
- data/lib/tasks/permission_forge_docs.rake +48 -0
- data/lib/tasks/permission_forge_expiration.rake +11 -5
- data/lib/tasks/permission_forge_gdpr.rake +36 -20
- metadata +84 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Admin
|
|
5
|
+
# The choices of a field's mask-strategy select (the field drawer): the
|
|
6
|
+
# strategies this app knows, after the saved one when this app does not
|
|
7
|
+
# know it — a row written past the model, or a custom strategy since
|
|
8
|
+
# removed from config.custom_mask_strategies (Services::MaskValue.known?).
|
|
9
|
+
# The saved name is offered as it is stored, selected, so a save that
|
|
10
|
+
# changes something else never rewrites the strategy (field test round 6:
|
|
11
|
+
# the select showed nothing selected and a label change saved `full`);
|
|
12
|
+
# it masks fully until the admin picks one this app knows.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# choices = MaskStrategyChoices.new(field) # field.mask_strategy == 'emial'
|
|
16
|
+
# choices.unknown? # => true
|
|
17
|
+
# choices.names # => ['emial', 'full', 'email', 'last4', 'first1_last4']
|
|
18
|
+
# choices.options # => [['emial', :unknown_strategy], ['full', nil], ...]
|
|
19
|
+
class MaskStrategyChoices
|
|
20
|
+
# @param field [PermissionForge::ResourceField, nil] the saved row, nil for a column with none
|
|
21
|
+
def initialize(field)
|
|
22
|
+
@field = field
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The saved strategy is none this app knows, and the row masks.
|
|
26
|
+
def unknown?
|
|
27
|
+
!@field.nil? && @field.mask_access == true && !Services::MaskValue.known?(@field.mask_strategy)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The names the select offers, the saved unknown one first.
|
|
31
|
+
#
|
|
32
|
+
# @return [Array<String>]
|
|
33
|
+
def names
|
|
34
|
+
known = Services::MaskValue.strategies.keys.map(&:to_s)
|
|
35
|
+
unknown? && @field.mask_strategy.present? ? [@field.mask_strategy] + known : known
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# [name, note] per choice. The note is the key under
|
|
39
|
+
# `resource_fields.show` shown after the label: :unknown_strategy for
|
|
40
|
+
# the saved name this app does not know, :custom for a configured
|
|
41
|
+
# custom strategy, nil for a built-in.
|
|
42
|
+
#
|
|
43
|
+
# @return [Array<Array(String, Symbol, nil)>]
|
|
44
|
+
def options
|
|
45
|
+
names.map { |name| [name, self.class.note(name)] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param name [String] a strategy name
|
|
49
|
+
# @return [Symbol, nil] :unknown_strategy, :custom or nil (built-in)
|
|
50
|
+
def self.note(name)
|
|
51
|
+
return :unknown_strategy unless Services::MaskValue.known?(name)
|
|
52
|
+
|
|
53
|
+
:custom unless Services::MaskValue::BUILT_IN_STRATEGIES.key?(name.to_s)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -5,6 +5,15 @@ module PermissionForge
|
|
|
5
5
|
# Read-only reflection over the host app's ActiveRecord models, so the
|
|
6
6
|
# admin UI only ever offers real models, columns and associations (no
|
|
7
7
|
# free-text names that silently do nothing when misspelled).
|
|
8
|
+
#
|
|
9
|
+
# Columns are read through PermissionForge::SchemaRead: a model's column
|
|
10
|
+
# list is compared with the database once per catalog (a column a
|
|
11
|
+
# migration added while the process was up is offered, a removed one
|
|
12
|
+
# flagged), and a name that is not a column is asked again once before
|
|
13
|
+
# it is called virtual. Associations are the ones a rule's path can
|
|
14
|
+
# follow (Operators::RulePath.unjoinable_cause): the builder never offers
|
|
15
|
+
# one the applier refuses to join. Labels are Admin::FieldLabel's and
|
|
16
|
+
# Admin::ModelLabel's.
|
|
8
17
|
class ModelCatalog
|
|
9
18
|
Column = Struct.new(:name, :label, :type, :enum_values, :user_reference, :virtual, keyword_init: true) do
|
|
10
19
|
def user_reference? = user_reference
|
|
@@ -16,8 +25,8 @@ module PermissionForge
|
|
|
16
25
|
# @param target [Class] the reflection's resolved class
|
|
17
26
|
# @param owner [Class] the model declaring the association
|
|
18
27
|
def self.from(reflection, target, owner)
|
|
19
|
-
name = reflection.name
|
|
20
|
-
new(name: name
|
|
28
|
+
name = reflection.name.to_s
|
|
29
|
+
new(name: name, label: FieldLabel.for(owner, name).text, class_name: target.name,
|
|
21
30
|
foreign_key: reflection.foreign_key.to_s, macro: reflection.macro)
|
|
22
31
|
end
|
|
23
32
|
end
|
|
@@ -31,27 +40,35 @@ module PermissionForge
|
|
|
31
40
|
# read + edit entry from ExtraFieldDefaults right now.
|
|
32
41
|
delegate :implicit_grant?, to: :virtual_fields
|
|
33
42
|
|
|
34
|
-
|
|
43
|
+
# @param verify [Boolean] compare the column list with the database
|
|
44
|
+
# (one metadata query per model and catalog); false for the
|
|
45
|
+
# catalogs that only count (Home and the navigation's attention
|
|
46
|
+
# items)
|
|
47
|
+
def initialize(klass, configuration, verify: true)
|
|
35
48
|
@klass = klass
|
|
36
49
|
@configuration = configuration
|
|
50
|
+
@verify = verify
|
|
37
51
|
end
|
|
38
52
|
|
|
39
53
|
def name = @klass.name
|
|
40
|
-
def label = @klass.
|
|
41
|
-
def plural_label = @klass
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
def label = ModelLabel.for(@klass).text
|
|
55
|
+
def plural_label = ModelLabel.plural(@klass)
|
|
56
|
+
|
|
57
|
+
# The model's columns (ignored_columns excluded), compared with the
|
|
58
|
+
# database once for this catalog unless it only counts
|
|
59
|
+
# (PermissionForge::SchemaRead).
|
|
60
|
+
def column_names = managed_fields.column_names
|
|
61
|
+
|
|
62
|
+
# Whether `name` is a column of the model, asked again once after the
|
|
63
|
+
# schema is read again before "no" (PermissionForge::SchemaRead).
|
|
64
|
+
def column?(name)
|
|
65
|
+
PermissionForge::SchemaRead.column?(@klass, name.to_s, ignored: false)
|
|
45
66
|
end
|
|
46
67
|
|
|
47
68
|
# Columns PermissionForge manages (ignored_fields removed).
|
|
48
|
-
def managed_column_names
|
|
49
|
-
column_names - ignored_column_names
|
|
50
|
-
end
|
|
69
|
+
def managed_column_names = managed_fields.managed_column_names
|
|
51
70
|
|
|
52
|
-
def ignored_column_names
|
|
53
|
-
column_names & Array(@configuration.ignored_fields).map(&:to_s)
|
|
54
|
-
end
|
|
71
|
+
def ignored_column_names = managed_fields.ignored_column_names
|
|
55
72
|
|
|
56
73
|
# Virtual fields the model declares through extra_fields_method.
|
|
57
74
|
def declared_virtual_field_names
|
|
@@ -64,10 +81,10 @@ module PermissionForge
|
|
|
64
81
|
virtual_fields.names
|
|
65
82
|
end
|
|
66
83
|
|
|
67
|
-
# Every field the admin configures: managed columns, then virtual
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
84
|
+
# Every field the admin configures: managed columns, then virtual
|
|
85
|
+
# fields (PermissionForge::ManagedFields, the names every path that
|
|
86
|
+
# creates rows writes rows for).
|
|
87
|
+
def managed_field_names = managed_fields.names
|
|
71
88
|
|
|
72
89
|
def virtual_field?(name)
|
|
73
90
|
virtual_fields.include?(name)
|
|
@@ -87,9 +104,9 @@ module PermissionForge
|
|
|
87
104
|
|
|
88
105
|
def column(name)
|
|
89
106
|
name = name.to_s
|
|
90
|
-
return virtual_column(name) unless
|
|
107
|
+
return virtual_column(name) unless column?(name)
|
|
91
108
|
|
|
92
|
-
Column.new(name: name, label: @klass
|
|
109
|
+
Column.new(name: name, label: FieldLabel.for(@klass, name).text, type: column_type(name),
|
|
93
110
|
enum_values: enum_values(name), user_reference: user_reference?(name), virtual: false)
|
|
94
111
|
end
|
|
95
112
|
|
|
@@ -103,18 +120,26 @@ module PermissionForge
|
|
|
103
120
|
|
|
104
121
|
private
|
|
105
122
|
|
|
106
|
-
def virtual_fields
|
|
107
|
-
|
|
123
|
+
def virtual_fields = managed_fields.virtual_fields
|
|
124
|
+
|
|
125
|
+
def managed_fields
|
|
126
|
+
@managed_fields ||= PermissionForge::ManagedFields.new(@klass, configuration: @configuration, verify: @verify)
|
|
108
127
|
end
|
|
109
128
|
|
|
110
|
-
# nil for
|
|
129
|
+
# nil for an association a rule's path cannot follow
|
|
130
|
+
# (Operators::RulePath.unjoinable_cause: polymorphic, a
|
|
131
|
+
# record-dependent scope, a broken :through or inverse_of, a class
|
|
132
|
+
# that cannot be loaded): the applier refuses to join it.
|
|
111
133
|
def build_association(ref)
|
|
112
|
-
|
|
113
|
-
|
|
134
|
+
return if PermissionForge::Operators::RulePath.unjoinable_cause(ref)
|
|
135
|
+
|
|
136
|
+
Association.from(ref, ref.klass, @klass)
|
|
137
|
+
rescue *PermissionForge::Operators::RuleColumn::UNTELLABLE
|
|
138
|
+
nil
|
|
114
139
|
end
|
|
115
140
|
|
|
116
141
|
def virtual_column(name)
|
|
117
|
-
Column.new(name: name, label: @klass
|
|
142
|
+
Column.new(name: name, label: FieldLabel.for(@klass, name).text, type: :string,
|
|
118
143
|
enum_values: nil, user_reference: false, virtual: true)
|
|
119
144
|
end
|
|
120
145
|
|
|
@@ -144,13 +169,16 @@ module PermissionForge
|
|
|
144
169
|
end
|
|
145
170
|
end
|
|
146
171
|
|
|
147
|
-
|
|
172
|
+
# @param verify [Boolean] whether the column lists are compared with
|
|
173
|
+
# the database (ModelInfo); false for a catalog that only counts
|
|
174
|
+
def initialize(configuration: PermissionForge.configuration, verify: true)
|
|
148
175
|
@configuration = configuration
|
|
176
|
+
@verify = verify
|
|
149
177
|
end
|
|
150
178
|
|
|
151
179
|
# @return [Array<ModelInfo>] sorted by label
|
|
152
180
|
def models
|
|
153
|
-
@models ||= model_classes.map { |k| ModelInfo.new(k, @configuration) }.sort_by(&:label)
|
|
181
|
+
@models ||= model_classes.map { |k| ModelInfo.new(k, @configuration, verify: @verify) }.sort_by(&:label)
|
|
154
182
|
end
|
|
155
183
|
|
|
156
184
|
# @return [ModelInfo, nil] for a model name, whether or not it is listed.
|
|
@@ -165,8 +193,8 @@ module PermissionForge
|
|
|
165
193
|
private
|
|
166
194
|
|
|
167
195
|
def build_info(name)
|
|
168
|
-
klass = name
|
|
169
|
-
ModelInfo.new(klass, @configuration) if usable?(klass)
|
|
196
|
+
klass = PermissionForge::ModelLookup.model_for(name)
|
|
197
|
+
ModelInfo.new(klass, @configuration, verify: @verify) if klass && usable?(klass)
|
|
170
198
|
end
|
|
171
199
|
|
|
172
200
|
def model_classes
|
|
@@ -185,7 +213,7 @@ module PermissionForge
|
|
|
185
213
|
|
|
186
214
|
def usable?(klass)
|
|
187
215
|
klass.is_a?(Class) && klass < ActiveRecord::Base && !klass.abstract_class? &&
|
|
188
|
-
!klass.name.to_s.start_with?('PermissionForge::') && klass.
|
|
216
|
+
!klass.name.to_s.start_with?('PermissionForge::') && PermissionForge::SchemaRead.table?(klass, klass.table_name)
|
|
189
217
|
rescue StandardError
|
|
190
218
|
false
|
|
191
219
|
end
|
|
@@ -200,16 +228,32 @@ module PermissionForge
|
|
|
200
228
|
true
|
|
201
229
|
end
|
|
202
230
|
|
|
203
|
-
def eager_load_app_models
|
|
204
|
-
|
|
231
|
+
def eager_load_app_models = self.class.eager_load_app_models
|
|
232
|
+
|
|
233
|
+
class << self
|
|
234
|
+
# Loads the application's models (`app/models`, through its main
|
|
235
|
+
# autoloader), so `ActiveRecord::Base.descendants` lists them: the
|
|
236
|
+
# one definition for whatever must see every model in a process that
|
|
237
|
+
# does not eager-load — the admin's model list, and
|
|
238
|
+
# `rake permission_forge:audit` (a single-table subclass's `encrypts`
|
|
239
|
+
# or a model on a table a rule joins is seen only once loaded:
|
|
240
|
+
# PermissionForge::EncryptedAttributes). A failure is logged at debug
|
|
241
|
+
# and skipped: the caller sees the models already loaded.
|
|
242
|
+
#
|
|
243
|
+
# @return [void]
|
|
244
|
+
def eager_load_app_models
|
|
245
|
+
return unless defined?(Rails) && Rails.root && Rails.respond_to?(:autoloaders)
|
|
246
|
+
|
|
247
|
+
eager_load_dir(Rails.autoloaders.main, Rails.root.join('app', 'models').to_s)
|
|
248
|
+
rescue StandardError => e
|
|
249
|
+
Rails.logger&.debug("[PermissionForge] model eager load skipped: #{e.class}")
|
|
250
|
+
end
|
|
205
251
|
|
|
206
|
-
|
|
207
|
-
rescue StandardError => e
|
|
208
|
-
Rails.logger&.debug("[PermissionForge] model eager load skipped: #{e.class}")
|
|
209
|
-
end
|
|
252
|
+
private
|
|
210
253
|
|
|
211
|
-
|
|
212
|
-
|
|
254
|
+
def eager_load_dir(loader, dir)
|
|
255
|
+
loader.eager_load_dir(dir) if File.directory?(dir) && loader.respond_to?(:eager_load_dir)
|
|
256
|
+
end
|
|
213
257
|
end
|
|
214
258
|
end
|
|
215
259
|
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Admin
|
|
5
|
+
# The one definition of the label the admin shows for a model (a
|
|
6
|
+
# Resource): the Resource row's own label when an admin gave it one,
|
|
7
|
+
# else the host application's translation (`activerecord.models.<model>`,
|
|
8
|
+
# through `model_name.human` — never its English fallback), else the
|
|
9
|
+
# constant's name verbatim ("Billing::Invoice"). The plural ("Only show
|
|
10
|
+
# [Customers] where …") is the host's plural translation when it has
|
|
11
|
+
# one, else `permission_forge.ui.resource_plural` around the label:
|
|
12
|
+
# Rails' `pluralize` is English whatever the page's language.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# ModelLabel.for(resource).text # => "Clients" (the row's label), "Kunde" (host), or "Customer"
|
|
16
|
+
# ModelLabel.plural('Customer') # => "Customers" (host translation), else "Customer records"
|
|
17
|
+
#
|
|
18
|
+
# @api private
|
|
19
|
+
class ModelLabel
|
|
20
|
+
class << self
|
|
21
|
+
# @param resource_or_name [Resource, #name, String, Class, ModelCatalog::ModelInfo]
|
|
22
|
+
# @return [FieldLabel::Label] verbatim when the text is the constant's name
|
|
23
|
+
def for(resource_or_name)
|
|
24
|
+
label = row_label(resource_or_name)
|
|
25
|
+
return FieldLabel::Label.new(text: label, verbatim: false) if label
|
|
26
|
+
|
|
27
|
+
name = name_of(resource_or_name)
|
|
28
|
+
translated = host_translation(name)
|
|
29
|
+
translated ? FieldLabel::Label.new(text: translated, verbatim: false) : FieldLabel::Label.new(text: name, verbatim: true)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param name [String, Class, #name] the model
|
|
33
|
+
# @return [String]
|
|
34
|
+
def plural(name)
|
|
35
|
+
host_translation(name_of(name), count: 2) ||
|
|
36
|
+
I18n.t('permission_forge.ui.resource_plural', name: self.for(name).text, count: 2)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The host's translation of the model's name, or nil.
|
|
40
|
+
#
|
|
41
|
+
# @return [String, nil]
|
|
42
|
+
def host_translation(name, count: 1)
|
|
43
|
+
klass = FieldLabel.klass_of(name)
|
|
44
|
+
return nil unless klass.respond_to?(:model_name)
|
|
45
|
+
|
|
46
|
+
value = klass.model_name.human(count: count, default: FieldLabel::MISSING)
|
|
47
|
+
value == FieldLabel::MISSING ? nil : value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def row_label(resource)
|
|
53
|
+
return nil unless resource.respond_to?(:label) && !resource.is_a?(Class)
|
|
54
|
+
|
|
55
|
+
resource.label.to_s.strip.presence
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def name_of(resource_or_name)
|
|
59
|
+
return resource_or_name.name.to_s if resource_or_name.respond_to?(:name) && !resource_or_name.is_a?(String)
|
|
60
|
+
|
|
61
|
+
resource_or_name.to_s
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -11,10 +11,14 @@ module PermissionForge
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
# SQL-backed: searches whichever of the usual name/email columns exist.
|
|
14
|
+
# A column the user model encrypts (PermissionForge::EncryptedAttributes)
|
|
15
|
+
# holds ciphertext, which a LIKE would match by its JSON (`iv` found
|
|
16
|
+
# everyone): it is left out, and a deterministically encrypted one
|
|
17
|
+
# matches the whole query exactly (`where(email: query)` encrypts it).
|
|
14
18
|
class Relation
|
|
15
19
|
# Common person columns; only those the user table has are searched.
|
|
16
|
-
SEARCH_COLUMNS = %w[name
|
|
17
|
-
|
|
20
|
+
SEARCH_COLUMNS = (%w[name] + PersonLabel::EMAIL_ATTRIBUTES.map(&:to_s) +
|
|
21
|
+
%w[full_name display_name first_name last_name firstname lastname username login]).freeze
|
|
18
22
|
# A query of digits also matches the id — up to a bigint's length.
|
|
19
23
|
NUMERIC = /\A\d{1,18}\z/
|
|
20
24
|
|
|
@@ -31,11 +35,12 @@ module PermissionForge
|
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
def search(query)
|
|
34
|
-
|
|
38
|
+
model = @relation.klass
|
|
39
|
+
columns = PermissionForge::SchemaRead.columns_among(model, SEARCH_COLUMNS)
|
|
35
40
|
id = query.to_i if query.match?(NUMERIC)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
Relation.new(@relation.
|
|
41
|
+
condition = word_conditions(PermissionForge::EncryptedAttributes.plain(model, columns), query.downcase, id)
|
|
42
|
+
scopes = [(@relation.where(condition) if condition), *exact_matches(model, columns, query)].compact
|
|
43
|
+
Relation.new(scopes.empty? ? @relation.none : scopes.reduce(:or))
|
|
39
44
|
end
|
|
40
45
|
|
|
41
46
|
def count
|
|
@@ -50,15 +55,22 @@ module PermissionForge
|
|
|
50
55
|
|
|
51
56
|
# Every word must match some column, so "ana kras" finds Ana Krasniqi
|
|
52
57
|
# whether the name lives in one column or in first/last name columns.
|
|
53
|
-
# A numeric query also matches the id.
|
|
58
|
+
# A numeric query also matches the id. nil when nothing can match.
|
|
54
59
|
def word_conditions(columns, needle, id)
|
|
55
60
|
words = needle.split
|
|
56
|
-
matched = words.map { |word| any_column(columns, word) }.compact.reduce(:and)
|
|
57
|
-
return table[:id].eq(id) if matched.nil?
|
|
61
|
+
matched = words.map { |word| any_column(columns, word) }.compact.reduce(:and) if columns.any?
|
|
62
|
+
return id && table[:id].eq(id) if matched.nil?
|
|
58
63
|
|
|
59
64
|
id ? matched.or(table[:id].eq(id)) : matched
|
|
60
65
|
end
|
|
61
66
|
|
|
67
|
+
# The whole query against each deterministically encrypted column, as
|
|
68
|
+
# typed and lower-cased.
|
|
69
|
+
def exact_matches(model, columns, query)
|
|
70
|
+
values = [query, query.downcase].uniq
|
|
71
|
+
PermissionForge::EncryptedAttributes.deterministic(model, columns).map { |column| @relation.where(column => values) }
|
|
72
|
+
end
|
|
73
|
+
|
|
62
74
|
def any_column(columns, word)
|
|
63
75
|
like = "%#{ActiveRecord::Base.sanitize_sql_like(word)}%"
|
|
64
76
|
columns.map { |column| table[column].lower.matches(like) }.reduce(:or)
|
|
@@ -9,7 +9,6 @@ module PermissionForge
|
|
|
9
9
|
# (see PeopleCandidates).
|
|
10
10
|
class PeopleDirectory
|
|
11
11
|
FILTERS = %w[all ending expired muting no_access].freeze
|
|
12
|
-
ENDING_WINDOW_DAYS = 7
|
|
13
12
|
DEFAULT_PER_PAGE = 25
|
|
14
13
|
# Beyond this the offset overflows the database's integer; the page is
|
|
15
14
|
# empty long before.
|
|
@@ -19,9 +18,7 @@ module PermissionForge
|
|
|
19
18
|
|
|
20
19
|
# Assignments whose people a filter keeps ('no_access' keeps everyone else).
|
|
21
20
|
FILTER_ASSIGNMENTS = {
|
|
22
|
-
'ending' =>
|
|
23
|
-
PermissionForge::PermissionSetUser.where(expires_at: Time.current..ENDING_WINDOW_DAYS.days.from_now.end_of_day)
|
|
24
|
-
},
|
|
21
|
+
'ending' => -> { PermissionForge::PermissionSetUser.ending_within },
|
|
25
22
|
'expired' => -> { PermissionForge::PermissionSetUser.expired },
|
|
26
23
|
'muting' => lambda {
|
|
27
24
|
PermissionForge::PermissionSetUser.active_assignments.where(permission_set_id: PermissionForge::PermissionSet.muting.select(:id))
|
|
@@ -55,7 +52,8 @@ module PermissionForge
|
|
|
55
52
|
def search(query: nil, filter: 'all', **paging)
|
|
56
53
|
filter = 'all' unless FILTERS.include?(filter.to_s)
|
|
57
54
|
candidates = apply_query(apply_filter(base_candidates, filter.to_s), query)
|
|
58
|
-
|
|
55
|
+
page = ParamShapes.integer(paging[:page], default: 1, range: 1..).clamp(1, MAX_PAGE) # the one reader: "2abc" is page 1
|
|
56
|
+
paginate(candidates, page, paging.fetch(:per_page, DEFAULT_PER_PAGE))
|
|
59
57
|
end
|
|
60
58
|
|
|
61
59
|
# Counts for the filter chips (respecting the search query).
|
|
@@ -109,6 +107,26 @@ module PermissionForge
|
|
|
109
107
|
(ids.is_a?(Array) ? ids : [ids]).filter_map { |id| cast_one(id, type) }.uniq
|
|
110
108
|
end
|
|
111
109
|
|
|
110
|
+
# The request values that name none of `found` (cast ids, e.g. the
|
|
111
|
+
# people #find_all returned): each cast to the key type when it can
|
|
112
|
+
# be (99, not "99"), else as sent, trimmed and without NUL bytes.
|
|
113
|
+
# Blanks and values that are no id at all (an object, a list) are
|
|
114
|
+
# left out.
|
|
115
|
+
#
|
|
116
|
+
# @return [Array] distinct, in order
|
|
117
|
+
def self.unmatched(ids, found, configuration: PermissionForge.configuration)
|
|
118
|
+
model = configuration.user_model
|
|
119
|
+
type = model.type_for_attribute(model.primary_key)
|
|
120
|
+
(ids.is_a?(Array) ? ids : [ids]).filter_map do |id|
|
|
121
|
+
next unless id.is_a?(String) || id.is_a?(Integer)
|
|
122
|
+
|
|
123
|
+
cast = cast_one(id, type)
|
|
124
|
+
next id.to_s.delete("\0").strip.presence if cast.nil?
|
|
125
|
+
|
|
126
|
+
cast unless found.include?(cast)
|
|
127
|
+
end.uniq
|
|
128
|
+
end
|
|
129
|
+
|
|
112
130
|
def self.cast_one(id, type)
|
|
113
131
|
return nil unless id.is_a?(String) || id.is_a?(Integer)
|
|
114
132
|
|
|
@@ -14,29 +14,20 @@ module PermissionForge
|
|
|
14
14
|
PermissionForge::PermissionSet.create!(attrs.merge(is_active: true, is_editable: true))
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
# An editable copy
|
|
17
|
+
# An editable copy, named "<name> (copy)" (numbered when taken) unless
|
|
18
|
+
# a name is given. A source in a locked Permission Set Group is copied
|
|
18
19
|
# outside it: the copy would change the locked group's list of sets.
|
|
19
|
-
def self.duplicate(source, name:)
|
|
20
|
+
def self.duplicate(source, name: PermissionForge::PermissionSet.copy_name(source.name))
|
|
20
21
|
attrs = { name: name, is_editable: true }
|
|
21
22
|
attrs[:permission_set_group] = nil if EditableLock.locked?(source.permission_set_group)
|
|
22
23
|
clone_unlocked(source, attrs)
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
# Case-insensitive.
|
|
26
|
-
def self.name_taken?(name)
|
|
27
|
-
PermissionForge::PermissionSet.where('LOWER(name) = ?', name.downcase).exists?
|
|
28
|
-
end
|
|
26
|
+
# Case-insensitive, in the current tenant (PermissionSet.name_taken?).
|
|
27
|
+
def self.name_taken?(name) = PermissionForge::PermissionSet.name_taken?(name)
|
|
29
28
|
|
|
30
29
|
# `base`, else "`base` 2", "`base` 3", ... — the first free name.
|
|
31
|
-
def self.unique_name(base)
|
|
32
|
-
candidate = base
|
|
33
|
-
suffix = 2
|
|
34
|
-
while name_taken?(candidate)
|
|
35
|
-
candidate = "#{base} #{suffix}"
|
|
36
|
-
suffix += 1
|
|
37
|
-
end
|
|
38
|
-
candidate
|
|
39
|
-
end
|
|
30
|
+
def self.unique_name(base) = PermissionForge::PermissionSet.unique_name(base)
|
|
40
31
|
|
|
41
32
|
# Every copied row is created unlocked, so its create event holds the
|
|
42
33
|
# state it has (no second, unaudited write).
|
|
@@ -5,24 +5,70 @@ module PermissionForge
|
|
|
5
5
|
# Display name / email / initials for a host-app user in the admin UI.
|
|
6
6
|
#
|
|
7
7
|
# Honors config.user_label_formatter for the name. Otherwise tries :name,
|
|
8
|
-
# :full_name, :display_name, then composes first_name/last_name (or firstname/lastname)
|
|
8
|
+
# :full_name, :display_name, then composes first_name/last_name (or firstname/lastname), then
|
|
9
|
+
# the email (EMAIL_ATTRIBUTES: `email`, or Rails 8's `email_address`). Attribute
|
|
9
10
|
# reads go raw-column-first (read_attribute_before_type_cast) so
|
|
10
11
|
# MaskedAttributes on the User model never garbles admin lists; virtual
|
|
11
|
-
# attributes and blank columns fall back to the public reader.
|
|
12
|
+
# attributes and blank columns fall back to the public reader. A column
|
|
13
|
+
# the model encrypts (`encrypts :email`) is read decrypted through its
|
|
14
|
+
# attribute type (PermissionForge::EncryptedAttributes), never as the
|
|
15
|
+
# ciphertext it stores — for the admin's display, which is never cached.
|
|
16
|
+
# `decrypt: false` (the default profile_builder, whose profile is cached
|
|
17
|
+
# with the permission hash in the cache store) reads such a column as
|
|
18
|
+
# nil instead: neither its ciphertext nor its plaintext. Nor does it ask
|
|
19
|
+
# a public reader for a name or an email ({LABEL_ATTRIBUTES}) while the
|
|
20
|
+
# model encrypts any of them: a `name` composed from encrypted columns
|
|
21
|
+
# would hand over their plaintext.
|
|
12
22
|
class PersonLabel
|
|
13
23
|
NAME_METHODS = %i[name full_name display_name].freeze
|
|
14
|
-
|
|
15
|
-
|
|
24
|
+
# The one definition of "the user's email attribute", in order: `email`,
|
|
25
|
+
# then `email_address` (the column Rails 8's authentication generator
|
|
26
|
+
# creates). Labels, people search, bulk matching and the default
|
|
27
|
+
# profile_builder all read it from here.
|
|
28
|
+
EMAIL_ATTRIBUTES = %i[email email_address].freeze
|
|
29
|
+
# The split name attributes composed into a name.
|
|
30
|
+
NAME_PARTS = %i[first_name last_name firstname lastname].freeze
|
|
31
|
+
# Every attribute a name or an email is read from.
|
|
32
|
+
LABEL_ATTRIBUTES = (NAME_METHODS + NAME_PARTS + EMAIL_ATTRIBUTES).freeze
|
|
33
|
+
|
|
34
|
+
# @param user [Object]
|
|
35
|
+
# @param configuration [PermissionForge::Configuration]
|
|
36
|
+
# @param decrypt [Boolean] false: an encrypted column reads as nil
|
|
37
|
+
def initialize(user, configuration: PermissionForge.configuration, decrypt: true)
|
|
16
38
|
@user = user
|
|
17
39
|
@configuration = configuration
|
|
40
|
+
@decrypt = decrypt
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A user attribute read raw-column-first (read_attribute_before_type_cast,
|
|
44
|
+
# past MaskedAttributes), the public reader for a virtual attribute or a
|
|
45
|
+
# blank column; nil when the user has neither. The one definition for the
|
|
46
|
+
# admin's labels (pf_raw_user_attribute) and the default profile_builder.
|
|
47
|
+
#
|
|
48
|
+
# @param user [Object]
|
|
49
|
+
# @param attr [Symbol, String]
|
|
50
|
+
# @return [Object, nil]
|
|
51
|
+
def self.raw_value(user, attr)
|
|
52
|
+
new(user).raw_value(attr)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @see .raw_value
|
|
56
|
+
def raw_value(attr)
|
|
57
|
+
attr_name = attr.to_s
|
|
58
|
+
if readable_column?(attr_name)
|
|
59
|
+
value = column_value(attr_name)
|
|
60
|
+
return value if value.to_s.strip.present? || encrypted?(attr_name)
|
|
61
|
+
end
|
|
62
|
+
@user.try(attr) unless withheld_reader?(attr_name)
|
|
18
63
|
end
|
|
19
64
|
|
|
20
65
|
def name
|
|
21
66
|
@name ||= formatted_name || default_name || email || fallback
|
|
22
67
|
end
|
|
23
68
|
|
|
69
|
+
# The first EMAIL_ATTRIBUTES value present, stripped.
|
|
24
70
|
def email
|
|
25
|
-
@email ||=
|
|
71
|
+
@email ||= first_present(EMAIL_ATTRIBUTES)
|
|
26
72
|
end
|
|
27
73
|
|
|
28
74
|
# Email shown under the name — omitted when the name already is the email.
|
|
@@ -30,8 +76,17 @@ module PermissionForge
|
|
|
30
76
|
email unless email == name
|
|
31
77
|
end
|
|
32
78
|
|
|
79
|
+
# Two letters for the avatar: the first and last word of a name ("AK"),
|
|
80
|
+
# or the first two letters of a single word. A label that is the email
|
|
81
|
+
# takes them from its local part ("dave@acme.test" => "DA"), never from
|
|
82
|
+
# the domain.
|
|
33
83
|
def initials
|
|
34
|
-
initial_letters(
|
|
84
|
+
initial_letters(initials_source.scan(/[[:alnum:]]+/)).join.upcase.presence || '?'
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Whether the label is the "User #<id>" fallback: neither a name nor an email.
|
|
88
|
+
def fallback?
|
|
89
|
+
name == fallback
|
|
35
90
|
end
|
|
36
91
|
|
|
37
92
|
# Lower-cased "name email" for in-memory search.
|
|
@@ -39,12 +94,29 @@ module PermissionForge
|
|
|
39
94
|
"#{name} #{email}".downcase
|
|
40
95
|
end
|
|
41
96
|
|
|
97
|
+
# The first word of a person's name, for sentences ("What Ana can do").
|
|
98
|
+
# A label that is not a name (the email, or the "User #2" fallback) is
|
|
99
|
+
# used whole: its first word would read as a placeholder ("User").
|
|
42
100
|
def first_name
|
|
101
|
+
return name unless personal_name?
|
|
102
|
+
|
|
43
103
|
name.to_s.split(/\s+/).first.presence || name
|
|
44
104
|
end
|
|
45
105
|
|
|
46
106
|
private
|
|
47
107
|
|
|
108
|
+
# Whether the label comes from a name (formatter or name attributes),
|
|
109
|
+
# not from the email or the id fallback.
|
|
110
|
+
def personal_name?
|
|
111
|
+
label = name
|
|
112
|
+
label != email && label != fallback
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def initials_source
|
|
116
|
+
label = name.to_s
|
|
117
|
+
label == email ? label.split('@').first.to_s : label
|
|
118
|
+
end
|
|
119
|
+
|
|
48
120
|
# First letters of the first and last word, or the first two letters of a single word.
|
|
49
121
|
def initial_letters(words)
|
|
50
122
|
words.size > 1 ? [words.first[0], words.last[0]] : [words.first.to_s[0, 2]]
|
|
@@ -58,11 +130,15 @@ module PermissionForge
|
|
|
58
130
|
end
|
|
59
131
|
|
|
60
132
|
def default_name
|
|
61
|
-
NAME_METHODS.
|
|
62
|
-
|
|
133
|
+
first_present(NAME_METHODS) || composed(*NAME_PARTS.first(2)) || composed(*NAME_PARTS.last(2))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def first_present(attrs)
|
|
137
|
+
attrs.each do |attr|
|
|
138
|
+
value = raw_value(attr).to_s.strip
|
|
63
139
|
return value unless value.empty?
|
|
64
140
|
end
|
|
65
|
-
|
|
141
|
+
nil
|
|
66
142
|
end
|
|
67
143
|
|
|
68
144
|
def composed(first, last)
|
|
@@ -73,13 +149,26 @@ module PermissionForge
|
|
|
73
149
|
I18n.t('permission_forge.shared.user_fallback', id: @user.id)
|
|
74
150
|
end
|
|
75
151
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
152
|
+
# The stored value; decrypted for an encrypted column (nil when this
|
|
153
|
+
# application cannot decrypt it: the reader would raise), nil for one
|
|
154
|
+
# under `decrypt: false`.
|
|
155
|
+
def column_value(attr_name)
|
|
156
|
+
value = @user.read_attribute_before_type_cast(attr_name)
|
|
157
|
+
return value if value.nil? || !encrypted?(attr_name)
|
|
158
|
+
|
|
159
|
+
PermissionForge::EncryptedAttributes.decrypt(@user.class, attr_name, value) if @decrypt
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def encrypted?(attr_name)
|
|
163
|
+
PermissionForge::EncryptedAttributes.encrypted?(@user.class, attr_name)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Under `decrypt: false`, a name or email attribute's public reader is
|
|
167
|
+
# not asked while the model encrypts any LABEL_ATTRIBUTES: the reader
|
|
168
|
+
# may read them decrypted.
|
|
169
|
+
def withheld_reader?(attr_name)
|
|
170
|
+
!@decrypt && LABEL_ATTRIBUTES.include?(attr_name.to_sym) &&
|
|
171
|
+
LABEL_ATTRIBUTES.any? { |attribute| encrypted?(attribute) }
|
|
83
172
|
end
|
|
84
173
|
|
|
85
174
|
# True when attr_name is a loaded column on an AR-like user (guards
|