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
|
@@ -44,7 +44,7 @@ module PermissionForge
|
|
|
44
44
|
example: 'Rails.application.credentials.dig(:permission_forge, :password)'),
|
|
45
45
|
d(:base_controller_class, 'admin_access', :class, example: "'::AdminController'"),
|
|
46
46
|
d(:admin_assignment_authorization, 'admin_access', :hook,
|
|
47
|
-
example: '->(controller, target, user_ids, action) {
|
|
47
|
+
example: '->(controller, target, user_ids, action) { PermissionForge.current_user&.admin? }'),
|
|
48
48
|
|
|
49
49
|
d(:user_class, 'people', :class, example: "'User'"),
|
|
50
50
|
d(:current_user_resolver, 'people', :hook, example: '-> { ::Current.user if defined?(::Current) && ::Current.respond_to?(:user) }'),
|
|
@@ -69,18 +69,21 @@ module PermissionForge
|
|
|
69
69
|
|
|
70
70
|
d(:auto_apply_masked_attributes, 'masked_attributes', :enum, options: %i[auto list off]),
|
|
71
71
|
d(:masked_write_protection, 'masked_attributes', :enum, options: %i[skip raise off]),
|
|
72
|
-
d(:masked_attributes_nil_user_strategy, 'masked_attributes', :enum,
|
|
72
|
+
d(:masked_attributes_nil_user_strategy, 'masked_attributes', :enum,
|
|
73
|
+
options: Configuration::StrategyValues::ALLOWED[:masked_attributes_nil_user_strategy]),
|
|
73
74
|
d(:hidden_attribute_value, 'masked_attributes', :str, nil_label: :nil),
|
|
74
75
|
d(:omit_hidden_in_attributes_hash, 'masked_attributes', :bool),
|
|
75
76
|
d(:mask_virtual_attributes, 'masked_attributes', :enum, options: %i[off auto list]),
|
|
76
77
|
|
|
77
78
|
d(:nil_user_strategy, 'record_rules', :enum, options: %i[bypass deny raise]),
|
|
79
|
+
d(:no_read_access_strategy, 'record_rules', :enum, options: Configuration::StrategyValues::ALLOWED[:no_read_access_strategy]),
|
|
78
80
|
d(:on_rule_bypass, 'record_rules', :hook, example: '->(user:, resource:, reason:) { Rails.logger.info(...) }'),
|
|
79
81
|
d(:auto_query_scoping, 'record_rules', :enum, options: %i[off filter_only], ladder: 'enforce'),
|
|
80
82
|
d(:unscoped_query_detection, 'record_rules', :enum, options: %i[off log raise], ladder: 'detect'),
|
|
81
83
|
d(:unscoped_query_detection_environments, 'record_rules', :list),
|
|
82
84
|
d(:database_row_level_security, 'record_rules', :bool, ladder: 'database'),
|
|
83
85
|
d(:rls_session_variable, 'record_rules', :str),
|
|
86
|
+
d(:rule_secret_column_patterns, 'record_rules', :list),
|
|
84
87
|
|
|
85
88
|
d(:controller_action_discovery, 'controller_actions', :bool),
|
|
86
89
|
d(:controller_action_resolver, 'controller_actions', :hook,
|
|
@@ -111,7 +114,8 @@ module PermissionForge
|
|
|
111
114
|
d(:audit_user_class, 'audit_trail', :class, example: "'User'"),
|
|
112
115
|
d(:audit_events, 'audit_trail', :enum, options: %i[auto true false]),
|
|
113
116
|
d(:audit_actor_resolver, 'audit_trail', :hook, nil_label: :default,
|
|
114
|
-
example: '->(controller) {
|
|
117
|
+
example: '->(controller) { Session.find_by(id: ' \
|
|
118
|
+
'controller.request.cookie_jar.signed[:session_id])&.user }'),
|
|
115
119
|
d(:audit_request_context, 'audit_trail', :hook, nil_label: :default,
|
|
116
120
|
example: '->(controller) { { request_id: controller.request.request_id, ' \
|
|
117
121
|
'ip: controller.request.remote_ip } }'),
|
|
@@ -121,20 +125,21 @@ module PermissionForge
|
|
|
121
125
|
d(:audit_log_subscriber, 'audit_trail', :bool),
|
|
122
126
|
d(:audit_retention_period, 'audit_trail', :num, unit: :seconds, nil_label: :nil),
|
|
123
127
|
d(:audit_log_authorization, 'audit_trail', :hook, nil_label: :default,
|
|
124
|
-
example: '->(controller) {
|
|
128
|
+
example: '->(controller) { PermissionForge.current_user&.auditor? == true }'),
|
|
125
129
|
|
|
126
130
|
d(:tenant_model, 'multi_tenancy', :class, nil_label: :disabled, example: "'Account'"),
|
|
127
131
|
d(:tenant_column, 'multi_tenancy', :str, nil_label: :disabled),
|
|
128
132
|
|
|
129
133
|
d(:api_enabled, 'admin_api', :bool),
|
|
130
|
-
d(:api_auth, 'admin_api', :hook, example: '->(controller) {
|
|
134
|
+
d(:api_auth, 'admin_api', :hook, example: '->(controller) { PermissionForge.current_user&.admin? }'),
|
|
131
135
|
d(:api_base_controller_class, 'admin_api', :class, example: "'::Api::BaseController'"),
|
|
132
136
|
d(:api_action_authorization, 'admin_api', :hook,
|
|
133
|
-
example: '->(controller, action, record) {
|
|
137
|
+
example: '->(controller, action, record) { PermissionForge.current_user&.admin? }'),
|
|
134
138
|
d(:api_assignment_authorization, 'admin_api', :hook,
|
|
135
|
-
example: '->(controller, permission_set, user_ids) {
|
|
139
|
+
example: '->(controller, permission_set, user_ids) { PermissionForge.current_user&.admin? }'),
|
|
136
140
|
d(:api_require_authorization, 'admin_api', :bool),
|
|
137
141
|
d(:api_stats_require_admin, 'admin_api', :bool),
|
|
142
|
+
d(:api_stats_authorization, 'admin_api', :hook, example: '->(controller) { PermissionForge.current_user&.admin? }'),
|
|
138
143
|
d(:api_record_identifier, 'admin_api', :enum, options: %i[uuid id]),
|
|
139
144
|
d(:api_default_per_page, 'admin_api', :num, unit: :records),
|
|
140
145
|
d(:api_max_per_page, 'admin_api', :num, unit: :records),
|
|
@@ -179,23 +184,42 @@ module PermissionForge
|
|
|
179
184
|
|
|
180
185
|
:warn
|
|
181
186
|
},
|
|
182
|
-
|
|
187
|
+
# :warn lets an unconfigured field through as :pass_through does (it only logs it): the audit's
|
|
188
|
+
# unconfigured_fields_pass and Home's privacy card count both.
|
|
189
|
+
'unconfigured_field_strategy' => ->(config) { %w[pass_through warn].include?(config.unconfigured_field_strategy.to_s) ? :warn : nil },
|
|
183
190
|
'masked_write_protection' => ->(config) { config.masked_write_protection.to_s == 'off' ? :warn : :ok },
|
|
184
191
|
'audit_trail_enabled' => ->(config) { config.audit_trail_enabled ? :ok : :warn },
|
|
185
192
|
'audit_events' => ->(config) { AuditLog.recording_problem(config) ? :warn : :ok },
|
|
186
193
|
'gdpr_defaults' => ->(config) { config.gdpr_defaults ? :ok : nil },
|
|
187
194
|
'nil_user_strategy' => ->(config) { config.nil_user_strategy.to_s == 'bypass' ? nil : :ok },
|
|
188
|
-
'
|
|
195
|
+
'no_read_access_strategy' => ->(config) { config.deny_record_reads_without_read_access? ? :ok : nil },
|
|
196
|
+
'masked_attributes_nil_user_strategy' => ->(config) { config.hide_masked_attributes_without_user_in_requests? ? :ok : nil },
|
|
189
197
|
'extra_fields_default_access' => ->(config) { config.grant_extra_fields_by_default? ? nil : :ok },
|
|
190
|
-
|
|
198
|
+
# :allow is the opt-out from the :block default: the audit's unconfigured_actions_allowed.
|
|
199
|
+
'unconfigured_action_strategy' => ->(config) { config.allow_unconfigured_actions? ? :warn : :ok },
|
|
191
200
|
'action_guard_nil_user_strategy' => ->(config) { config.action_guard_nil_user_bypass? ? nil : :ok }
|
|
192
201
|
}.freeze
|
|
193
202
|
|
|
203
|
+
# Risk rules that also read the data: key => ->(config, attention) { ... },
|
|
204
|
+
# `attention` being the page's AttentionItems. auto_query_scoping off is a
|
|
205
|
+
# risk only while record rules are in force that it leaves to find_by /
|
|
206
|
+
# permission_scoped: the rules the Home "Record rules" card lists
|
|
207
|
+
# (AttentionItems#unenforced_rules), so both pages warn together.
|
|
208
|
+
DATA_RISKS = {
|
|
209
|
+
'auto_query_scoping' => lambda { |config, attention|
|
|
210
|
+
next :ok if config.auto_query_scoping_enabled?
|
|
211
|
+
|
|
212
|
+
attention.unenforced_rules.any? ? :warn : nil
|
|
213
|
+
}
|
|
214
|
+
}.freeze
|
|
215
|
+
|
|
194
216
|
RECOMMENDED = {
|
|
195
217
|
'conditional_sets_strict_mode' => 'true',
|
|
218
|
+
'auto_query_scoping' => ':filter_only',
|
|
196
219
|
'api_require_authorization' => 'true',
|
|
197
220
|
'admin_tenantless_access' => ':deny',
|
|
198
221
|
'unconfigured_field_strategy' => ':block',
|
|
222
|
+
'unconfigured_action_strategy' => ':block',
|
|
199
223
|
'masked_write_protection' => ':skip',
|
|
200
224
|
'audit_trail_enabled' => 'true',
|
|
201
225
|
'audit_events' => ':auto',
|
|
@@ -206,10 +230,13 @@ module PermissionForge
|
|
|
206
230
|
name.to_s.delete_prefix('::') == 'ActionController::Base'
|
|
207
231
|
end
|
|
208
232
|
|
|
209
|
-
|
|
233
|
+
# @param attention [AttentionItems, nil] the page's attention items
|
|
234
|
+
# (shared with the Home badge); built on first use when nil
|
|
235
|
+
def initialize(configuration: PermissionForge.configuration, values: SettingsValues.new, attention: nil)
|
|
210
236
|
@configuration = configuration
|
|
211
237
|
@defaults = Configuration.new
|
|
212
238
|
@values = values
|
|
239
|
+
@attention = attention
|
|
213
240
|
end
|
|
214
241
|
|
|
215
242
|
def rows
|
|
@@ -233,6 +260,20 @@ module PermissionForge
|
|
|
233
260
|
rows.count(&:ok?)
|
|
234
261
|
end
|
|
235
262
|
|
|
263
|
+
# What Home lists as needing attention (AttentionItems#total, the
|
|
264
|
+
# page's own items): the health summary says it, so Settings never
|
|
265
|
+
# calls the setup good while Home warns (R1 #93).
|
|
266
|
+
#
|
|
267
|
+
# @return [Integer]
|
|
268
|
+
def attention_count
|
|
269
|
+
attention.total
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Nothing to fix: no risky setting and nothing on Home.
|
|
273
|
+
def healthy?
|
|
274
|
+
risky_rows.empty? && attention_count.zero?
|
|
275
|
+
end
|
|
276
|
+
|
|
236
277
|
def recommended_value(key)
|
|
237
278
|
row(key)&.recommended
|
|
238
279
|
end
|
|
@@ -250,7 +291,18 @@ module PermissionForge
|
|
|
250
291
|
key = definition.key
|
|
251
292
|
value = @values.normalize(definition, read(@configuration, key))
|
|
252
293
|
Row.new(definition: definition, value: value, default: @values.normalize(definition, read(@defaults, key)),
|
|
253
|
-
hook_state: (value if definition.type == :hook), risk:
|
|
294
|
+
hook_state: (value if definition.type == :hook), risk: risk_of(key))
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def risk_of(key)
|
|
298
|
+
data_risk = DATA_RISKS[key]
|
|
299
|
+
return data_risk.call(@configuration, attention) if data_risk
|
|
300
|
+
|
|
301
|
+
RISKS[key]&.call(@configuration)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def attention
|
|
305
|
+
@attention ||= AttentionItems.new(configuration: @configuration)
|
|
254
306
|
end
|
|
255
307
|
|
|
256
308
|
def read(config, key)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
module Admin
|
|
5
|
+
# The one registry of the words and glyphs of every state the admin shows:
|
|
6
|
+
# a field's configured level, an effective outcome, a CRUD cell, an action
|
|
7
|
+
# state, a lock and an actor kind. A state is a [kind, state] pair; its
|
|
8
|
+
# word is the locale key `ui.states.<kind>.<state>` and its glyph is
|
|
9
|
+
# here, so a page never pairs a glyph with a word of its own (R4 #52:
|
|
10
|
+
# "⊘ Hidden by muting" in one place, "∅ Hidden" for the same field in the
|
|
11
|
+
# next). The helpers that draw a state (pf_state_badge, pf_state_word,
|
|
12
|
+
# pf_level_badge, pf_state_cell, pf_lock_badge, ...) read it; no template
|
|
13
|
+
# looks a state key up itself (release_readiness/state_key_guard_test.rb),
|
|
14
|
+
# and two keys with the same English word in the label namespaces fail
|
|
15
|
+
# i18n_test.rb.
|
|
16
|
+
#
|
|
17
|
+
# Two kinds keep their words where they were until the 0.16 merge
|
|
18
|
+
# (NAMESPACES): the CRUD cell (shared.crud_cell) and the action states
|
|
19
|
+
# (resource_actions.states). A state worded by another kind's key says so
|
|
20
|
+
# in SHARED (one word, one key).
|
|
21
|
+
#
|
|
22
|
+
# StateLabel.for(:field_level, 'masked').to_s # => "•• Masked"
|
|
23
|
+
# StateLabel.word(:actor, :unknown) # => "Unknown"
|
|
24
|
+
#
|
|
25
|
+
# @api private
|
|
26
|
+
module StateLabel
|
|
27
|
+
# kind => { state => glyph }. A kind whose states have no glyph of their
|
|
28
|
+
# own (a lock draws an icon, an actor is a name) maps them to nil.
|
|
29
|
+
GLYPHS = {
|
|
30
|
+
field_level: { 'hidden' => '∅', 'masked' => '••', 'read' => '◉', 'edit' => '✎',
|
|
31
|
+
'leave' => '–', 'mask' => '••', 'hide' => '⊘', 'none' => '–' },
|
|
32
|
+
effective: { 'granted' => '✓', 'blocked' => '⊘', 'none' => '–', 'hidden_by_muting' => '⊘' },
|
|
33
|
+
crud: { 'allowed' => '✓', 'no' => '–', 'muted' => '⊘', 'unchanged' => '–', 'needs_read' => '–' },
|
|
34
|
+
action: { 'permitted' => '✓', 'not_permitted' => '–', 'blocked' => '⊘', 'unchanged' => '–', 'none' => '–' },
|
|
35
|
+
lock: { 'locked' => nil },
|
|
36
|
+
actor: { 'api' => nil, 'basic_auth' => nil, 'system' => nil, 'unknown' => nil, 'user' => nil }
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# Kinds whose words stay in their own namespace until 0.16 (allow-listed
|
|
40
|
+
# in i18n_test.rb's label lint).
|
|
41
|
+
NAMESPACES = { crud: 'permission_forge.shared.crud_cell', action: 'permission_forge.resource_actions.states' }.freeze
|
|
42
|
+
# Locale keys under a namespace that are not the state's own name.
|
|
43
|
+
KEY_NAMES = { [:crud, 'needs_read'] => 'needs_read_short' }.freeze
|
|
44
|
+
# A state worded by another kind's key: an action no set configures is
|
|
45
|
+
# "Not configured", the field level's word.
|
|
46
|
+
SHARED = { [:action, 'none'] => [:field_level, 'none'] }.freeze
|
|
47
|
+
|
|
48
|
+
# One state: its glyph (nil when the kind has none) and its word.
|
|
49
|
+
Label = Data.define(:kind, :state, :glyph, :word) do
|
|
50
|
+
# "glyph word", or the word alone.
|
|
51
|
+
def to_s = glyph ? "#{glyph} #{word}" : word
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
module_function
|
|
55
|
+
|
|
56
|
+
# @param kind [Symbol] one of GLYPHS' kinds
|
|
57
|
+
# @param state [String, Symbol]
|
|
58
|
+
# @param default [String, nil] the word for a state the registry does
|
|
59
|
+
# not have (an actor type outside the catalogue is shown as stored);
|
|
60
|
+
# without it such a state raises
|
|
61
|
+
# @return [Label]
|
|
62
|
+
# @raise [ArgumentError] an unknown kind, or an unknown state without `default`
|
|
63
|
+
def for(kind, state, default: nil)
|
|
64
|
+
kind = kind.to_sym
|
|
65
|
+
state = state.to_s
|
|
66
|
+
glyphs = GLYPHS.fetch(kind) { raise ArgumentError, "Unknown state kind: #{kind.inspect}" }
|
|
67
|
+
unless glyphs.key?(state)
|
|
68
|
+
raise ArgumentError, "Unknown #{kind} state: #{state.inspect}" if default.nil?
|
|
69
|
+
|
|
70
|
+
return Label.new(kind: kind, state: state, glyph: nil, word: default.to_s)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
Label.new(kind: kind, state: state, glyph: glyphs[state], word: I18n.t(key(kind, state)))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [String] the state's word, in the current locale
|
|
77
|
+
def word(kind, state, default: nil) = self.for(kind, state, default: default).word
|
|
78
|
+
|
|
79
|
+
# @return [String, nil] the state's glyph
|
|
80
|
+
def glyph(kind, state) = GLYPHS.fetch(kind.to_sym).fetch(state.to_s)
|
|
81
|
+
|
|
82
|
+
# @return [String] the locale key of the state's word
|
|
83
|
+
def key(kind, state)
|
|
84
|
+
kind = kind.to_sym
|
|
85
|
+
state = state.to_s
|
|
86
|
+
shared = SHARED[[kind, state]]
|
|
87
|
+
return key(*shared) if shared
|
|
88
|
+
|
|
89
|
+
namespace = NAMESPACES.fetch(kind) { "permission_forge.ui.states.#{kind}" }
|
|
90
|
+
"#{namespace}.#{KEY_NAMES.fetch([kind, state], state)}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [Array<Array(Symbol, String)>] every [kind, state] of the registry
|
|
94
|
+
def states = GLYPHS.flat_map { |kind, glyphs| glyphs.keys.map { |state| [kind, state] } }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack/method_override'
|
|
4
|
+
|
|
5
|
+
module PermissionForge
|
|
6
|
+
# Rack::MethodOverride for the admin screens, in the engine's own
|
|
7
|
+
# middleware stack (PermissionForge::Engine: it runs only for requests the
|
|
8
|
+
# host's router hands to the mounted engine). The admin's edit and delete
|
|
9
|
+
# forms POST with a hidden `_method` (patch, delete), which this turns into
|
|
10
|
+
# the verb the route wants. A full-stack host already has
|
|
11
|
+
# Rack::MethodOverride, and a second pass is a no-op (it only rewrites a
|
|
12
|
+
# POST); an API-only host (`rails new --api`) has none, and every such form
|
|
13
|
+
# answered 404 — the engine brings its own, so the host adds nothing for it
|
|
14
|
+
# (docs/admin-ui.md#api-only-applications names the three session
|
|
15
|
+
# middlewares only).
|
|
16
|
+
#
|
|
17
|
+
# The JSON API's paths (`<mount>/api…`) are left alone by this override:
|
|
18
|
+
# a JSON client says the verb it means. In a full-stack host the
|
|
19
|
+
# application's own Rack::MethodOverride runs before the router for every
|
|
20
|
+
# path, the API's included, as Rails has it (a `_method` field or an
|
|
21
|
+
# `X-HTTP-Method-Override` header on a POST then changes its verb there);
|
|
22
|
+
# api_auth and the API's guards apply either way.
|
|
23
|
+
#
|
|
24
|
+
# It keeps the app it wraps under its own name: Rack::MethodOverride's
|
|
25
|
+
# instance variable is not part of Rack's API.
|
|
26
|
+
#
|
|
27
|
+
# @api private
|
|
28
|
+
class AdminMethodOverride < Rack::MethodOverride
|
|
29
|
+
# The engine's JSON API, below the mount point (PATH_INFO inside the engine).
|
|
30
|
+
API_PATH = %r{\A/api(?:/|\.|\z)}
|
|
31
|
+
|
|
32
|
+
# @param app [#call] the engine's next middleware
|
|
33
|
+
def initialize(app)
|
|
34
|
+
super
|
|
35
|
+
@pf_app = app
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call(env)
|
|
39
|
+
return @pf_app.call(env) if API_PATH.match?(env[Rack::PATH_INFO].to_s)
|
|
40
|
+
|
|
41
|
+
super
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module PermissionForge
|
|
6
|
+
module Api
|
|
7
|
+
# The one declaration of what the Admin JSON API answers: every envelope
|
|
8
|
+
# (a record, a page of records, the rows one request created, an error),
|
|
9
|
+
# the messages each error status carries, which answers have no body,
|
|
10
|
+
# every serializer's payload (its keys and their types), the two results
|
|
11
|
+
# a controller builds itself (stats, a bulk assignment), and what each
|
|
12
|
+
# endpoint answers when it succeeds.
|
|
13
|
+
#
|
|
14
|
+
# { "data": { ... } } single
|
|
15
|
+
# { "data": { ... }, "meta": { "cache": { ... } } } single_with_cache (one assignment)
|
|
16
|
+
# { "data": [ ... ], "meta": { "pagination": { ... } } } collection
|
|
17
|
+
# { "data": [ ... ] } list (generate_fields)
|
|
18
|
+
# { "error": { "status": 404, "messages": "..." } } error
|
|
19
|
+
#
|
|
20
|
+
# A type is a Symbol from TYPES; a trailing `?` allows null
|
|
21
|
+
# (`:string?`). A Hash is an object with exactly those keys, a one-element
|
|
22
|
+
# Array a list of that element. `:foreign_identifier` marks the numeric id
|
|
23
|
+
# of another PermissionForge record, which the API's URLs do not take
|
|
24
|
+
# under `api_record_identifier = :uuid`: its value is unchanged (the
|
|
25
|
+
# record's id), and the key after it, `:foreign_uuid`, names the same
|
|
26
|
+
# record by its uuid (`permission_set_id`, `permission_set_uuid`:
|
|
27
|
+
# Serializers::BaseSerializer.reference) — answer-only: no endpoint
|
|
28
|
+
# takes it, the `_id` key changes a reference.
|
|
29
|
+
#
|
|
30
|
+
# test/permission_forge/api/envelope_schema_test.rb asserts every route,
|
|
31
|
+
# in every error class, against this module and every serializer's keys
|
|
32
|
+
# against PAYLOADS; `rake docs:api` renders the json samples of
|
|
33
|
+
# docs/admin-api.md from it (Docs::ApiSamples).
|
|
34
|
+
#
|
|
35
|
+
# @api private
|
|
36
|
+
module Schema
|
|
37
|
+
UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
38
|
+
# What a value of each type is, as the JSON answer carries it.
|
|
39
|
+
TYPES = {
|
|
40
|
+
integer: ->(value) { value.is_a?(Integer) },
|
|
41
|
+
string: ->(value) { value.is_a?(String) },
|
|
42
|
+
boolean: ->(value) { [true, false].include?(value) },
|
|
43
|
+
uuid: ->(value) { value.is_a?(String) && value.match?(UUID) },
|
|
44
|
+
time: ->(value) { value.is_a?(String) && Check.iso8601?(value) },
|
|
45
|
+
foreign_identifier: ->(value) { value.is_a?(Integer) },
|
|
46
|
+
foreign_uuid: ->(value) { value.is_a?(String) && value.match?(UUID) },
|
|
47
|
+
host_key: ->(value) { value.is_a?(Integer) || value.is_a?(String) },
|
|
48
|
+
host_key_list: ->(value) { value.is_a?(Array) && value.all? { |item| item.is_a?(Integer) || item.is_a?(String) } },
|
|
49
|
+
string_list: ->(value) { value.is_a?(Array) && value.all?(String) },
|
|
50
|
+
object: ->(value) { value.is_a?(Hash) },
|
|
51
|
+
# `messages` of a 422: a field (a parameter, an attribute, `base`)
|
|
52
|
+
# mapped to its messages.
|
|
53
|
+
field_messages: lambda { |value|
|
|
54
|
+
value.is_a?(Hash) && !value.empty? &&
|
|
55
|
+
value.all? { |field, list| field.is_a?(String) && list.is_a?(Array) && !list.empty? && list.all?(String) }
|
|
56
|
+
}
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
# One serializer's payload per entry, named after the serializer
|
|
60
|
+
# (`permission_set` = Serializers::PermissionSetSerializer), keys in
|
|
61
|
+
# the serializer's order.
|
|
62
|
+
PAYLOADS = {
|
|
63
|
+
permission_set: {
|
|
64
|
+
id: :integer, uuid: :uuid, name: :string, description: :string?,
|
|
65
|
+
is_active: :boolean, is_editable: :boolean, is_muting: :boolean,
|
|
66
|
+
requires_session_flag: :string?, allowed_ips: :string?, allowed_ip_list: :string_list,
|
|
67
|
+
allowed_hours_start: :integer?, allowed_hours_end: :integer?,
|
|
68
|
+
permission_set_group_id: :foreign_identifier?, permission_set_group_uuid: :foreign_uuid?, created_at: :time, updated_at: :time
|
|
69
|
+
},
|
|
70
|
+
resource: {
|
|
71
|
+
id: :integer, uuid: :uuid, permission_set_id: :foreign_identifier, permission_set_uuid: :foreign_uuid,
|
|
72
|
+
name: :string, description: :string?, is_active: :boolean, is_editable: :boolean,
|
|
73
|
+
read_access: :boolean, create_access: :boolean, edit_access: :boolean, delete_access: :boolean,
|
|
74
|
+
view_all: :boolean, modify_all: :boolean,
|
|
75
|
+
created_at: :time, updated_at: :time
|
|
76
|
+
},
|
|
77
|
+
resource_field: {
|
|
78
|
+
id: :integer, uuid: :uuid, resource_id: :foreign_identifier, resource_uuid: :foreign_uuid,
|
|
79
|
+
name: :string, label: :string, description: :string?, is_active: :boolean, is_editable: :boolean,
|
|
80
|
+
read_access: :boolean, edit_access: :boolean, mask_access: :boolean, mask_strategy: :string,
|
|
81
|
+
created_at: :time, updated_at: :time
|
|
82
|
+
},
|
|
83
|
+
resource_action: {
|
|
84
|
+
id: :integer, uuid: :uuid, resource_id: :foreign_identifier, resource_uuid: :foreign_uuid,
|
|
85
|
+
name: :string, label: :string?, description: :string?, is_permitted: :boolean, is_active: :boolean,
|
|
86
|
+
created_at: :time, updated_at: :time
|
|
87
|
+
},
|
|
88
|
+
permission_set_rule: {
|
|
89
|
+
id: :integer, uuid: :uuid, permission_set_id: :foreign_identifier, permission_set_uuid: :foreign_uuid,
|
|
90
|
+
resource_id: :foreign_identifier, resource_uuid: :foreign_uuid,
|
|
91
|
+
name: :string, failure_message: :string?, join_type: :string?,
|
|
92
|
+
field_name: :string, field_value: :string, field_accessor_path: :string?,
|
|
93
|
+
operator: :string, include_nulls: :boolean, is_active: :boolean, is_editable: :boolean,
|
|
94
|
+
created_at: :time, updated_at: :time
|
|
95
|
+
},
|
|
96
|
+
permission_set_user: {
|
|
97
|
+
id: :integer, uuid: :uuid, permission_set_id: :foreign_identifier, permission_set_uuid: :foreign_uuid,
|
|
98
|
+
user_id: :host_key, expires_at: :time?, created_at: :time, updated_at: :time
|
|
99
|
+
},
|
|
100
|
+
audit_event: {
|
|
101
|
+
id: :integer, uuid: :uuid, occurred_at: :time, event: :string, category: :string,
|
|
102
|
+
actor_type: :string, actor_id: :string?, actor_label: :string?, source: :string,
|
|
103
|
+
record_type: :string?, record_id: :string?, record_uuid: :string?, record_label: :string?,
|
|
104
|
+
permission_set_id: :foreign_identifier?, permission_set_uuid: :string?, subject_user_id: :string?,
|
|
105
|
+
data: :object, reason: :string?, request_id: :string?, ip: :string?, correlation_id: :string?, created_at: :time
|
|
106
|
+
},
|
|
107
|
+
permission_set_group: {
|
|
108
|
+
id: :integer, uuid: :uuid, name: :string, description: :string?, is_active: :boolean, is_editable: :boolean,
|
|
109
|
+
created_at: :time, updated_at: :time
|
|
110
|
+
},
|
|
111
|
+
permission_set_group_user: {
|
|
112
|
+
id: :integer, uuid: :uuid, permission_set_group_id: :foreign_identifier, permission_set_group_uuid: :foreign_uuid,
|
|
113
|
+
user_id: :host_key, created_at: :time, updated_at: :time
|
|
114
|
+
}
|
|
115
|
+
}.freeze
|
|
116
|
+
|
|
117
|
+
# Cache::Outcome as the API answers it: what one change did to the
|
|
118
|
+
# cached permissions (a bulk assignment, one assignment).
|
|
119
|
+
CACHE = { confirmed: :boolean?, process_local: :boolean, unconfirmed: :integer?, in_request: :boolean }.freeze
|
|
120
|
+
|
|
121
|
+
# The payloads a controller builds itself (no serializer).
|
|
122
|
+
RESULTS = {
|
|
123
|
+
stats: {
|
|
124
|
+
permission_sets_count: :integer, active_sets_count: :integer, resources_count: :integer,
|
|
125
|
+
rules_count: :integer, groups_count: :integer, users_count: :integer
|
|
126
|
+
},
|
|
127
|
+
# Services::BulkPermissionSetAssignment's counts, its Cache::Outcome
|
|
128
|
+
# and the ids that named nobody. `invalidated` is null inside a
|
|
129
|
+
# caller's transaction.
|
|
130
|
+
bulk_assignment: {
|
|
131
|
+
assigned: :integer, revoked: :integer, invalidated: :integer?,
|
|
132
|
+
cache: CACHE,
|
|
133
|
+
skipped: :host_key_list
|
|
134
|
+
}
|
|
135
|
+
}.freeze
|
|
136
|
+
|
|
137
|
+
PAGINATION = { page: :integer, per_page: :integer, total_count: :integer }.freeze
|
|
138
|
+
|
|
139
|
+
# The success envelopes, each around a payload spec.
|
|
140
|
+
ENVELOPES = {
|
|
141
|
+
single: ->(payload) { { data: payload } },
|
|
142
|
+
# One assignment made or revoked, and its Cache::Outcome.
|
|
143
|
+
single_with_cache: ->(payload) { { data: payload, meta: { cache: CACHE } } },
|
|
144
|
+
collection: ->(payload) { { data: [payload], meta: { pagination: PAGINATION } } },
|
|
145
|
+
list: ->(payload) { { data: [payload] } }
|
|
146
|
+
}.freeze
|
|
147
|
+
|
|
148
|
+
# What `messages` is under each error status. A 422 maps fields to
|
|
149
|
+
# their messages; the others carry one sentence.
|
|
150
|
+
ERRORS = { 400 => :string, 401 => :string, 403 => :string, 404 => :string, 422 => :field_messages }.freeze
|
|
151
|
+
|
|
152
|
+
# The answers without a body, and when: a delete (204), the 401 while
|
|
153
|
+
# config.api_auth is not set, the 404 while config.api_enabled is off
|
|
154
|
+
# (it does not reveal that an API exists under the mount).
|
|
155
|
+
BODILESS = { 204 => :deleted, 401 => :api_auth_not_set, 404 => :api_disabled }.freeze
|
|
156
|
+
|
|
157
|
+
# What an endpoint answers when it succeeds: its status, envelope and
|
|
158
|
+
# payload (PAYLOADS or RESULTS); the catch-all is always its 404.
|
|
159
|
+
Endpoint = Data.define(:status, :envelope, :payload)
|
|
160
|
+
|
|
161
|
+
crud = lambda do |name|
|
|
162
|
+
{ 'index' => Endpoint.new(200, :collection, name), 'show' => Endpoint.new(200, :single, name),
|
|
163
|
+
'create' => Endpoint.new(201, :single, name), 'update' => Endpoint.new(200, :single, name),
|
|
164
|
+
'destroy' => Endpoint.new(204, nil, nil) }
|
|
165
|
+
end
|
|
166
|
+
prefixed = ->(controller, actions) { actions.transform_keys { |action| "#{controller}##{action}" } }
|
|
167
|
+
|
|
168
|
+
# Keyed by "controller#action", the controller under permission_forge/api/.
|
|
169
|
+
ENDPOINTS = {
|
|
170
|
+
'dashboard#show' => Endpoint.new(200, :single, :stats),
|
|
171
|
+
'audit_events#index' => Endpoint.new(200, :collection, :audit_event),
|
|
172
|
+
'audit_events#show' => Endpoint.new(200, :single, :audit_event),
|
|
173
|
+
**prefixed.call('permission_sets', crud.call(:permission_set).merge('clone' => Endpoint.new(201, :single, :permission_set))),
|
|
174
|
+
**prefixed.call('resources', crud.call(:resource).merge('generate_fields' => Endpoint.new(201, :list, :resource_field))),
|
|
175
|
+
**prefixed.call('resource_fields', crud.call(:resource_field)),
|
|
176
|
+
**prefixed.call('resource_actions', crud.call(:resource_action)),
|
|
177
|
+
**prefixed.call('permission_set_rules', crud.call(:permission_set_rule)),
|
|
178
|
+
**prefixed.call('permission_set_users', crud.call(:permission_set_user).slice('index')
|
|
179
|
+
.merge('create' => Endpoint.new(201, :single_with_cache, :permission_set_user),
|
|
180
|
+
'destroy' => Endpoint.new(200, :single_with_cache, :permission_set_user),
|
|
181
|
+
'bulk' => Endpoint.new(200, :single, :bulk_assignment))),
|
|
182
|
+
**prefixed.call('permission_set_groups', crud.call(:permission_set_group)
|
|
183
|
+
.merge('clone' => Endpoint.new(201, :single, :permission_set_group))),
|
|
184
|
+
**prefixed.call('permission_set_group_users', crud.call(:permission_set_group_user).slice('index', 'create', 'destroy')),
|
|
185
|
+
'unmatched_routes#show' => Endpoint.new(404, :error, nil)
|
|
186
|
+
}.freeze
|
|
187
|
+
|
|
188
|
+
module_function
|
|
189
|
+
|
|
190
|
+
# @param name [Symbol] a PAYLOADS or RESULTS entry
|
|
191
|
+
# @return [Hash{Symbol => Symbol, Hash}] the keys and their types
|
|
192
|
+
def payload(name)
|
|
193
|
+
PAYLOADS.fetch(name) { RESULTS.fetch(name) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# @return [Array<Symbol>] the keys of a payload, in order
|
|
197
|
+
def payload_keys(name) = payload(name).keys
|
|
198
|
+
|
|
199
|
+
# @return [Array<Symbol>] the keys of a payload that hold another record's numeric id
|
|
200
|
+
# (each followed by its `:foreign_uuid` key)
|
|
201
|
+
def foreign_identifiers(name)
|
|
202
|
+
payload(name).select { |_key, type| type.is_a?(Symbol) && base_type(type) == :foreign_identifier }.keys
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The spec of a success envelope around a payload.
|
|
206
|
+
def envelope(kind, payload_name)
|
|
207
|
+
ENVELOPES.fetch(kind).call(payload(payload_name))
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The spec of an error answer under `status`.
|
|
211
|
+
def error_envelope(status)
|
|
212
|
+
{ error: { status: [:exactly, status], messages: ERRORS.fetch(status) } }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# An error answer: the body the API renders for `status`.
|
|
216
|
+
#
|
|
217
|
+
# @return [Hash]
|
|
218
|
+
def error_body(status, messages)
|
|
219
|
+
{ 'error' => { 'status' => status, 'messages' => messages } }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# What is wrong with a parsed JSON answer of an endpoint's success.
|
|
223
|
+
#
|
|
224
|
+
# @param endpoint [Endpoint]
|
|
225
|
+
# @param body [Hash, nil] the parsed body (nil when there is none)
|
|
226
|
+
# @return [Array<String>] empty when it matches
|
|
227
|
+
def success_problems(endpoint, body)
|
|
228
|
+
return error_problems(endpoint.status, body) if endpoint.envelope == :error
|
|
229
|
+
return body.nil? ? [] : ["#{endpoint.status}: a body where none is declared"] if endpoint.envelope.nil?
|
|
230
|
+
|
|
231
|
+
problems(body, envelope(endpoint.envelope, endpoint.payload))
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# What is wrong with a parsed JSON error answer under `status`; nil
|
|
235
|
+
# (no body) only where BODILESS declares it.
|
|
236
|
+
#
|
|
237
|
+
# @return [Array<String>]
|
|
238
|
+
def error_problems(status, body)
|
|
239
|
+
return BODILESS.key?(status) ? [] : ["#{status}: no body, and BODILESS does not declare one"] if body.nil?
|
|
240
|
+
return ["#{status}: not an error status of the schema (#{ERRORS.keys.join(', ')})"] unless ERRORS.key?(status)
|
|
241
|
+
|
|
242
|
+
problems(body, error_envelope(status))
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# What is wrong with `value` against `spec`, one message per problem,
|
|
246
|
+
# each with its JSON path (Check).
|
|
247
|
+
#
|
|
248
|
+
# @return [Array<String>]
|
|
249
|
+
def problems(value, spec, path = '$') = Check.problems(value, spec, path)
|
|
250
|
+
|
|
251
|
+
def base_type(type) = type.to_s.delete_suffix('?').to_sym
|
|
252
|
+
|
|
253
|
+
# Walks a parsed answer along a spec (a Hash, a one-element Array, a
|
|
254
|
+
# type Symbol, or [:exactly, value]).
|
|
255
|
+
#
|
|
256
|
+
# @api private
|
|
257
|
+
module Check
|
|
258
|
+
# A value of the answer and its JSON path.
|
|
259
|
+
Node = Data.define(:value, :path) do
|
|
260
|
+
def refused(expected) = ["#{path}: expected #{expected}, got #{value.inspect[0, 60]}"]
|
|
261
|
+
def at(key) = "#{path}.#{key}"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
module_function
|
|
265
|
+
|
|
266
|
+
def problems(value, spec, path)
|
|
267
|
+
node = Node.new(value, path)
|
|
268
|
+
case spec
|
|
269
|
+
when Hash then object_problems(node, spec)
|
|
270
|
+
when Array then spec.first == :exactly ? exact_problems(node, spec.last) : list_problems(node, spec.first)
|
|
271
|
+
else type_problems(node, spec)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def object_problems(node, spec)
|
|
276
|
+
value = node.value
|
|
277
|
+
return node.refused('an object') unless value.is_a?(Hash)
|
|
278
|
+
|
|
279
|
+
keys = value.keys.map(&:to_s)
|
|
280
|
+
key_problems(node, keys, spec.keys.map(&:to_s)) + value_problems(node, spec)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The values of the declared keys the answer has, each against its spec.
|
|
284
|
+
def value_problems(node, spec)
|
|
285
|
+
value = node.value
|
|
286
|
+
spec.flat_map { |key, inner| value.key?(key.to_s) ? problems(value[key.to_s], inner, node.at(key)) : [] }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Keys the answer has and the spec does not, and the reverse.
|
|
290
|
+
def key_problems(node, keys, expected)
|
|
291
|
+
(keys - expected).map { |key| "#{node.at(key)}: not declared in Api::Schema" } +
|
|
292
|
+
(expected - keys).map { |key| "#{node.at(key)}: missing" }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def list_problems(node, spec)
|
|
296
|
+
value = node.value
|
|
297
|
+
return node.refused('a list') unless value.is_a?(Array)
|
|
298
|
+
|
|
299
|
+
value.each_with_index.flat_map { |item, index| problems(item, spec, "#{node.path}[#{index}]") }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def exact_problems(node, expected)
|
|
303
|
+
node.value == expected ? [] : node.refused(expected.inspect)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def type_problems(node, type)
|
|
307
|
+
value = node.value
|
|
308
|
+
return [] if value.nil? && type.to_s.end_with?('?')
|
|
309
|
+
|
|
310
|
+
TYPES.fetch(Schema.base_type(type)).call(value) ? [] : node.refused(type)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def iso8601?(text)
|
|
314
|
+
Time.iso8601(text)
|
|
315
|
+
true
|
|
316
|
+
rescue ArgumentError
|
|
317
|
+
false
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|