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
|
@@ -15,9 +15,10 @@ module PermissionForge
|
|
|
15
15
|
module StrategyValues
|
|
16
16
|
ALLOWED = {
|
|
17
17
|
nil_user_strategy: %i[bypass raise deny],
|
|
18
|
+
no_read_access_strategy: %i[rules_only deny],
|
|
18
19
|
response_filter_nil_user_strategy: %i[bypass block],
|
|
19
20
|
masked_write_protection: %i[skip raise off],
|
|
20
|
-
masked_attributes_nil_user_strategy: %i[bypass hide],
|
|
21
|
+
masked_attributes_nil_user_strategy: %i[bypass hide_in_requests hide],
|
|
21
22
|
action_guard_nil_user_strategy: %i[bypass deny raise],
|
|
22
23
|
admin_tenantless_access: %i[deny allow],
|
|
23
24
|
unconfigured_action_strategy: %i[allow block],
|
|
@@ -27,8 +27,8 @@ module PermissionForge
|
|
|
27
27
|
# virtual attribute or a Ruby method (e.g., `"#{first_name} #{last_name}"`)
|
|
28
28
|
# rather than a real column, so a SQL ORDER BY would fail.
|
|
29
29
|
#
|
|
30
|
-
# The User class must respond to `:name` and `:email` (
|
|
31
|
-
# for display labels. Override to scope or sort however you like:
|
|
30
|
+
# The User class must respond to `:name` and/or `:email` (or Rails 8's
|
|
31
|
+
# `:email_address`), as method or column, for display labels. Override to scope or sort however you like:
|
|
32
32
|
# config.user_list_resolver = -> { User.active.order(:last_name, :first_name) }
|
|
33
33
|
attr_accessor :user_list_resolver
|
|
34
34
|
|
|
@@ -45,8 +45,8 @@ module PermissionForge
|
|
|
45
45
|
# Receives the user, returns a String (the human-readable part).
|
|
46
46
|
# The combobox automatically appends ` #<id>` so the JS can extract the id.
|
|
47
47
|
#
|
|
48
|
-
# Default: tries name, full_name, display_name (in that order), then email
|
|
49
|
-
# then a localized fallback. Override for full control:
|
|
48
|
+
# Default: tries name, full_name, display_name (in that order), then email
|
|
49
|
+
# (or email_address), then a localized fallback. Override for full control:
|
|
50
50
|
# config.user_label_formatter = ->(u) { "#{u.last_name}, #{u.first_name} <#{u.email}>" }
|
|
51
51
|
attr_accessor :user_label_formatter
|
|
52
52
|
|
|
@@ -160,7 +160,8 @@ module PermissionForge
|
|
|
160
160
|
|
|
161
161
|
# Lambda that receives the controller instance and returns true/false for auth.
|
|
162
162
|
# When nil and API enabled, all requests return 401 (safe default).
|
|
163
|
-
# Example
|
|
163
|
+
# Example (PermissionForge.current_user is current_user_resolver's user, set when
|
|
164
|
+
# api_base_controller_class signs the caller in): ->(_controller) { PermissionForge.current_user&.admin? }
|
|
164
165
|
attr_accessor :api_auth
|
|
165
166
|
|
|
166
167
|
# Base controller class for API controllers (default: "::ActionController::Base").
|
|
@@ -179,10 +180,18 @@ module PermissionForge
|
|
|
179
180
|
# Default records per page when per_page param is not provided.
|
|
180
181
|
attr_accessor :api_default_per_page
|
|
181
182
|
|
|
182
|
-
# When true,
|
|
183
|
-
#
|
|
183
|
+
# When true (default), GET /api/stats answers only callers
|
|
184
|
+
# api_stats_authorization allows (403 for everyone else). false: every
|
|
185
|
+
# caller api_auth admits.
|
|
184
186
|
attr_accessor :api_stats_require_admin
|
|
185
187
|
|
|
188
|
+
# Lambda that receives the controller and decides who may read GET
|
|
189
|
+
# /api/stats while api_stats_require_admin is on; a truthy answer allows.
|
|
190
|
+
# When nil, stats are refused (until 0.16 an api_auth that sets @admin =
|
|
191
|
+
# true on the controller still allows, with a deprecation warning).
|
|
192
|
+
# Example: ->(controller) { PermissionForge.current_user&.admin? }
|
|
193
|
+
attr_accessor :api_stats_authorization
|
|
194
|
+
|
|
186
195
|
# Record identifier field for API lookups (default: :uuid).
|
|
187
196
|
# Change to :id if your app uses integer IDs in URLs.
|
|
188
197
|
attr_accessor :api_record_identifier
|
|
@@ -208,8 +217,8 @@ module PermissionForge
|
|
|
208
217
|
|
|
209
218
|
# Lambda for action-level authorization on API mutation endpoints (clone, etc.).
|
|
210
219
|
# Receives (controller, action_name, record) and should return true/false.
|
|
211
|
-
# When nil,
|
|
212
|
-
# Example: ->(controller, action, record) {
|
|
220
|
+
# When nil, every mutation is refused while api_require_authorization is true (the default).
|
|
221
|
+
# Example: ->(controller, action, record) { PermissionForge.current_user&.admin? }
|
|
213
222
|
attr_accessor :api_action_authorization
|
|
214
223
|
|
|
215
224
|
# Lambda for authorization checks on user-to-permission-set assignments
|
|
@@ -299,8 +308,12 @@ module PermissionForge
|
|
|
299
308
|
# Hash of regex => strategy string for GDPR field pattern matching
|
|
300
309
|
attr_reader :gdpr_field_patterns
|
|
301
310
|
|
|
311
|
+
# Not created_at / updated_at (before 0.15 they were here): generated
|
|
312
|
+
# fields give them a row, at most read-only on a granting set
|
|
313
|
+
# (TimestampFields), so the default unconfigured_field_strategy (:block)
|
|
314
|
+
# no longer hides them from a reader who is granted them.
|
|
302
315
|
DEFAULT_IGNORED_FIELDS = %w[
|
|
303
|
-
id
|
|
316
|
+
id token_digest token_last_digits
|
|
304
317
|
created_by updated_by token password encrypted_password
|
|
305
318
|
deleted_at uuid deactivated
|
|
306
319
|
].freeze
|
|
@@ -312,7 +325,7 @@ module PermissionForge
|
|
|
312
325
|
|
|
313
326
|
# Column names that are personal data, with the mask for each. One
|
|
314
327
|
# definition for masking detection (ResourceFieldGenerator,
|
|
315
|
-
# GdprFieldAuditor,
|
|
328
|
+
# GdprFieldAuditor, SensitiveColumns, the security audit) and for
|
|
316
329
|
# Rails' filter_parameters (PermissionForge.gdpr_filter_parameters), which
|
|
317
330
|
# Rails also applies to ActiveRecord::Base.filter_attributes — so a match
|
|
318
331
|
# prints as [FILTERED] in #inspect. A word matches anywhere in the name,
|
|
@@ -323,9 +336,11 @@ module PermissionForge
|
|
|
323
336
|
# `home_tel`, `tel2`, `telNumber`, `cityName`, `zipcode` yes — `hotel_id`,
|
|
324
337
|
# `capacity`, `telemetry_enabled`, `automobile_id`, `gzip_enabled` no.
|
|
325
338
|
# Every alternative stands alone (no group with an inner `|`):
|
|
326
|
-
#
|
|
339
|
+
# SensitiveColumns names the one that matched. The first pattern
|
|
340
|
+
# that matches decides the strategy: Rails 8's `email_address` is an
|
|
341
|
+
# email (it also matches `address`, below).
|
|
327
342
|
DEFAULT_GDPR_FIELD_PATTERNS = {
|
|
328
|
-
/\Aemail\z|_email\z/ => 'email',
|
|
343
|
+
/\Aemail\z|_email\z|\Aemail_address\z|_email_address\z/ => 'email',
|
|
329
344
|
/phone|telefon|\Amobile|_mobile|\Afax|_fax|\Atel\z|\Atel_|\Atel[A-Z0-9]|_tel\z|_tel_|_tel[A-Z0-9]/ => 'last4',
|
|
330
345
|
/\Assn\z|social_security|national_id/ => 'last4',
|
|
331
346
|
/iban|bank_account|credit_card|card_number/ => 'last4',
|
|
@@ -390,9 +405,10 @@ module PermissionForge
|
|
|
390
405
|
end
|
|
391
406
|
|
|
392
407
|
# Field-name variants produced by the configured mappings (e.g. `user_id`
|
|
393
|
-
# -> `user_uuid`, `price_cents` -> `price`)
|
|
394
|
-
#
|
|
395
|
-
# names
|
|
408
|
+
# -> `user_uuid`, `price_cents` -> `price`), whatever the model. What a
|
|
409
|
+
# field row applies to is PermissionForge::FieldVariants: these, kept to
|
|
410
|
+
# the names the Resource's model has (shared by the granting and muting
|
|
411
|
+
# phases of PermissionHashBuilder and the admin).
|
|
396
412
|
#
|
|
397
413
|
# @param field_name [String, Symbol]
|
|
398
414
|
# @return [Array<String>]
|
|
@@ -572,6 +588,7 @@ module PermissionForge
|
|
|
572
588
|
@api_auth = nil
|
|
573
589
|
@api_base_controller_class = '::ActionController::Base'
|
|
574
590
|
@api_stats_require_admin = true
|
|
591
|
+
@api_stats_authorization = nil
|
|
575
592
|
@api_pagination_handler = nil
|
|
576
593
|
@api_max_per_page = 100
|
|
577
594
|
@api_default_per_page = 15
|
|
@@ -591,13 +608,19 @@ module PermissionForge
|
|
|
591
608
|
->(user) { user.respond_to?(:active_for_authentication?) ? user.active_for_authentication? : true }
|
|
592
609
|
end
|
|
593
610
|
|
|
611
|
+
# The profile is cached with the permission hash (the cache store:
|
|
612
|
+
# Redis, Solid Cache, ...), so an attribute the user model encrypts is
|
|
613
|
+
# left out (nil): neither its ciphertext nor its plaintext goes there.
|
|
594
614
|
def default_profile_builder
|
|
595
615
|
lambda { |user|
|
|
616
|
+
label = Admin::PersonLabel.new(user, decrypt: false)
|
|
596
617
|
{
|
|
597
618
|
id: user.id,
|
|
598
619
|
uuid: user.try(:uuid),
|
|
599
|
-
|
|
600
|
-
|
|
620
|
+
# Raw-column-first (Admin::PersonLabel#raw_value): a masked User model never garbles the profile
|
|
621
|
+
name: label.raw_value(:name),
|
|
622
|
+
# The first present of `email`, Rails 8's `email_address` (Admin::PersonLabel::EMAIL_ATTRIBUTES)
|
|
623
|
+
email: label.email
|
|
601
624
|
}
|
|
602
625
|
}
|
|
603
626
|
end
|
|
@@ -16,9 +16,10 @@ module PermissionForge
|
|
|
16
16
|
# in PermissionForge.cache, so every process sees an invalidation
|
|
17
17
|
# (InvalidatesManagedActions drops it when a defining record changes).
|
|
18
18
|
#
|
|
19
|
-
# A stale "not managed" lets a request through under the
|
|
20
|
-
#
|
|
21
|
-
# permission hash is — the same
|
|
19
|
+
# A stale "not managed" lets a request through under the :allow strategy
|
|
20
|
+
# (under :block, the default, both answers refuse), so the index is
|
|
21
|
+
# invalidated and rebuilt the way a user's permission hash is — the same
|
|
22
|
+
# code, one definition:
|
|
22
23
|
# - every invalidation leaves a fresh stamp (STAMP_KEY) before it removes
|
|
23
24
|
# the keys, and a build only leaves the index in the store when the
|
|
24
25
|
# stamp it read before reading the rows is still there after storing it
|
|
@@ -197,11 +198,11 @@ module PermissionForge
|
|
|
197
198
|
PermissionForge::ResourceAction.arel_table[:name])
|
|
198
199
|
end
|
|
199
200
|
|
|
200
|
-
# Mirrors PermissionCacheAccessor#permission_cache_key
|
|
201
|
+
# Mirrors PermissionCacheAccessor#permission_cache_key: the ambient
|
|
202
|
+
# tenant's key (PermissionForge.ambient_tenant_id, the one reader).
|
|
201
203
|
def cache_key
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
tenant ? tenant_key(tenant.id) : CACHE_KEY
|
|
204
|
+
tenant_id = PermissionForge.ambient_tenant_id
|
|
205
|
+
tenant_id ? tenant_key(tenant_id) : CACHE_KEY
|
|
205
206
|
end
|
|
206
207
|
|
|
207
208
|
def tenant_key(tenant_id)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PermissionForge
|
|
4
|
+
# Whether this process can use its database, asked where the answer may be
|
|
5
|
+
# "no" without anything being wrong with the app: at boot (BootChecks),
|
|
6
|
+
# when auto-applied masking reads the Resource table (MaskedModels,
|
|
7
|
+
# MaskingSync), and wherever the audit table is looked for.
|
|
8
|
+
#
|
|
9
|
+
# A process may have no database to use: a Docker build precompiles assets
|
|
10
|
+
# before any database exists, a fresh `rails new --skip-kamal` app comments
|
|
11
|
+
# its production database paths out (the deploy supplies DATABASE_URL at
|
|
12
|
+
# runtime), a worker starts while the database fails over. The same app
|
|
13
|
+
# without the gem boots in each case, so nothing the gem does at boot may
|
|
14
|
+
# abort it then.
|
|
15
|
+
#
|
|
16
|
+
# Connecting can fail in Rails' own code in more ways than
|
|
17
|
+
# ActiveRecord::ActiveRecordError: the sqlite3 adapter refuses a
|
|
18
|
+
# configuration without a database file with an ArgumentError. It is
|
|
19
|
+
# rescued only around a call that runs Rails' connection code alone (a
|
|
20
|
+
# ping, `table_exists?`), never around the gem's own code: an
|
|
21
|
+
# ArgumentError there is a bug and still raises. (An adapter whose driver
|
|
22
|
+
# gem is missing fails while Active Record loads, before any of this runs,
|
|
23
|
+
# with or without the gem.)
|
|
24
|
+
#
|
|
25
|
+
# Every place that asks whether a table is there and may answer "no"
|
|
26
|
+
# quietly asks table_exists? here. Its "no" is never kept: Active Record's
|
|
27
|
+
# schema cache remembers a missing table until the process restarts, so a
|
|
28
|
+
# process that booted before the migration (run from another process)
|
|
29
|
+
# would never see the table; each caller decides how often it asks again
|
|
30
|
+
# (MaskingSync: each run until it syncs; Audit: MISSING_TABLE_RECHECK).
|
|
31
|
+
# The audit table check of the record path passes the narrower
|
|
32
|
+
# UNREACHABLE_ERRORS: a configuration error there still raises out of
|
|
33
|
+
# Audit.record, so the change it records rolls back, as it always did.
|
|
34
|
+
# Services::HostKeyGuard lets a failed read propagate to its own rescue
|
|
35
|
+
# (READ_ERRORS), so that a pass is never remembered from a check that
|
|
36
|
+
# could not look.
|
|
37
|
+
#
|
|
38
|
+
# @api private
|
|
39
|
+
module DatabaseReach
|
|
40
|
+
# A configured database that does not answer (refused, timed out, lost)
|
|
41
|
+
# or does not exist. Never a configuration error: where a quiet "no"
|
|
42
|
+
# lets a change commit unrecorded (the audit table check of
|
|
43
|
+
# Audit.record), only these count as "cannot be asked".
|
|
44
|
+
UNREACHABLE_ERRORS = [ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError].freeze
|
|
45
|
+
|
|
46
|
+
# What a ping or a table check that may answer "no" quietly rescues: the
|
|
47
|
+
# database cannot be used at all — not reachable, not created, not
|
|
48
|
+
# configured (no adapter, an adapter that refuses its configuration:
|
|
49
|
+
# sqlite3's "No database file specified") — or Active Record raised for
|
|
50
|
+
# the query.
|
|
51
|
+
CONNECT_ERRORS = [ActiveRecord::ActiveRecordError, ArgumentError].freeze
|
|
52
|
+
|
|
53
|
+
# What a read raises when the database went away after it answered, or
|
|
54
|
+
# the table is not there yet (not migrated): the errors a boot step or a
|
|
55
|
+
# read of the Resource rows is skipped on.
|
|
56
|
+
READ_ERRORS = [ActiveRecord::ConnectionNotEstablished, ActiveRecord::StatementInvalid].freeze
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# Connects and pings. `connection_pool.with_connection` alone is not
|
|
60
|
+
# enough: since Rails 7.2 it hands back a connection that has not
|
|
61
|
+
# connected yet, so an unreachable database looked available.
|
|
62
|
+
#
|
|
63
|
+
# @param model [Class, nil] an ActiveRecord model whose database is
|
|
64
|
+
# asked; nil for the gem's tables (referenced inside the rescue: the
|
|
65
|
+
# first reference loads the model, and Active Record with it)
|
|
66
|
+
# @return [Exception, nil] what connecting raised (CONNECT_ERRORS), nil
|
|
67
|
+
# when the database answered
|
|
68
|
+
def problem(model = nil)
|
|
69
|
+
(model || PermissionForge::PermissionSet).connection_pool.with_connection(&:verify!)
|
|
70
|
+
nil
|
|
71
|
+
rescue *CONNECT_ERRORS => e
|
|
72
|
+
e
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @param model [Class] an ActiveRecord model
|
|
76
|
+
# @param errors [Array<Class>] what counts as "cannot be asked"
|
|
77
|
+
# (CONNECT_ERRORS, or the narrower UNREACHABLE_ERRORS)
|
|
78
|
+
# @yieldparam error [Exception] what the check raised, when it did
|
|
79
|
+
# @return [Boolean] whether the model's table is there; false when the
|
|
80
|
+
# check raised one of `errors`. A "no" is dropped from the schema
|
|
81
|
+
# cache (`reset_column_information`): the next call asks the database.
|
|
82
|
+
def table_exists?(model, errors: CONNECT_ERRORS)
|
|
83
|
+
return true if model.table_exists?
|
|
84
|
+
|
|
85
|
+
model.reset_column_information
|
|
86
|
+
false
|
|
87
|
+
rescue *errors => e
|
|
88
|
+
yield e if block_given?
|
|
89
|
+
false
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -30,7 +30,7 @@ module PermissionForge
|
|
|
30
30
|
module Deprecation
|
|
31
31
|
# The version that removes what is deprecated now. A test fails once
|
|
32
32
|
# VERSION reaches it: remove the deprecated code, then move the horizon.
|
|
33
|
-
HORIZON = '0.
|
|
33
|
+
HORIZON = '0.16'
|
|
34
34
|
GEM_NAME = 'PermissionForge'
|
|
35
35
|
|
|
36
36
|
DEPRECATOR = ActiveSupport::Deprecation.new(HORIZON, GEM_NAME)
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'i18n'
|
|
5
|
+
require 'active_support/json'
|
|
6
|
+
require 'active_support/core_ext/object/json'
|
|
7
|
+
require 'active_support/core_ext/string/inflections'
|
|
8
|
+
|
|
9
|
+
module PermissionForge
|
|
10
|
+
module Docs
|
|
11
|
+
# The ```json sample=<name> blocks of docs/admin-api.md, rendered from
|
|
12
|
+
# Api::Schema and the serializers (`bundle exec rake docs:api`;
|
|
13
|
+
# docs/api_samples_test.rb fails while the document is stale). A success
|
|
14
|
+
# sample is a serializer's own output for an example record (EXAMPLES,
|
|
15
|
+
# else a value of the key's type), encoded as the API encodes it, inside
|
|
16
|
+
# the envelope the schema declares; an error sample is the schema's error
|
|
17
|
+
# body with the gem's en message. Only the body of a marked block is
|
|
18
|
+
# rewritten: the prose around it is kept as it stands.
|
|
19
|
+
#
|
|
20
|
+
# @api private
|
|
21
|
+
class ApiSamples
|
|
22
|
+
LOCALE_FILE = File.expand_path('../../../config/locales/permission_forge.en.yml', __dir__)
|
|
23
|
+
CREATED = Time.utc(2026, 3, 10, 12, 0, 0)
|
|
24
|
+
UPDATED = Time.utc(2026, 3, 12, 9, 30, 0)
|
|
25
|
+
|
|
26
|
+
# A value for a key no EXAMPLES entry gives, by its type.
|
|
27
|
+
TYPE_EXAMPLES = {
|
|
28
|
+
integer: 1, string: 'text', boolean: true, uuid: '00000000-0000-4000-8000-000000000001', time: CREATED,
|
|
29
|
+
foreign_identifier: 1, foreign_uuid: '00000000-0000-4000-8000-000000000002', host_key: 42, host_key_list: [],
|
|
30
|
+
string_list: [], object: {}
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# The example record of each payload a sample shows.
|
|
34
|
+
EXAMPLES = {
|
|
35
|
+
permission_set: {
|
|
36
|
+
id: 12, uuid: 'd2b4c7e1-5a3f-4b8e-9c6d-1f2a3b4c5d6e', name: 'Sales Team', description: 'Read and edit customers',
|
|
37
|
+
is_active: true, is_editable: true, is_muting: false, requires_session_flag: nil,
|
|
38
|
+
allowed_ips: '203.0.113.0/24', allowed_ip_list: ['203.0.113.0/24'], allowed_hours_start: nil, allowed_hours_end: nil,
|
|
39
|
+
permission_set_group_id: 3, permission_set_group_uuid: '8a6f2c1d-3e4b-4f5a-8b7c-9d0e1f2a3b4c',
|
|
40
|
+
created_at: CREATED, updated_at: UPDATED
|
|
41
|
+
},
|
|
42
|
+
permission_set_group: {
|
|
43
|
+
id: 3, uuid: '8a6f2c1d-3e4b-4f5a-8b7c-9d0e1f2a3b4c', name: 'Sales', description: nil,
|
|
44
|
+
is_active: true, is_editable: true, created_at: CREATED, updated_at: CREATED
|
|
45
|
+
},
|
|
46
|
+
audit_event: {
|
|
47
|
+
id: 481, uuid: '5c1e9a7b-2d4f-4e6a-9b8c-0d1e2f3a4b5c', occurred_at: Time.utc(2026, 9, 14, 8, 12, 44.120391r),
|
|
48
|
+
event: 'assignment.revoked', category: 'change', actor_type: 'user', actor_id: '7', actor_label: 'Ada Admin',
|
|
49
|
+
source: 'admin_ui', record_type: 'PermissionForge::PermissionSetUser', record_id: '93',
|
|
50
|
+
record_uuid: 'a77f3b2c-1d4e-4f5a-8b6c-7d8e9f0a1b2c', record_label: 'Bo Brandt',
|
|
51
|
+
permission_set_id: 12, permission_set_uuid: 'd2b4c7e1-5a3f-4b8e-9c6d-1f2a3b4c5d6e', subject_user_id: '31',
|
|
52
|
+
data: { 'snapshot' => { 'user_id' => 31, 'expires_at' => nil } }, reason: 'left the team',
|
|
53
|
+
request_id: '6f1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d', ip: '203.0.113.9',
|
|
54
|
+
correlation_id: '0b8c7d6e-5f4a-4b3c-9d2e-1f0a9b8c7d6e', created_at: Time.utc(2026, 9, 14, 8, 12, 44.121002r)
|
|
55
|
+
}
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
58
|
+
# The 422s an input error answers, one per line of `input_errors`:
|
|
59
|
+
# [field, en key].
|
|
60
|
+
INPUT_ERRORS = [
|
|
61
|
+
%w[expires_at api.errors.not_iso8601_time], %w[permission_set api.errors.section_missing],
|
|
62
|
+
%w[bulk_action api.errors.bulk_action_invalid], %w[user_ids api.errors.user_ids_missing],
|
|
63
|
+
%w[user_id api.errors.user_not_found], %w[grant_all api.errors.boolean_invalid],
|
|
64
|
+
%w[is_active api.errors.boolean_invalid], %w[name api.errors.text_shape],
|
|
65
|
+
%w[field_value api.errors.field_value_shape],
|
|
66
|
+
%w[base api.errors.field_generation_failed]
|
|
67
|
+
].freeze
|
|
68
|
+
|
|
69
|
+
# One sample: how it is laid out (`:pretty`, `:inline` on one line,
|
|
70
|
+
# `:lines` one inline body per line), the schema it follows
|
|
71
|
+
# ([:single | :collection | :list, payload] or [:error, status]) and how
|
|
72
|
+
# it is built (called with the ApiSamples).
|
|
73
|
+
Sample = Data.define(:style, :schema, :build) do
|
|
74
|
+
def error? = schema.first == :error
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
SAMPLES = {
|
|
78
|
+
'permission_set' => Sample.new(:pretty, %i[single permission_set], ->(_) { Example.envelope(:single, :permission_set) }),
|
|
79
|
+
'permission_set_group_collection' => Sample.new(:pretty, %i[collection permission_set_group],
|
|
80
|
+
->(_) { Example.envelope(:collection, :permission_set_group) }),
|
|
81
|
+
'audit_event_collection' => Sample.new(:pretty, %i[collection audit_event],
|
|
82
|
+
->(_) { Example.envelope(:collection, :audit_event, per_page: 100) }),
|
|
83
|
+
'validation_error' => Sample.new(:pretty, [:error, 422], ->(_) { Text.error(422, 'name' => ['validations.name_blank']) }),
|
|
84
|
+
'not_found' => Sample.new(:pretty, [:error, 404], ->(_) { Text.error(404, 'api.errors.not_found') }),
|
|
85
|
+
'route_not_found' => Sample.new(:inline, [:error, 404], ->(_) { Text.error(404, 'api.errors.route_not_found') }),
|
|
86
|
+
'input_errors' => Sample.new(:lines, [:error, 422],
|
|
87
|
+
->(_) { INPUT_ERRORS.map { |field, key| Text.error(422, field => [key]) } }),
|
|
88
|
+
'body_unreadable' => Sample.new(:inline, [:error, 400], ->(_) { Text.error(400, 'api.errors.body_unreadable') }),
|
|
89
|
+
'action_name_invalid' => Sample.new(:inline, [:error, 422], ->(_) { Text.error(422, 'name' => ['errors.admin.action_format']) })
|
|
90
|
+
}.freeze
|
|
91
|
+
|
|
92
|
+
# The gem's en messages, in the error body the schema declares.
|
|
93
|
+
module Text
|
|
94
|
+
module_function
|
|
95
|
+
|
|
96
|
+
# An error body whose messages are the en texts of `messages`: a
|
|
97
|
+
# key, or a field mapped to keys.
|
|
98
|
+
def error(status, messages)
|
|
99
|
+
texts = messages.is_a?(Hash) ? messages.transform_values { |keys| keys.map { |key| t(key) } } : t(messages)
|
|
100
|
+
Api::Schema.error_body(status, texts)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The gem's en text of `permission_forge.<key>`.
|
|
104
|
+
def t(key)
|
|
105
|
+
I18n.load_path |= [LOCALE_FILE] unless I18n.load_path.any? { |path| path.to_s == LOCALE_FILE }
|
|
106
|
+
I18n.t("permission_forge.#{key}", locale: :en, raise: true)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A serializer's own output for an example record, in its envelope.
|
|
111
|
+
module Example
|
|
112
|
+
module_function
|
|
113
|
+
|
|
114
|
+
# A success envelope around the example record of `payload`, as the
|
|
115
|
+
# API encodes it (`render json:`: times in ISO 8601).
|
|
116
|
+
def envelope(kind, payload, per_page: Configuration.new.api_default_per_page)
|
|
117
|
+
data = JSON.parse(ActiveSupport::JSON.encode(serialized(payload)))
|
|
118
|
+
case kind
|
|
119
|
+
when :single then { 'data' => data }
|
|
120
|
+
when :list then { 'data' => [data] }
|
|
121
|
+
when :collection then { 'data' => [data], 'meta' => { 'pagination' => { 'page' => 1, 'per_page' => per_page, 'total_count' => 1 } } }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @raise [ArgumentError] for a serializer key Api::Schema does not declare
|
|
126
|
+
def serialized(payload)
|
|
127
|
+
serializer = Api::Serializers.const_get("#{payload.to_s.camelize}Serializer")
|
|
128
|
+
fields = Api::Schema.payload(payload)
|
|
129
|
+
declared!(serializer, fields.keys)
|
|
130
|
+
example = record(payload, fields)
|
|
131
|
+
serializer.new(example).with_reference_uuids(reference_uuids(serializer, example)).as_json
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The example's own uuid for each reference (it has no database to
|
|
135
|
+
# look them up in): association => { id => uuid }.
|
|
136
|
+
def reference_uuids(serializer, example)
|
|
137
|
+
serializer.references.to_h do |association|
|
|
138
|
+
id_key, uuid_key = Api::Serializers::BaseSerializer.reference_keys(association)
|
|
139
|
+
[association, { example[id_key] => example[uuid_key] }]
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def declared!(serializer, keys)
|
|
144
|
+
undeclared = serializer.attribute_names - keys
|
|
145
|
+
return if undeclared.empty?
|
|
146
|
+
|
|
147
|
+
raise ArgumentError, "#{serializer.name.demodulize} answers #{undeclared.join(', ')}, which Api::Schema does not declare"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# EXAMPLES' values for `payload`, else a value of each key's type.
|
|
151
|
+
def record(payload, fields)
|
|
152
|
+
examples = EXAMPLES.fetch(payload, {})
|
|
153
|
+
values = fields.to_h { |key, type| [key, examples.fetch(key) { TYPE_EXAMPLES.fetch(type.to_s.delete_suffix('?').to_sym) }] }
|
|
154
|
+
Struct.new(*values.keys, keyword_init: true).new(**values)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# How a block lays out its JSON.
|
|
159
|
+
module Layout
|
|
160
|
+
module_function
|
|
161
|
+
|
|
162
|
+
# Indented two spaces; a list of scalars on one line.
|
|
163
|
+
def pretty(value, indent = 0)
|
|
164
|
+
case value
|
|
165
|
+
when Hash
|
|
166
|
+
entries = value.map { |key, inner| "#{JSON.generate(key.to_s)}: #{pretty(inner, indent + 2)}" }
|
|
167
|
+
value.empty? ? '{}' : nested('{}', entries, indent)
|
|
168
|
+
when Array then scalars?(value) ? inline(value) : nested('[]', value.map { |item| pretty(item, indent + 2) }, indent)
|
|
169
|
+
else JSON.generate(value)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# `brackets` is the pair ("{}" or "[]") around the entries, one per line.
|
|
174
|
+
def nested(brackets, entries, indent)
|
|
175
|
+
pad = ' ' * indent
|
|
176
|
+
"#{brackets[0]}\n#{entries.map { |entry| "#{pad} #{entry}" }.join(",\n")}\n#{pad}#{brackets[1]}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def scalars?(list) = list.none? { |item| item.is_a?(Hash) || item.is_a?(Array) }
|
|
180
|
+
|
|
181
|
+
# One line: `{ "key": value }`, `[a, b]`.
|
|
182
|
+
def inline(value)
|
|
183
|
+
case value
|
|
184
|
+
when Hash then value.empty? ? '{}' : "{ #{value.map { |key, inner| "#{JSON.generate(key.to_s)}: #{inline(inner)}" }.join(', ')} }"
|
|
185
|
+
when Array then "[#{value.map { |item| inline(item) }.join(', ')}]"
|
|
186
|
+
else JSON.generate(value)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# @param document [String] docs/admin-api.md as it stands
|
|
192
|
+
# @param blocks [Array] its fenced blocks, as test/support/doc_blocks.rb parses them
|
|
193
|
+
# (each with #file, #line, #body and #sample)
|
|
194
|
+
def initialize(document:, blocks:)
|
|
195
|
+
@document = document
|
|
196
|
+
@blocks = blocks.select(&:sample)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# The document with the body of every sample block regenerated.
|
|
200
|
+
#
|
|
201
|
+
# @raise [ArgumentError] for a sample= naming no sample, or a serializer
|
|
202
|
+
# key Api::Schema does not declare
|
|
203
|
+
def render
|
|
204
|
+
lines = @document.lines
|
|
205
|
+
@blocks.sort_by(&:line).reverse_each do |block|
|
|
206
|
+
lines[block.line, block.body.lines.size] = text(sample_name(block)).lines
|
|
207
|
+
end
|
|
208
|
+
lines.join
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def stale? = render != @document
|
|
212
|
+
|
|
213
|
+
# The JSON value of a sample (an Array of bodies for a `:lines` sample).
|
|
214
|
+
def value(name)
|
|
215
|
+
SAMPLES.fetch(name).build.call(self)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The sample laid out as the block holds it, ending in a newline.
|
|
219
|
+
def text(name)
|
|
220
|
+
built = value(name)
|
|
221
|
+
body = case SAMPLES.fetch(name).style
|
|
222
|
+
when :pretty then Layout.pretty(built)
|
|
223
|
+
when :inline then Layout.inline(built)
|
|
224
|
+
when :lines then built.map { |item| Layout.inline(item) }.join("\n")
|
|
225
|
+
end
|
|
226
|
+
"#{body}\n"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# What is wrong with a sample against Api::Schema.
|
|
230
|
+
#
|
|
231
|
+
# @return [Array<String>]
|
|
232
|
+
def problems(name)
|
|
233
|
+
sample = SAMPLES.fetch(name)
|
|
234
|
+
kind, detail = sample.schema
|
|
235
|
+
spec = kind == :error ? Api::Schema.error_envelope(detail) : Api::Schema.envelope(kind, detail)
|
|
236
|
+
items = sample.style == :lines ? value(name) : [value(name)]
|
|
237
|
+
items.flat_map { |item| Api::Schema.problems(item, spec) }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
def sample_name(block)
|
|
243
|
+
name = block.sample
|
|
244
|
+
return name if SAMPLES.key?(name)
|
|
245
|
+
|
|
246
|
+
raise ArgumentError, "#{block.file}:#{block.line}: sample=#{name} names no sample (#{self.class}::SAMPLES)"
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|