diff --git a/CHANGELOG.md b/CHANGELOG.md index 6647cd11c..d3b3910dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [v2.3.4] - 2026-04-07 + +### Added + +- Blueprinter JSON serialization framework with 15 blueprint classes and context-specific views (:index, :show, :editor, :navigator, :viewer) +- blueprinter-activerecord auto-preloader for automatic N+1 prevention +- Oj fast JSON generator (~2x faster than stdlib) +- Rule and Review test factories +- 12 query performance regression tests +- Session auth method tracking (session[:auth_method]) — distinguishes "signed in via" from "account linked to" +- Unlink identity feature with password verification +- VULCAN_AUTO_LINK_USER global setting for automatic provider-to-local account linking +- Admin password management UI: always show all options regardless of SMTP configuration + +### Changed + +- All controllers migrated from to_json(methods:[]) to Blueprint.render +- All model as_json overrides removed (BaseRule, Rule, Review, Membership) +- Project#details consolidated from 9 COUNT queries to 3 (GROUP BY) +- Project#available_members uses SQL WHERE NOT IN instead of Ruby set subtraction +- Project#available_components uses .select() for column filtering +- Component#reviews uses pluck(:id, :rule_id) instead of loading full rule objects +- Rule creation uses DB lookup instead of parsing multi-MB XML +- UsersController audit query bounded with .limit(200) +- ApplicationController check_access_request_notifications rewritten (N+1 → single query) +- Replaced gitlab_omniauth-ldap with omniauth-ldap 2.3.3 (removes nkf VM crash) +- Ruby 3.4.8 → 3.4.9 +- Bumped version to v2.3.4 + +### Fixed + +- OIDC provider conflict: symbol/string comparison bug in User.from_omniauth +- Provider+uid-first lookup pattern (GitLab pattern) prevents provider hijacking +- rescue_from ordering: StandardError defined before ProviderConflictError +- Production /stigs crash (R14/R15 memory, H12 timeout) — SeverityCounts concern auto-excludes xml/binary columns +- VulcanAudit bitwise & → && fix for nil rule +- OmniAuth backtrace logging gated on development only — now logs in all environments +- email_verified OIDC claim hardened with ActiveModel::Type::Boolean.new.cast +- Polymorphic membership_type filter in access request notifications +- JSON.parse round-trip eliminated in component show jbuilder (render_as_hash) +- Slack notification firing on every user update instead of only admin changes +- Polymorphic audit query missing user_type filter +- PROJECT_MEMBER_ADMINS normalized from scalar string to array +- UsersTable typeColumn uses falsy check for undefined provider +- Exception message no longer leaked to client in rescue blocks +- update_columns used for password reset token to skip validations +- Visibility chain (stray public keyword) fixed in registrations controller +- valid_password? bcrypt→PBKDF2 rehash side-effect documented at unlink call site + ## [v2.3.1] - 2026-03-03 ### Added @@ -66,7 +115,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Upgraded Ruby from 3.3.9 to 3.4.8 and Puma to 7.2.0 +- Upgraded Ruby from 3.3.9 to 3.4.9 and Puma to 7.2.0 - Upgraded Node.js to 24 LTS - Upgraded PostgreSQL from 12/16 to 18 across Docker, CI, and documentation - Replaced overcommit with lefthook for git hooks; added pre-push checks for RuboCop, ESLint, and Brakeman @@ -312,6 +361,7 @@ For releases prior to v2.1.6, please see the [GitHub releases page](https://gith --- +[v2.3.4]: https://github.com/mitre/vulcan/compare/v2.3.1...v2.3.4 [v2.3.1]: https://github.com/mitre/vulcan/compare/v2.2.0...v2.3.1 [v2.2.1]: https://github.com/mitre/vulcan/compare/v2.2.0...v2.2.1 [v2.2.0]: https://github.com/mitre/vulcan/compare/v2.1.9...v2.2.0 diff --git a/Dockerfile b/Dockerfile index a26fa2f9f..41657bbb0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ # ============================================================================= # Make sure versions match .ruby-version -ARG RUBY_VERSION=3.4.8 +ARG RUBY_VERSION=3.4.9 ARG NODE_VERSION=24.14.0 # ============================================================================= diff --git a/Gemfile b/Gemfile index 379865da2..c8b8ace44 100644 --- a/Gemfile +++ b/Gemfile @@ -150,3 +150,9 @@ gem 'highline', '~> 2.0' gem 'slack-ruby-client', '1.0.0' # Slack notification formatting gem 'slack_block_kit', '0.3.3' + +gem 'blueprinter', '~> 1.2' + +gem 'blueprinter-activerecord', '~> 1.3' + +gem 'oj', '~> 3.16' diff --git a/Gemfile.lock b/Gemfile.lock index 1e3840d2f..f3e287251 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -105,6 +105,10 @@ GEM bigdecimal (3.3.1) bindata (2.5.1) bindex (0.8.1) + blueprinter (1.2.1) + blueprinter-activerecord (1.3.0) + activerecord (>= 6.0) + blueprinter (~> 1.0) bootsnap (1.18.6) msgpack (~> 1.2) brakeman (7.1.0) @@ -382,6 +386,9 @@ GEM rack (>= 1.2, < 4) snaky_hash (~> 2.0, >= 2.0.3) version_gem (~> 1.1, >= 1.1.9) + oj (3.16.16) + bigdecimal (>= 3.0) + ostruct (>= 0.2) omniauth (2.1.3) hashie (>= 3.4.6) rack (>= 2.2.3) @@ -726,6 +733,8 @@ DEPENDENCIES activerecord-session_store amoeba audited (~> 5.8.0) + blueprinter (~> 1.2) + blueprinter-activerecord (~> 1.3) bootsnap (>= 1.4.2) brakeman bundler-audit @@ -753,6 +762,7 @@ DEPENDENCIES mitre-settingslogic (~> 3.0) nokogiri nokogiri-happymapper + oj (~> 3.16) omniauth (~> 2.1) omniauth-github omniauth-ldap (~> 2.3) diff --git a/README.md b/README.md index 6d7bc78da..0ef6e754f 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ yarn dev # Start dev server ## 🛠️ Technology Stack ### Core Framework -- **Ruby 3.4.8** with **Rails 8.0.2.1** +- **Ruby 3.4.9** with **Rails 8.0.2.1** - **PostgreSQL 12+** database - **Node.js 24 LTS** for JavaScript runtime @@ -109,7 +109,7 @@ yarn dev # Start dev server ### Prerequisites -- Ruby 3.4.8 (use rbenv or rvm) +- Ruby 3.4.9 (use rbenv or rvm) - PostgreSQL 12+ - Node.js 24 LTS - Yarn package manager diff --git a/VERSION b/VERSION index aaf7425f4..20dd63223 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v2.3.1 +v2.3.4 diff --git a/app/blueprints/additional_answer_blueprint.rb b/app/blueprints/additional_answer_blueprint.rb new file mode 100644 index 000000000..509e4b452 --- /dev/null +++ b/app/blueprints/additional_answer_blueprint.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Serializes AdditionalAnswer for rule editor forms. +class AdditionalAnswerBlueprint < Blueprinter::Base + identifier :id + + fields :additional_question_id, :answer +end diff --git a/app/blueprints/check_blueprint.rb b/app/blueprints/check_blueprint.rb new file mode 100644 index 000000000..ce3e2c794 --- /dev/null +++ b/app/blueprints/check_blueprint.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Serializes Check records for rule detail views and editors. +class CheckBlueprint < Blueprinter::Base + identifier :id + + fields :system, :content_ref_name, :content_ref_href, :content + + # Rails accepts_nested_attributes_for expects _destroy key + field :_destroy do |_check, _options| + false + end +end diff --git a/app/blueprints/component_blueprint.rb b/app/blueprints/component_blueprint.rb new file mode 100644 index 000000000..d486d53a2 --- /dev/null +++ b/app/blueprints/component_blueprint.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +# Serializes Component records with context-specific views. +# +# Views: +# :index — listing page (minimal fields + severity_counts) +# :show — non-member read-only view (adds rules, reviews) +# :editor — full editing page (adds histories, memberships, metadata, etc.) +# +# Replaces Component#as_json override and the to_json(methods: [...]) pattern. +# The `admins` method is intentionally excluded — Vue analysis confirmed no +# component page consumer reads component.admins (it's only used on project pages). +class ComponentBlueprint < Blueprinter::Base + identifier :id + + # === Default: fields shared by ALL views === + fields :name, :prefix, :version, :release + + field :based_on_title do |component, _options| + component.based_on&.title + end + + field :based_on_version do |component, _options| + component.based_on&.version + end + + field :severity_counts do |component, _options| + component.severity_counts_hash + end + + # === Index view: listing page === + view :index do + fields :updated_at, :released + end + + # === Related view: related_rules parents (includes project for display name) === + view :related do + fields :updated_at, :released + + field :project do |component, _options| + ProjectBlueprint.render_as_hash(component.project) + end + end + + # === Show view: non-member read-only === + view :show do + fields :title, :description, :admin_name, :admin_email, :released, :updated_at + + association :rules, blueprint: RuleBlueprint, view: :viewer do |component, _options| + component.rules + end + + # Uses Component#reviews method (not ReviewBlueprint) because it returns + # pre-formatted hashes with `displayed_rule_name` that ReviewBlueprint lacks. + field :reviews do |component, _options| + component.reviews + end + end + + # === Editor view: full editing page === + view :editor do + # All DB columns needed by Vue components + fields :title, :description, :admin_name, :admin_email, + :released, :advanced_fields, :project_id, :component_id, + :security_requirements_guide_id, :memberships_count, + :rules_count, :updated_at, :created_at + + field :releasable do |component, _options| + component.releasable + end + + field :status_counts do |component, _options| + component.status_counts + end + + field :additional_questions do |component, _options| + component.additional_questions.as_json + end + + # Rules via RuleBlueprint :editor view + association :rules, blueprint: RuleBlueprint, view: :editor do |component, _options| + component.rules + end + + # Uses Component#reviews method (not ReviewBlueprint) because it returns + # pre-formatted hashes with `displayed_rule_name` that ReviewBlueprint lacks. + field :reviews do |component, _options| + component.reviews + end + + field :histories do |component, _options| + component.histories + end + + # Memberships via MembershipBlueprint (includes name, email from user) + association :memberships, blueprint: MembershipBlueprint do |component, _options| + component.memberships + end + + field :metadata do |component, _options| + component.metadata + end + + association :inherited_memberships, blueprint: MembershipBlueprint do |component, _options| + component.inherited_memberships + end + + # Available members — uses SQL NOT IN (not Ruby subtraction) + association :available_members, blueprint: UserBlueprint do |component, _options| + component.available_members + end + + # All users with access — compact (id, name, email only) + association :all_users, blueprint: UserBlueprint do |component, _options| + component.all_users + end + end +end diff --git a/app/blueprints/disa_rule_description_blueprint.rb b/app/blueprints/disa_rule_description_blueprint.rb new file mode 100644 index 000000000..7a6b74535 --- /dev/null +++ b/app/blueprints/disa_rule_description_blueprint.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Serializes DISA rule description fields for rule detail and editor views. +class DisaRuleDescriptionBlueprint < Blueprinter::Base + identifier :id + + fields :vuln_discussion, :false_positives, :false_negatives, + :documentable, :mitigations, :severity_override_guidance, + :potential_impacts, :third_party_tools, :mitigation_control, + :responsibility, :ia_controls, :mitigations_available, + :poam_available, :poam + + field :_destroy do |_drd, _options| + false + end +end diff --git a/app/blueprints/membership_blueprint.rb b/app/blueprints/membership_blueprint.rb new file mode 100644 index 000000000..7eb226596 --- /dev/null +++ b/app/blueprints/membership_blueprint.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Replaces Membership#as_json which added `methods: [:name, :email]`. +class MembershipBlueprint < Blueprinter::Base + identifier :id + + fields :user_id, :role, :membership_type, :membership_id + + # Delegated from user — avoids N+1 when user is eager-loaded via + # the has_many :memberships, -> { includes :user } scope + field :name do |membership, _options| + membership.user&.name + end + + field :email do |membership, _options| + membership.user&.email + end +end diff --git a/app/blueprints/project_blueprint.rb b/app/blueprints/project_blueprint.rb new file mode 100644 index 000000000..e91f3da68 --- /dev/null +++ b/app/blueprints/project_blueprint.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# Serializes Project records with context-specific views. +class ProjectBlueprint < Blueprinter::Base + identifier :id + + fields :name, :description, :visibility, :memberships_count, + :admin_name, :admin_email, :created_at, :updated_at + + view :index do + association :memberships, blueprint: MembershipBlueprint do |project, _options| + project.memberships + end + end + + view :show do + field :details do |project, _options| + project.details + end + + field :histories do |project, _options| + project.histories + end + + field :metadata do |project, _options| + project.project_metadata&.data + end + + association :memberships, blueprint: MembershipBlueprint do |project, _options| + project.memberships + end + + association :components, blueprint: ComponentBlueprint, view: :index do |project, _options| + project.components + end + + association :available_components, blueprint: ComponentBlueprint, view: :index do |project, _options| + project.available_components + end + + association :available_members, blueprint: UserBlueprint do |project, _options| + project.available_members + end + + association :users, blueprint: UserBlueprint do |project, _options| + project.users + end + + field :access_requests do |project, _options| + project.access_requests.eager_load(:user, :project).map do |ar| + { id: ar.id, user: UserBlueprint.render_as_hash(ar.user), project_id: ar.project_id } + end + end + end +end diff --git a/app/blueprints/project_index_blueprint.rb b/app/blueprints/project_index_blueprint.rb new file mode 100644 index 000000000..3e342ed25 --- /dev/null +++ b/app/blueprints/project_index_blueprint.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Specialized project blueprint for the index page that includes per-user +# computed fields (admin, is_member, access_request_id). These require +# the current_user to be passed via options. +# +# Usage: ProjectIndexBlueprint.render(projects, current_user: current_user) +class ProjectIndexBlueprint < Blueprinter::Base + identifier :id + + fields :name, :description, :visibility, :memberships_count, + :admin_name, :admin_email, :created_at, :updated_at + + association :memberships, blueprint: MembershipBlueprint do |project, _options| + project.memberships + end + + field :admin do |project, options| + user = options[:current_user] + next false unless user + + project.memberships.any? { |m| m.role == 'admin' && m.user_id == user.id } + end + + field :is_member do |project, options| + user = options[:current_user] + next false unless user + next true if user.admin? + + project.memberships.any? { |m| m.user_id == user.id } + end + + field :access_request_id do |project, options| + user = options[:current_user] + next nil unless user + + # Use pre-loaded hash if available, otherwise query + ar_hash = options[:access_requests_by_project] + if ar_hash + ar_hash[project.id]&.id + else + user.access_requests.find_by(project_id: project.id)&.id + end + end +end diff --git a/app/blueprints/review_blueprint.rb b/app/blueprints/review_blueprint.rb new file mode 100644 index 000000000..74d806c0b --- /dev/null +++ b/app/blueprints/review_blueprint.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Replaces Review#as_json which added `methods: [:name]`. +# The current Rule#as_json further strips user_id, rule_id, updated_at. +class ReviewBlueprint < Blueprinter::Base + identifier :id + + fields :action, :comment, :created_at + + # Delegated from user — avoids N+1 when user is eager-loaded + field :name do |review, _options| + review.user&.name + end +end diff --git a/app/blueprints/rule_blueprint.rb b/app/blueprints/rule_blueprint.rb new file mode 100644 index 000000000..1239e2c48 --- /dev/null +++ b/app/blueprints/rule_blueprint.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +# Serializes Rule records with context-specific views. +# +# Views: +# :navigator — sidebar rule list (minimal fields for sorting/filtering) +# :viewer — read-only detail (adds text fields and nested associations) +# :editor — full editing form (adds reviews, SRG data, satisfactions) +# +# Replaces Rule#as_json and BaseRule#as_json overrides. +class RuleBlueprint < Blueprinter::Base + identifier :id + + # === Default view: fields shared by ALL views === + fields :rule_id, :title, :version, :status, :rule_severity, :locked, + :review_requestor_id, :changes_requested + + # === Navigator view: sidebar list === + # Only fields needed for the rule navigator sidebar (sorting, filtering, badges). + # No heavy text fields, no nested associations. + view :navigator do + # Default fields are sufficient for navigator + end + + # === Viewer view: read-only detail === + view :viewer do + fields :rule_weight, :fixtext, :fixtext_fixref, :ident, :ident_system, + :vendor_comments, :vuln_id, :legacy_ids, + :component_id, :status_justification, :artifact_description, + :locked_fields + + field :nist_control_family do |rule, _options| + rule.nist_control_family + end + + field :srg_id do |rule, _options| + rule.srg_rule&.version + end + + association :disa_rule_descriptions_attributes, blueprint: DisaRuleDescriptionBlueprint, + name: :disa_rule_descriptions_attributes do |rule, _options| + rule.disa_rule_descriptions + end + + association :checks_attributes, blueprint: CheckBlueprint, + name: :checks_attributes do |rule, _options| + rule.checks + end + + association :satisfies, blueprint: SatisfactionBlueprint do |rule, _options| + rule.satisfies + end + + association :satisfied_by, blueprint: SatisfiedByBlueprint do |rule, _options| + rule.satisfied_by + end + end + + # === Editor view: full editing form === + view :editor do + include_view :viewer + + fields :inspec_control_body, :inspec_control_file, + :inspec_control_body_lang, :inspec_control_file_lang, + :fix_id + + association :rule_descriptions_attributes, blueprint: RuleDescriptionBlueprint, + name: :rule_descriptions_attributes do |rule, _options| + rule.rule_descriptions + end + + association :reviews, blueprint: ReviewBlueprint do |rule, _options| + rule.reviews + end + + association :additional_answers_attributes, blueprint: AdditionalAnswerBlueprint, + name: :additional_answers_attributes do |rule, _options| + rule.additional_answers + end + + field :srg_rule_attributes do |rule, _options| + SrgRuleBlueprint.render_as_hash(rule.srg_rule) if rule.srg_rule + end + + field :srg_info do |rule, _options| + { version: rule.srg_rule&.security_requirements_guide&.version } + end + end +end diff --git a/app/blueprints/rule_description_blueprint.rb b/app/blueprints/rule_description_blueprint.rb new file mode 100644 index 000000000..0e9fd39d6 --- /dev/null +++ b/app/blueprints/rule_description_blueprint.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Serializes RuleDescription for rule detail views. +class RuleDescriptionBlueprint < Blueprinter::Base + identifier :id + + field :description + + field :_destroy do |_rd, _options| + false + end +end diff --git a/app/blueprints/satisfaction_blueprint.rb b/app/blueprints/satisfaction_blueprint.rb new file mode 100644 index 000000000..78e5df175 --- /dev/null +++ b/app/blueprints/satisfaction_blueprint.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Lightweight blueprint for Rule satisfaction relationships (satisfies). +class SatisfactionBlueprint < Blueprinter::Base + identifier :id + field :rule_id + field :srg_id do |rule, _options| + rule.srg_rule&.version + end +end diff --git a/app/blueprints/satisfied_by_blueprint.rb b/app/blueprints/satisfied_by_blueprint.rb new file mode 100644 index 000000000..61677af02 --- /dev/null +++ b/app/blueprints/satisfied_by_blueprint.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Blueprint for satisfied_by relationships — includes fixtext in addition to base fields. +class SatisfiedByBlueprint < SatisfactionBlueprint + field :fixtext +end diff --git a/app/blueprints/srg_blueprint.rb b/app/blueprints/srg_blueprint.rb new file mode 100644 index 000000000..208f15e96 --- /dev/null +++ b/app/blueprints/srg_blueprint.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Serializes SecurityRequirementsGuide records. XML column is NEVER included. +class SrgBlueprint < Blueprinter::Base + identifier :id + + fields :srg_id, :name, :title, :version + + field :severity_counts do |srg, _options| + srg.severity_counts_hash + end + + view :index do + # Default fields + severity_counts + end + + view :show do + field :release_date + + association :srg_rules, blueprint: SrgRuleBlueprint do |srg, _options| + srg.srg_rules + end + end +end diff --git a/app/blueprints/srg_rule_blueprint.rb b/app/blueprints/srg_rule_blueprint.rb new file mode 100644 index 000000000..9c42cdd2c --- /dev/null +++ b/app/blueprints/srg_rule_blueprint.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Serializes an SRG rule (the original SRG requirement that a Rule implements). +# Used as nested data inside RuleBlueprint :editor view for the srg_rule_attributes field. +# Excludes internal fields that the editor doesn't need (id, locked, timestamps, etc.). +class SrgRuleBlueprint < Blueprinter::Base + # No identifier — this is always nested, never fetched by ID + + fields :rule_id, :title, :version, :rule_severity, :rule_weight, + :ident, :ident_system, :fixtext, :fixtext_fixref, :fix_id, + :inspec_control_body, :inspec_control_file, + :inspec_control_body_lang, :inspec_control_file_lang, + :vuln_id, :legacy_ids + + association :rule_descriptions_attributes, blueprint: RuleDescriptionBlueprint, + name: :rule_descriptions_attributes do |srg_rule, _options| + srg_rule.rule_descriptions + end + + association :disa_rule_descriptions_attributes, blueprint: DisaRuleDescriptionBlueprint, + name: :disa_rule_descriptions_attributes do |srg_rule, _options| + srg_rule.disa_rule_descriptions + end + + association :checks_attributes, blueprint: CheckBlueprint, + name: :checks_attributes do |srg_rule, _options| + srg_rule.checks + end +end diff --git a/app/blueprints/stig_blueprint.rb b/app/blueprints/stig_blueprint.rb new file mode 100644 index 000000000..ea134a6e6 --- /dev/null +++ b/app/blueprints/stig_blueprint.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Serializes STIG records. XML column is NEVER included — it's multi-MB +# and only needed for export (which uses Stig.find directly). +class StigBlueprint < Blueprinter::Base + identifier :id + + # === Default: fields shared by all views === + fields :stig_id, :name, :title, :version, :benchmark_date + + field :severity_counts do |stig, _options| + stig.severity_counts_hash + end + + # === Index view: listing page === + view :index do + # Default fields + severity_counts are sufficient + end + + # === Show view: detail page === + view :show do + field :description + + association :stig_rules, blueprint: StigRuleBlueprint do |stig, _options| + stig.stig_rules + end + end +end diff --git a/app/blueprints/stig_rule_blueprint.rb b/app/blueprints/stig_rule_blueprint.rb new file mode 100644 index 000000000..f1e142768 --- /dev/null +++ b/app/blueprints/stig_rule_blueprint.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Serializes StigRule records for STIG show pages. +# Similar to SrgRuleBlueprint but for published STIG rules. +class StigRuleBlueprint < Blueprinter::Base + identifier :id + + fields :rule_id, :title, :version, :rule_severity, :rule_weight, + :ident, :ident_system, :fixtext, :fixtext_fixref, :fix_id, + :vuln_id, :legacy_ids + + association :disa_rule_descriptions_attributes, blueprint: DisaRuleDescriptionBlueprint, + name: :disa_rule_descriptions_attributes do |rule, _options| + rule.disa_rule_descriptions + end + + association :checks_attributes, blueprint: CheckBlueprint, + name: :checks_attributes do |rule, _options| + rule.checks + end +end diff --git a/app/blueprints/user_blueprint.rb b/app/blueprints/user_blueprint.rb new file mode 100644 index 000000000..9398366b2 --- /dev/null +++ b/app/blueprints/user_blueprint.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Compact user representation for dropdowns, member lists, and available_members. +# Only exposes id, name, email — never passwords, tokens, or admin status. +class UserBlueprint < Blueprinter::Base + identifier :id + + fields :name, :email +end diff --git a/app/controllers/api/search_controller.rb b/app/controllers/api/search_controller.rb index 71b18ce70..8d22c6ec9 100644 --- a/app/controllers/api/search_controller.rb +++ b/app/controllers/api/search_controller.rb @@ -132,6 +132,7 @@ def search_rules(limit) # def search_srgs(limit) SecurityRequirementsGuide + .select(:id, :srg_id, :name, :title, :version) .where(*build_ilike_conditions(%w[name title srg_id])) .limit(limit) .map do |srg| @@ -151,6 +152,7 @@ def search_srgs(limit) # def search_stigs(limit) Stig + .select(:id, :stig_id, :name, :title, :version, :description) .where(*build_ilike_conditions(%w[name title stig_id description])) .limit(limit) .map do |stig| diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 4bafb453d..4c9242c7d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -268,13 +268,30 @@ def setup_navigation def check_access_request_notifications @access_requests = [] return @access_requests unless user_signed_in? - - # iterate over the user's projects and check if they are admin - # if they are admin on a project, retrieve the access requests if any - current_user.available_projects.each do |project| - @access_requests << project.access_requests.eager_load(:user, :project).as_json(methods: %i[user project]) if current_user.can_admin_project?(project) + return @access_requests if request.format.json? # Skip for API calls — navbar not rendered + + # Single query: find all access requests for projects where current user is admin. + # Replaces N+1 loop that called can_admin_project? + eager_load per project. + pending_requests = if current_user.admin? + # Super admins see all pending requests — no need to pluck project IDs + ProjectAccessRequest.eager_load(:user, :project) + else + admin_project_ids = Membership.where(user_id: current_user.id, role: 'admin', + membership_type: 'Project') + .pluck(:membership_id) + return @access_requests if admin_project_ids.empty? + + ProjectAccessRequest.where(project_id: admin_project_ids) + .eager_load(:user, :project) + end + + @access_requests = pending_requests.map do |ar| + { + id: ar.id, + user: UserBlueprint.render_as_hash(ar.user), + project: { id: ar.project.id, name: ar.project.name } + } end - @access_requests.flatten! end def check_locked_user_notifications diff --git a/app/controllers/components_controller.rb b/app/controllers/components_controller.rb index b16f994c2..e82190e2d 100644 --- a/app/controllers/components_controller.rb +++ b/app/controllers/components_controller.rb @@ -33,7 +33,7 @@ def index .where(released: true) respond_to do |format| - format.html { @components_json = components.to_json } + format.html { @components_json = ComponentBlueprint.render(components, view: :index) } format.json { @components_json = components } # Jbuilder uses the relation end end @@ -60,17 +60,11 @@ def search def show respond_to do |format| format.html do - @component_json = if @effective_permissions - @component.to_json( - methods: %i[histories memberships metadata inherited_memberships available_members rules - reviews admins all_users] - ) - else - @component.to_json(methods: %i[rules reviews]) - end + view = @effective_permissions ? :editor : :show + @component_json = ComponentBlueprint.render(@component, view: view) @project_json = @component.project.to_json end - format.json # Uses show.json.jbuilder (faster than to_json) + format.json # Uses show.json.jbuilder end end @@ -423,7 +417,7 @@ def find ) .order(:rule_id) - render json: rules + render json: RuleBlueprint.render_as_hash(rules, view: :editor) end private @@ -500,7 +494,7 @@ def set_rule # If a record for the rule exists, set the instance variable @rule_json to the rule's JSON attribute if @rule.present? - @rule_json = @rule.to_json + @rule_json = RuleBlueprint.render(@rule, view: :editor) # Else, create an error message and respond to either HTML or JSON requests else diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 58fdb47e4..9160728a8 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -21,21 +21,20 @@ class ProjectsController < ApplicationController only: %i[import_backup create_from_backup] def index - @projects = current_user.available_projects.eager_load(:memberships).alphabetical.as_json(methods: %i[memberships]) - @projects.each do |project| - project['admin'] = project['memberships'].any? do |m| - PROJECT_MEMBER_ADMINS.include?(m['role']) && m['user_id'] == current_user.id - end - project['is_member'] = project['memberships'].any? do |m| - m['user_id'] == current_user.id - end || current_user.admin - project['access_request_id'] = current_user.access_requests.find_by(project_id: project['id'])&.id - end + projects = current_user.available_projects.preload(:memberships).alphabetical.to_a + # Batch-load access requests to avoid N+1 per-project find_by + project_ids = projects.map(&:id) + ar_by_project = current_user.access_requests + .where(project_id: project_ids) + .index_by(&:project_id) + @projects = ProjectIndexBlueprint.render_as_hash( + projects, + current_user: current_user, + access_requests_by_project: ar_by_project + ) respond_to do |format| format.html - format.json do - render json: @projects - end + format.json { render json: @projects } end end @@ -56,13 +55,10 @@ def show # Setting current_user allows `available_components` to be filtered down only to the # projects that a user has permissions to access @project.current_user = current_user - @project_json = @project.to_json( - methods: %i[histories memberships metadata components available_components available_members details users - access_requests] - ) + @project_json = ProjectBlueprint.render(@project, view: :show) respond_to do |format| format.html - format.json { render json: @project_json } + format.json { render body: @project_json, content_type: 'application/json' } end end diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index 443e17c2f..d6802a391 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -16,8 +16,11 @@ class RulesController < ApplicationController def index @rules = @component.rules.eager_load(:reviews, :disa_rule_descriptions, :rule_descriptions, :checks, - :additional_answers, :satisfies, :satisfied_by, + :additional_answers, + { satisfies: :srg_rule, satisfied_by: :srg_rule }, srg_rule: %i[disa_rule_descriptions rule_descriptions checks]) + @rules_json = RuleBlueprint.render(@rules, view: :editor) + @component_json = ComponentBlueprint.render(@component, view: :editor) end def search @@ -40,7 +43,7 @@ def search end def show - render json: @rule.to_json(methods: %i[histories satisfies satisfied_by]) + render json: RuleBlueprint.render_as_hash(@rule, view: :editor) end def related_rules @@ -50,16 +53,23 @@ def related_rules ) stig_rules = StigRule.where(srg_id: srg_id).eager_load(:disa_rule_descriptions, :checks, :stig) rules = rules.filter { |r| r.component.all_users.include?(current_user) } unless current_user.admin? - parents = (stig_rules.map(&:stig).as_json + rules.map(&:component).as_json(methods: %i[project])).uniq + stig_parents = StigBlueprint.render_as_hash(stig_rules.map(&:stig).uniq, view: :index) + components = rules.map(&:component).uniq + ActiveRecord::Associations::Preloader.new(records: components, associations: :project).call + component_parents = ComponentBlueprint.render_as_hash(components, view: :related) + parents = (stig_parents + component_parents) - render json: { rules: stig_rules + rules, parents: parents }.to_json + all_rules = StigRuleBlueprint.render_as_hash(stig_rules) + + RuleBlueprint.render_as_hash(rules, view: :editor) + + render json: { rules: all_rules, parents: parents } end def create rule = create_or_duplicate if rule.save render json: { toast: 'Successfully created control.', - data: rule.to_json(methods: %i[histories satisfies satisfied_by]) } + data: RuleBlueprint.render_as_hash(rule, view: :editor) } else render json: { toast: { @@ -136,7 +146,7 @@ def section_locks @rule.audit_comment = comment.presence || "#{locked ? 'Locked' : 'Unlocked'} section: #{section}" @rule.update!(locked_fields: fields) - render json: { rule: @rule.as_json, toast: "#{section} #{locked ? 'locked' : 'unlocked'}" } + render json: { rule: RuleBlueprint.render_as_hash(@rule, view: :editor), toast: "#{section} #{locked ? 'locked' : 'unlocked'}" } end def bulk_section_locks @@ -160,7 +170,7 @@ def bulk_section_locks @rule.audit_comment = comment.presence || "#{action_word} sections: #{sections.join(', ')}" @rule.update!(locked_fields: fields) - render json: { rule: @rule.as_json, toast: "#{action_word} #{sections.size} sections" } + render json: { rule: RuleBlueprint.render_as_hash(@rule, view: :editor), toast: "#{action_word} #{sections.size} sections" } end private @@ -180,15 +190,28 @@ def create_or_duplicate new_rule elsif authorize_admin_project.nil? srg = SecurityRequirementsGuide.find_by(id: @component.security_requirements_guide_id) - srg_rule = srg.parsed_benchmark.rule.find { |r| r.ident.reject(&:legacy).first.ident == 'CCI-000366' } - - rule = BaseRule.from_mapping(Rule, srg_rule) + db_srg_rule = srg.srg_rules.eager_load(:disa_rule_descriptions, :checks, :rule_descriptions, :references) + .where('ident LIKE ?', '%CCI-000366%').first + + rule = Rule.new( + component: @component, + srg_rule: db_srg_rule, + rule_id: (@component.rules.order(:rule_id).pluck(:rule_id).last.to_i + 1).to_s.rjust(6, '0'), + status: 'Not Yet Determined', + rule_severity: 'unknown', + rule_weight: db_srg_rule&.rule_weight || '10.0', + version: db_srg_rule&.version, + title: db_srg_rule&.title, + ident: db_srg_rule&.ident || 'CCI-000366', + ident_system: db_srg_rule&.ident_system, + fixtext: db_srg_rule&.fixtext, + fixtext_fixref: db_srg_rule&.fixtext_fixref, + fix_id: db_srg_rule&.fix_id + ) + rule.disa_rule_descriptions.build(db_srg_rule.disa_rule_descriptions.map { |d| d.attributes.except('id', 'base_rule_id') }) if db_srg_rule&.disa_rule_descriptions&.any? + rule.checks.build(db_srg_rule.checks.map { |c| c.attributes.except('id', 'base_rule_id') }) if db_srg_rule&.checks&.any? + rule.references.build(db_srg_rule.references.map { |r| r.attributes.except('id', 'base_rule_id') }) if db_srg_rule&.references&.any? rule.audits.build(Audited.audit_class.create_initial_rule_audit_from_mapping(@component.id)) - rule.component = @component - rule.srg_rule = srg.srg_rules.find_by(ident: 'CCI-000366') - rule.rule_id = (@component.rules.order(:rule_id).pluck(:rule_id).last.to_i + 1)&.to_s&.rjust(6, '0') - rule.status = 'Not Yet Determined' - rule.rule_severity = 'unknown' rule end diff --git a/app/controllers/security_requirements_guides_controller.rb b/app/controllers/security_requirements_guides_controller.rb index 860d7c8ee..9a8d62118 100644 --- a/app/controllers/security_requirements_guides_controller.rb +++ b/app/controllers/security_requirements_guides_controller.rb @@ -11,16 +11,15 @@ class SecurityRequirementsGuidesController < ApplicationController def index @srgs = SecurityRequirementsGuide.with_severity_counts.order(:srg_id, :version) - # Rails automatically renders index.html.haml for HTML, index.json.jbuilder for JSON + @srgs_json = SrgBlueprint.render(@srgs, view: :index) end def show - # Eager load associations for performance @srg = SecurityRequirementsGuide.includes(srg_rules: %i[disa_rule_descriptions checks]).find(params[:id]) respond_to do |format| - format.html { @srg_json = @srg.to_json(methods: %i[srg_rules]) } - format.json # Uses show.json.jbuilder (faster than to_json) + format.html { @srg_json = SrgBlueprint.render(@srg, view: :show) } + format.json # Uses show.json.jbuilder end end diff --git a/app/controllers/stigs_controller.rb b/app/controllers/stigs_controller.rb index 90a73c0dc..323f49ce1 100644 --- a/app/controllers/stigs_controller.rb +++ b/app/controllers/stigs_controller.rb @@ -11,16 +11,15 @@ class StigsController < ApplicationController def index @stigs = Stig.with_severity_counts.order(:stig_id, :version) - # Rails automatically renders index.html.haml for HTML, index.json.jbuilder for JSON + @stigs_json = StigBlueprint.render(@stigs, view: :index) end def show - # Eager load associations for performance (set_stig loads basic STIG) @stig = Stig.includes(stig_rules: %i[disa_rule_descriptions checks]).find(params[:id]) respond_to do |format| - format.html { @stig_json = @stig.to_json(methods: %i[stig_rules]) } - format.json # Uses show.json.jbuilder (faster than to_json) + format.html { @stig_json = StigBlueprint.render(@stig, view: :show) } + format.json # Uses show.json.jbuilder end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 12da770af..537a88ff9 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -15,6 +15,7 @@ def index @histories = Audited.audit_class.includes(:auditable, :user) .where(auditable_type: 'User') .order(created_at: :desc) + .limit(200) .map(&:format) end diff --git a/app/javascript/components/users/EditUserModal.vue b/app/javascript/components/users/EditUserModal.vue index b9fc68582..e57d1fab8 100644 --- a/app/javascript/components/users/EditUserModal.vue +++ b/app/javascript/components/users/EditUserModal.vue @@ -89,7 +89,7 @@

Password Management

-
+
- -
+ +
srg_rule&.version, - srg_rule_attributes: srg_rule&.as_json&.except('id', 'locked', 'created_at', 'updated_at', 'status', - 'status_justification', 'artifact_description', - 'vendor_comments', 'review_requestor_id', - 'component_id', 'changes_requested', 'srg_rule_id', - 'security_requirements_guide_id'), - satisfies: satisfies.map do |s| - { id: s.id, rule_id: s.rule_id, srg_id: s.srg_rule&.version } - end, - satisfied_by: satisfied_by.map do |s| - { id: s.id, rule_id: s.rule_id, fixtext: s.fixtext, srg_id: s.srg_rule&.version } - end, - additional_answers_attributes: additional_answers.as_json.map do |c| - c.except('rule_id', 'created_at', 'updated_at') - end, - srg_info: { version: SecurityRequirementsGuide.find_by(id: srg_rule&.security_requirements_guide_id)&.version } - } - ) - end - - result - end + # Serialization is handled by RuleBlueprint. + # See app/blueprints/rule_blueprint.rb for :navigator, :viewer, :editor views. ## # Revert a specific field on a rule from an audit diff --git a/app/models/user.rb b/app/models/user.rb index a5beea5e3..b4d2e7486 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -113,7 +113,7 @@ def self.from_omniauth(auth) end rescue StandardError => e Rails.logger.error "Failed to create/update user from OmniAuth: #{e.message}" - Rails.logger.debug e.backtrace.join("\n") if Rails.env.development? + Rails.logger.debug e.backtrace.join("\n") raise end diff --git a/app/views/components/show.json.jbuilder b/app/views/components/show.json.jbuilder index 411332e0a..a30748f8c 100644 --- a/app/views/components/show.json.jbuilder +++ b/app/views/components/show.json.jbuilder @@ -21,10 +21,8 @@ if @effective_permissions json.all_users @component.all_users json.reviews @component.reviews - # Full rules for editor - json.rules @component.rules do |rule| - json.merge! rule.as_json # Use full as_json for editor - end + # Full rules for editor via RuleBlueprint (render_as_hash avoids JSON encode+parse round-trip) + json.rules RuleBlueprint.render_as_hash(@component.rules, view: :editor) else # Non-member viewing released component - lightweight for BenchmarkViewer json.extract! @component, :id, :name, :prefix, :version, :release, :updated_at diff --git a/app/views/rules/index.html.haml b/app/views/rules/index.html.haml index 92fe76c54..74f7cd89a 100644 --- a/app/views/rules/index.html.haml +++ b/app/views/rules/index.html.haml @@ -5,8 +5,8 @@ #Rules %Rules{ | 'v-bind:project': @project.to_json, | - 'v-bind:component': @component.to_json(include: :memberships, methods: %i[histories reviews metadata all_users inherited_memberships]), | - 'v-bind:rules': @rules.to_json, | + 'v-bind:component': @component_json, | + 'v-bind:rules': @rules_json, | 'v-bind:statuses': RuleConstants::STATUSES.to_json, | 'v-bind:effective_permissions': @effective_permissions.to_json, | 'v-bind:current_user_id': current_user.id.to_json, | diff --git a/app/views/security_requirements_guides/index.html.haml b/app/views/security_requirements_guides/index.html.haml index e7df6aa7f..fd98ed61e 100644 --- a/app/views/security_requirements_guides/index.html.haml +++ b/app/views/security_requirements_guides/index.html.haml @@ -3,6 +3,6 @@ #SecurityRequirementsGuides %SecurityRequirementsGuides{ | - 'v-bind:givensrgs': @srgs.to_json, | + 'v-bind:givensrgs': @srgs_json, | 'v-bind:is_vulcan_admin': (current_user&.admin ? "true" : "false" ) | } diff --git a/app/views/stigs/index.html.haml b/app/views/stigs/index.html.haml index f793d8a60..c77528528 100644 --- a/app/views/stigs/index.html.haml +++ b/app/views/stigs/index.html.haml @@ -3,6 +3,6 @@ #Stigs %Stigs{ | - 'v-bind:givenstigs': @stigs.to_json, | + 'v-bind:givenstigs': @stigs_json, | 'v-bind:is_vulcan_admin': (current_user&.admin ? "true" : "false" ) | } diff --git a/config/initializers/blueprinter.rb b/config/initializers/blueprinter.rb new file mode 100644 index 000000000..ab091c5aa --- /dev/null +++ b/config/initializers/blueprinter.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Blueprinter JSON serializer configuration. +# +# Blueprinter replaces the model-level `as_json` overrides with dedicated +# serializer classes (one per model, with named views for different contexts). +# This follows the GitLab/Discourse pattern of separating serialization +# from domain models — see https://thoughtbot.com/blog/better-serialization-less-as-json +# +# Usage: +# RuleBlueprint.render(rule, view: :editor) +# ComponentBlueprint.render(component, view: :show) +# StigBlueprint.render_as_hash(stigs, view: :index) # returns hash, no JSON string +# +# Explicitly activate Oj Rails compatibility before Blueprinter uses it. +# Suppresses "Oj::Rails.mimic_JSON was called implicitly" warning. +Oj.mimic_JSON + +Blueprinter.configure do |config| + # Use Oj for ~2x faster JSON generation vs stdlib JSON. + config.generator = Oj + + # Sort fields by definition order (as declared in the blueprint class). + # Makes output match the blueprint's declared field order for readability. + config.sort_fields_by = :definition + + # Automatic N+1 prevention: the blueprinter-activerecord extension + # inspects each blueprint's associations and calls includes/preload + # on the ActiveRecord::Relation before serialization. + config.extensions << BlueprinterActiveRecord::Preloader.new(auto: true) +end diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 3059b46cb..b8e36778c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -63,7 +63,9 @@ services: db: condition: service_healthy command: > - bash -c "bin/rails db:prepare && foreman start -f Procfile.dev" + bash -c "rm -f tmp/pids/server.pid && + bin/rails db:prepare && yarn build:watch & + bundle exec rails server -b 0.0.0.0 -p 3000" volumes: vulcan_dev_dbdata: diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 987cd5402..16ca24f75 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -31,7 +31,7 @@ Returns application metadata. **No authentication required** — used by monitor "name": "Vulcan", "version": "2.3.1", "rails": "8.0.4", - "ruby": "3.4.8", + "ruby": "3.4.9", "environment": "production" } ``` diff --git a/docs/deployment/bare-metal.md b/docs/deployment/bare-metal.md index 962267379..4865bb5af 100644 --- a/docs/deployment/bare-metal.md +++ b/docs/deployment/bare-metal.md @@ -53,9 +53,9 @@ source ~/.bashrc # Install ruby-build git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build -# Install Ruby 3.4.8 -rbenv install 3.4.8 -rbenv global 3.4.8 +# Install Ruby 3.4.9 +rbenv install 3.4.9 +rbenv global 3.4.9 ruby -v # Verify installation ``` @@ -66,9 +66,9 @@ ruby -v # Verify installation curl -sSL https://get.rvm.io | bash -s stable source ~/.rvm/scripts/rvm -# Install Ruby 3.4.8 -rvm install 3.4.8 -rvm use 3.4.8 --default +# Install Ruby 3.4.9 +rvm install 3.4.9 +rvm use 3.4.9 --default ruby -v # Verify installation ``` diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index 417cefe60..04d137320 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -70,7 +70,7 @@ docker run -d \ ## Image Details -- **Base**: Ruby 3.4.8 on Debian Bookworm +- **Base**: Ruby 3.4.9 on Debian Bookworm - **Architectures**: linux/amd64, linux/arm64 (multi-arch, built natively via Docker Build Cloud) - **Size**: ~1.76GB (73% smaller than v2.1) - **Memory**: Uses jemalloc for 20-40% memory reduction diff --git a/docs/development/architecture.md b/docs/development/architecture.md index cd9c94230..45415fa69 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -13,7 +13,7 @@ Vulcan is a Rails-based web application designed for creating and managing Secur ## Technology Stack ### Backend -- **Ruby 3.4.8** - Programming language +- **Ruby 3.4.9** - Programming language - **Rails 8.0.2.1** - Web application framework - **PostgreSQL** - Primary database @@ -172,10 +172,10 @@ See [Authorization Architecture](authorization.md) for details. ### Container-based ```dockerfile # Multi-stage build for optimization -FROM ruby:3.4.8-slim as builder +FROM ruby:3.4.9-slim as builder # Build dependencies and assets -FROM ruby:3.4.8-slim +FROM ruby:3.4.9-slim # Runtime with jemalloc for memory optimization ``` diff --git a/docs/development/release-process.md b/docs/development/release-process.md index 292fb30a0..fdaf2c4c1 100644 --- a/docs/development/release-process.md +++ b/docs/development/release-process.md @@ -37,7 +37,7 @@ fix: correct rule export when description is blank refactor: extract XCCDF parser into dedicated class test: add request specs for component export docs: document AC-8 consent TTL configuration -chore: update Ruby to 3.4.8 +chore: update Ruby to 3.4.9 ``` ### How commit types map to changelog sections @@ -191,7 +191,7 @@ This follows the same tag-triggered flow. There is no separate hotfix workflow. - **Registry**: [hub.docker.com/r/mitre/vulcan](https://hub.docker.com/r/mitre/vulcan) - **Architectures**: `linux/amd64`, `linux/arm64` (built natively via Docker Build Cloud) - **Tags**: `v2.3.2` (immutable) and `latest` (updated on each release) -- **Base**: Ruby 3.4.8 on Debian Bookworm with jemalloc +- **Base**: Ruby 3.4.9 on Debian Bookworm with jemalloc ## Troubleshooting diff --git a/docs/development/setup.md b/docs/development/setup.md index d092fa690..331a8ae3c 100644 --- a/docs/development/setup.md +++ b/docs/development/setup.md @@ -6,7 +6,7 @@ This guide walks through setting up a local Vulcan development environment. ### Required Software -- **Ruby 3.4.8** (use rbenv or rvm for version management) +- **Ruby 3.4.9** (use rbenv or rvm for version management) - **Node.js 24 LTS** and **Yarn** package manager - **PostgreSQL 18** database server - **Git** version control @@ -131,8 +131,8 @@ echo 'eval "$(rbenv init -)"' >> ~/.bashrc git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build # Install Ruby -rbenv install 3.4.8 -rbenv local 3.4.8 +rbenv install 3.4.9 +rbenv local 3.4.9 ``` #### Using rvm @@ -142,12 +142,12 @@ rbenv local 3.4.8 \curl -sSL https://get.rvm.io | bash -s stable # Install Ruby -rvm install 3.4.8 -rvm use 3.4.8 +rvm install 3.4.9 +rvm use 3.4.9 # Create gemset (optional) rvm gemset create vulcan -rvm use 3.4.8@vulcan +rvm use 3.4.9@vulcan ``` ### Database Configuration @@ -383,7 +383,7 @@ Recommended extensions: ### RubyMine -1. Set Ruby SDK to 3.4.8 +1. Set Ruby SDK to 3.4.9 2. Configure Rails project 3. Enable RuboCop inspection 4. Set JavaScript version to ES6+ diff --git a/docs/development/testing.md b/docs/development/testing.md index 54c75ad4b..4ad0701c0 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -503,7 +503,7 @@ jobs: - name: Setup Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.8 + ruby-version: 3.4.9 bundler-cache: true - name: Setup Node diff --git a/docs/index.md b/docs/index.md index 09d4d005b..600f91132 100644 --- a/docs/index.md +++ b/docs/index.md @@ -88,7 +88,7 @@ Vulcan bridges the gap between security requirements and practical implementatio

Backend

    -
  • Ruby 3.4.8 with Rails 8.0.2.1
  • +
  • Ruby 3.4.9 with Rails 8.0.2.1
  • PostgreSQL 18
diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index 75b4f769f..721997782 100644 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -4,10 +4,12 @@ All Vulcan releases with changelogs and migration notes. ## Current Release -- **[v2.3.1](v2.3.1)** — Per-section rule locking, field state visualization, export modal UX, JSON archive backup/restore +- **[v2.3.4](v2.3.4)** — Blueprinter JSON serialization, query performance hardening, OIDC fix, auth UX ## Previous Releases +- **[v2.3.1](v2.3.1)** — Per-section rule locking, field state visualization, export modal UX, JSON archive backup/restore + - **[v2.2.1](v2.2.1)** — Account lockout (STIG AC-07), classification banner, consent modal, password policy, admin user management - **[v2.2.0](v2.2.0)** — Rails 8 upgrade, request spec migration, MDI to Bootstrap icons migration diff --git a/docs/release-notes/v2.3.1.md b/docs/release-notes/v2.3.1.md index a1349f898..ab21c0ce8 100644 --- a/docs/release-notes/v2.3.1.md +++ b/docs/release-notes/v2.3.1.md @@ -6,7 +6,7 @@ This release includes DISA process documentation, SRG ID display in satisfaction ## Upgrades -- **Ruby 3.4.8** - Upgraded from 3.3.9 (latest patch release with security fixes) +- **Ruby 3.4.9** - Upgraded from 3.3.9 (latest patch release with security fixes) - **Puma 7.2.0** - Upgraded from 5.6.9 for Heroku Router 2.0 compatibility and performance - **parallel_tests** - Added for faster local test execution - **Vitest CI integration** - Frontend tests (1114 tests) now run in GitHub Actions diff --git a/docs/release-notes/v2.3.4.md b/docs/release-notes/v2.3.4.md new file mode 100644 index 000000000..6833fe42d --- /dev/null +++ b/docs/release-notes/v2.3.4.md @@ -0,0 +1,59 @@ +# Vulcan v2.3.4 + +Released: 2026-04-07 + +## Highlights + +- **Blueprinter JSON serialization** — 15 blueprint classes replace all model `as_json` overrides, with context-specific views and automatic N+1 prevention via blueprinter-activerecord +- **Query performance hardening** — 9 COUNT queries consolidated to 3, XML parsing eliminated from rule creation, unbounded queries bounded, Ruby set operations replaced with SQL +- **OIDC provider conflict fix** — symbol/string comparison bug, provider+uid-first lookup, rescue_from ordering +- **Production /stigs crash fix** — SeverityCounts concern auto-excludes multi-MB xml/binary columns from SELECT +- **Auth UX improvements** — session auth method tracking, unlink identity, VULCAN_AUTO_LINK_USER setting + +## Added + +- Blueprinter framework with views: RuleBlueprint (:navigator, :viewer, :editor), ComponentBlueprint (:index, :show, :editor), StigBlueprint, SrgBlueprint, ProjectBlueprint, and 9 supporting blueprints +- Oj fast JSON generator (~2x faster than stdlib) +- Session auth method tracking — "Signed in via Okta" distinct from "Account linked to Okta" +- Unlink identity feature with password verification in user profile +- `VULCAN_AUTO_LINK_USER` environment variable for automatic provider-to-local account linking +- Rule and Review test factories +- 12 query performance regression tests + +## Changed + +- All controllers migrated from `to_json(methods:[])` to `Blueprint.render` +- `Project#details`: 9 separate COUNT queries → 3 queries (GROUP BY) +- `Project#available_members`: Ruby `Array#-` → SQL `WHERE NOT IN` +- `Component#reviews`: full rule load → `pluck(:id, :rule_id)` +- Rule creation: multi-MB XML parse → database lookup +- `UsersController#index` audit query bounded with `.limit(200)` +- Replaced `gitlab_omniauth-ldap` with `omniauth-ldap` 2.3.3 (fixes Ruby VM crash from nkf) +- Ruby 3.4.8 → 3.4.9 +- Admin password management UI shows all options regardless of SMTP + +## Fixed + +- OIDC login failure: symbol/string provider comparison in `User.from_omniauth` +- Production /stigs crash: xml blob memory blowout (R14/R15/H12 on Heroku) +- `VulcanAudit` bitwise `&` → `&&` for nil rule +- OmniAuth backtrace logging now works in all environments +- `email_verified` OIDC claim hardened against string `"false"` +- Exception messages no longer leaked to client in rescue blocks +- Password reset uses `update_columns` to skip validations +- Polymorphic `membership_type` filter in access request notifications +- 8 additional auth/security fixes (see CHANGELOG for full list) + +## Upgrade Notes + +No database migrations required. This is a drop-in upgrade from v2.3.1+. + +**New environment variable:** +- `VULCAN_AUTO_LINK_USER` (default: `false`) — set to `true` to automatically link external identities to local accounts with matching email. Only enable when all identity providers verify email ownership. + +**Gem changes:** +- Added: `blueprinter`, `blueprinter-activerecord`, `oj` +- Replaced: `gitlab_omniauth-ldap` → `omniauth-ldap` 2.3.3 +- Removed: `nkf` (no longer needed) + +**Version**: v2.3.4 diff --git a/docs/security/compliance.md b/docs/security/compliance.md index 0df38cb0b..131b1d034 100644 --- a/docs/security/compliance.md +++ b/docs/security/compliance.md @@ -367,7 +367,7 @@ Rate limiting is enabled by default. Thresholds can be adjusted in `config/initi ```dockerfile # Secure Dockerfile Example -FROM ruby:3.4.8-slim AS production +FROM ruby:3.4.9-slim AS production # Security: Run as non-root user RUN groupadd -r app && useradd -r -g app app diff --git a/lib/tasks/stig_and_srg_puller.rake b/lib/tasks/stig_and_srg_puller.rake index 4743b791c..d4924c895 100644 --- a/lib/tasks/stig_and_srg_puller.rake +++ b/lib/tasks/stig_and_srg_puller.rake @@ -100,8 +100,9 @@ namespace :stig_and_srg_puller do new_rule = new_rules[existing_rule.version] next if new_rule.blank? - rule_attributes = new_rule.as_json.compact - rule_attributes.delete(:nist_control_family) # This is not a rule attribute, but a method + # Use .attributes instead of .as_json — as_json includes computed fields + # (nist_control_family, nested associations) that aren't DB columns. + rule_attributes = new_rule.attributes.compact.except('id', 'created_at', 'updated_at') existing_rule.update(rule_attributes) end if existing_object.save diff --git a/package.json b/package.json index be2d96b4c..cc91e3c3b 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "vue-template-compiler": "~2.7.16", "vue-turbolinks": "^2.1.0" }, - "version": "2.3.1", + "version": "2.3.4", "devDependencies": { "@eslint/js": "^9.33.0", "@vitejs/plugin-vue2": "^2.3.4", diff --git a/spec/blueprints/component_blueprint_spec.rb b/spec/blueprints/component_blueprint_spec.rb new file mode 100644 index 000000000..0743727eb --- /dev/null +++ b/spec/blueprints/component_blueprint_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require 'rails_helper' + +## +# ComponentBlueprint Tests +# +# REQUIREMENT: The :editor view must produce output field-compatible with +# the current `to_json(methods: %i[histories memberships metadata +# inherited_memberships available_members rules reviews admins all_users])`. +# +RSpec.describe 'ComponentBlueprint' do + let_it_be(:admin_user) { create(:user, admin: true) } + let_it_be(:project) { create(:project) } + let_it_be(:srg) { SecurityRequirementsGuide.first || create(:security_requirements_guide) } + let_it_be(:component) { create(:component, project: project, based_on: srg) } + let_it_be(:membership) do + Membership.find_or_create_by!(user: admin_user, membership: component, role: 'admin') + end + + describe ':index view' do + let(:json) { ComponentBlueprint.render_as_hash(component, view: :index) } + + it 'includes listing fields' do + %i[id name prefix version release based_on_title based_on_version].each do |f| + expect(json).to have_key(f), "Missing :index field: #{f}" + end + end + + it 'includes severity_counts' do + expect(json).to have_key(:severity_counts) + end + + it 'excludes heavy fields' do + expect(json).not_to have_key(:rules) + expect(json).not_to have_key(:histories) + expect(json).not_to have_key(:available_members) + end + end + + describe ':show view (non-member, released component)' do + let(:json) { ComponentBlueprint.render_as_hash(component, view: :show) } + + it 'includes rules and reviews' do + expect(json).to have_key(:rules) + expect(json).to have_key(:reviews) + expect(json[:rules]).to be_an(Array) + end + + it 'excludes editor-only fields' do + expect(json).not_to have_key(:available_members) + expect(json).not_to have_key(:all_users) + expect(json).not_to have_key(:inherited_memberships) + end + end + + describe ':editor view (project member)' do + let(:json) { ComponentBlueprint.render_as_hash(component, view: :editor) } + + it 'includes all DB columns needed by Vue' do + %i[id name prefix version release title description + admin_name admin_email released advanced_fields + project_id component_id security_requirements_guide_id + memberships_count rules_count updated_at].each do |f| + expect(json).to have_key(f), "Missing :editor field: #{f}" + end + end + + it 'includes computed fields' do + expect(json).to have_key(:based_on_title) + expect(json).to have_key(:based_on_version) + expect(json).to have_key(:releasable) + expect(json).to have_key(:severity_counts) + expect(json).to have_key(:status_counts) + end + + it 'includes all method-derived data' do + expect(json).to have_key(:rules) + expect(json).to have_key(:reviews) + expect(json).to have_key(:histories) + expect(json).to have_key(:memberships) + expect(json).to have_key(:metadata) + expect(json).to have_key(:inherited_memberships) + expect(json).to have_key(:available_members) + expect(json).to have_key(:all_users) + expect(json).to have_key(:additional_questions) + end + + it 'does NOT include dead admins field' do + # Per Vue analysis: no component page Vue consumer reads component.admins + expect(json).not_to have_key(:admins) + end + + it 'rules are serialized via RuleBlueprint :editor' do + if json[:rules].any? + rule_json = json[:rules].first + # RuleBlueprint :editor includes these + expect(rule_json).to have_key(:srg_rule_attributes) + expect(rule_json).to have_key(:reviews) + expect(rule_json).to have_key(:srg_info) + end + end + + it 'memberships include name and email' do + if json[:memberships].any? + m = json[:memberships].first + expect(m).to have_key(:name) + expect(m).to have_key(:email) + expect(m).to have_key(:role) + end + end + + it 'available_members only include id, name, email' do + if json[:available_members].any? + u = json[:available_members].first + expect(u.keys.sort).to eq(%i[email id name]) + end + end + + it 'all_users only include id, name, email' do + if json[:all_users].any? + u = json[:all_users].first + expect(u.keys.sort).to eq(%i[email id name]) + end + end + end +end diff --git a/spec/blueprints/leaf_blueprints_spec.rb b/spec/blueprints/leaf_blueprints_spec.rb new file mode 100644 index 000000000..0b0fac2ed --- /dev/null +++ b/spec/blueprints/leaf_blueprints_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'rails_helper' + +## +# Leaf Blueprint Tests +# +# REQUIREMENT: Each leaf blueprint must produce output that matches the current +# as_json output shape, including the `_destroy: false` key that Rails +# accepts_nested_attributes_for expects. This ensures Vue components that +# consume this data continue to work without changes. +# +RSpec.describe 'Leaf Blueprints' do + let_it_be(:srg) do + srg_xml = Rails.root.join('db/seeds/srgs/U_GPOS_SRG_V3R3_Manual-xccdf.xml').read + parsed = Xccdf::Benchmark.parse(srg_xml) + srg = SecurityRequirementsGuide.from_mapping(parsed) + srg.xml = srg_xml + srg.save! + srg + end + let_it_be(:component) { create(:component, based_on: srg) } + let_it_be(:rule) { component.rules.first } + + describe CheckBlueprint do + let(:check) { rule.checks.first } + + it 'includes check fields and _destroy flag' do + json = CheckBlueprint.render_as_hash(check) + + expect(json).to have_key(:id) + expect(json).to have_key(:system) + expect(json).to have_key(:content_ref_name) + expect(json).to have_key(:content_ref_href) + expect(json).to have_key(:content) + expect(json).to have_key(:_destroy) + expect(json[:_destroy]).to be false + end + + it 'excludes check timestamps and base_rule_id' do + json = CheckBlueprint.render_as_hash(check) + + expect(json).not_to have_key(:created_at) + expect(json).not_to have_key(:updated_at) + expect(json).not_to have_key(:base_rule_id) + end + + it 'matches the shape of the current as_json output' do + blueprint_output = CheckBlueprint.render_as_hash(check) + legacy_output = check.as_json.merge(_destroy: false).symbolize_keys + legacy_output = legacy_output.except(:created_at, :updated_at, :base_rule_id) + + expect(blueprint_output.keys.sort).to eq(legacy_output.keys.sort) + end + end + + describe DisaRuleDescriptionBlueprint do + let(:drd) { rule.disa_rule_descriptions.first } + + it 'includes DISA description fields and _destroy flag' do + json = DisaRuleDescriptionBlueprint.render_as_hash(drd) + + expect(json).to have_key(:id) + expect(json).to have_key(:vuln_discussion) + expect(json).to have_key(:mitigations) + expect(json).to have_key(:documentable) + expect(json).to have_key(:severity_override_guidance) + expect(json).to have_key(:_destroy) + end + + it 'excludes DISA description timestamps and base_rule_id' do + json = DisaRuleDescriptionBlueprint.render_as_hash(drd) + + expect(json).not_to have_key(:created_at) + expect(json).not_to have_key(:updated_at) + expect(json).not_to have_key(:base_rule_id) + end + end + + describe RuleDescriptionBlueprint do + let(:rd) do + rule.rule_descriptions.first || + RuleDescription.create!(base_rule: rule, description: 'Test description') + end + + it 'includes rule description fields and _destroy flag' do + json = RuleDescriptionBlueprint.render_as_hash(rd) + + expect(json).to have_key(:id) + expect(json).to have_key(:description) + expect(json).to have_key(:_destroy) + end + + it 'excludes rule description timestamps and base_rule_id' do + json = RuleDescriptionBlueprint.render_as_hash(rd) + + expect(json).not_to have_key(:created_at) + expect(json).not_to have_key(:updated_at) + expect(json).not_to have_key(:base_rule_id) + end + end + + describe AdditionalAnswerBlueprint do + let_it_be(:question) do + AdditionalQuestion.find_or_create_by!( + name: 'Test Question', + component: component, + question_type: 'freeform' + ) + end + let_it_be(:answer) do + AdditionalAnswer.find_or_create_by!( + additional_question: question, + rule: rule, + answer: 'Test answer content' + ) + end + + it 'includes expected fields' do + json = AdditionalAnswerBlueprint.render_as_hash(answer) + + expect(json).to have_key(:id) + expect(json).to have_key(:additional_question_id) + expect(json).to have_key(:answer) + end + + it 'excludes rule_id and timestamps (matches current as_json.except pattern)' do + json = AdditionalAnswerBlueprint.render_as_hash(answer) + + expect(json).not_to have_key(:rule_id) + expect(json).not_to have_key(:created_at) + expect(json).not_to have_key(:updated_at) + end + end +end diff --git a/spec/blueprints/review_membership_blueprints_spec.rb b/spec/blueprints/review_membership_blueprints_spec.rb new file mode 100644 index 000000000..862357dbe --- /dev/null +++ b/spec/blueprints/review_membership_blueprints_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Review and Membership Blueprints' do + let_it_be(:user) { create(:user, name: 'Test Reviewer', email: 'reviewer@test.com') } + + let(:reviewer_name) { user.name } + let(:reviewer_email) { user.email } + + let_it_be(:project) { create(:project) } + let_it_be(:srg) { SecurityRequirementsGuide.first || create(:security_requirements_guide) } + let_it_be(:component) { create(:component, project: project, based_on: srg) } + let_it_be(:rule) { component.rules.first } + + describe ReviewBlueprint do + let_it_be(:review) { Review.create!(user: user, rule: rule, action: 'comment', comment: 'Looks good') } + + it 'includes id, action, comment, created_at, and name' do + json = ReviewBlueprint.render_as_hash(review) + + expect(json[:id]).to eq(review.id) + expect(json[:action]).to eq('comment') + expect(json[:comment]).to eq('Looks good') + expect(json[:created_at]).to be_present + expect(json[:name]).to eq(reviewer_name) + end + + it 'excludes user_id, rule_id, updated_at (matches Rule#as_json strip pattern)' do + json = ReviewBlueprint.render_as_hash(review) + + expect(json).not_to have_key(:user_id) + expect(json).not_to have_key(:rule_id) + expect(json).not_to have_key(:updated_at) + end + + it 'handles nil user gracefully' do + orphan_review = Review.new(action: 'comment', comment: 'test') + json = ReviewBlueprint.render_as_hash(orphan_review) + + expect(json[:name]).to be_nil + end + end + + describe MembershipBlueprint do + let_it_be(:membership) do + Membership.find_or_create_by!(user: user, membership: component, role: 'author') + end + + it 'includes id, user_id, role, name, email, membership_type' do + json = MembershipBlueprint.render_as_hash(membership) + + expect(json[:id]).to eq(membership.id) + expect(json[:user_id]).to eq(user.id) + expect(json[:role]).to eq('author') + expect(json[:name]).to eq(reviewer_name) + expect(json[:email]).to eq(reviewer_email) + expect(json[:membership_type]).to eq('Component') + end + + it 'excludes timestamps' do + json = MembershipBlueprint.render_as_hash(membership) + + expect(json).not_to have_key(:created_at) + expect(json).not_to have_key(:updated_at) + end + end + + describe UserBlueprint do + it 'includes only id, name, email' do + json = UserBlueprint.render_as_hash(user) + + expect(json.keys.sort).to eq(%i[email id name]) + expect(json[:id]).to eq(user.id) + expect(json[:name]).to eq(reviewer_name) + expect(json[:email]).to eq(reviewer_email) + end + + it 'does NOT include sensitive fields' do + json = UserBlueprint.render_as_hash(user) + + expect(json).not_to have_key(:encrypted_password) + expect(json).not_to have_key(:admin) + expect(json).not_to have_key(:provider) + expect(json).not_to have_key(:uid) + expect(json).not_to have_key(:reset_password_token) + end + end +end diff --git a/spec/blueprints/rule_blueprint_spec.rb b/spec/blueprints/rule_blueprint_spec.rb new file mode 100644 index 000000000..c62b39924 --- /dev/null +++ b/spec/blueprints/rule_blueprint_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'rails_helper' + +## +# RuleBlueprint Tests +# +# REQUIREMENT: The :editor view must produce output that is field-compatible +# with the current Rule#as_json override, so Vue components continue to work. +# The :navigator view is a lightweight subset for the sidebar rule list. +# +RSpec.describe 'RuleBlueprint' do + let_it_be(:srg) do + srg_xml = Rails.root.join('db/seeds/srgs/U_GPOS_SRG_V3R3_Manual-xccdf.xml').read + parsed = Xccdf::Benchmark.parse(srg_xml) + srg = SecurityRequirementsGuide.from_mapping(parsed) + srg.xml = srg_xml + srg.save! + srg + end + let_it_be(:component) { create(:component, based_on: srg) } + let_it_be(:rule) do + component.rules.eager_load( + :reviews, :disa_rule_descriptions, :rule_descriptions, :checks, + :additional_answers, { satisfies: :srg_rule }, { satisfied_by: :srg_rule }, + { srg_rule: %i[disa_rule_descriptions rule_descriptions checks security_requirements_guide] } + ).first + end + + describe ':editor view' do + let(:json) { RuleBlueprint.render_as_hash(rule, view: :editor) } + + it 'includes base rule columns' do + %i[id rule_id title version rule_severity rule_weight status + status_justification fixtext fixtext_fixref ident ident_system + vendor_comments artifact_description component_id locked + review_requestor_id changes_requested vuln_id legacy_ids + inspec_control_body inspec_control_file locked_fields].each do |field| + expect(json).to have_key(field), "Missing field: #{field}" + end + end + + it 'includes computed fields' do + expect(json).to have_key(:nist_control_family) + expect(json).to have_key(:srg_id) + expect(json).to have_key(:srg_info) + expect(json[:srg_info]).to have_key(:version) + end + + it 'includes nested associations as _attributes keys' do + expect(json).to have_key(:rule_descriptions_attributes) + expect(json).to have_key(:disa_rule_descriptions_attributes) + expect(json).to have_key(:checks_attributes) + expect(json).to have_key(:additional_answers_attributes) + expect(json).to have_key(:srg_rule_attributes) + end + + it 'includes reviews without user_id, rule_id, updated_at' do + expect(json).to have_key(:reviews) + if json[:reviews].any? + review = json[:reviews].first + expect(review).to have_key(:id) + expect(review).to have_key(:name) + expect(review).not_to have_key(:user_id) + expect(review).not_to have_key(:rule_id) + end + end + + it 'includes satisfies and satisfied_by arrays' do + expect(json).to have_key(:satisfies) + expect(json).to have_key(:satisfied_by) + expect(json[:satisfies]).to be_an(Array) + expect(json[:satisfied_by]).to be_an(Array) + end + + it 'excludes type and deleted_at (internal STI/soft-delete fields)' do + expect(json).not_to have_key(:type) + expect(json).not_to have_key(:deleted_at) + end + + it 'generates zero N+1 queries when rule is properly eager-loaded' do + # Force the rule into memory + loaded_rule = rule + + srg_queries = [] + callback = lambda { |_name, _start, _finish, _id, payload| + sql = payload[:sql] + srg_queries << sql if sql.include?('security_requirements_guides') && sql.exclude?('SCHEMA') + } + + ActiveSupport::Notifications.subscribed(callback, 'sql.active_record') do + RuleBlueprint.render_as_hash(loaded_rule, view: :editor) + end + + expect(srg_queries).to be_empty, + "Expected 0 SRG queries, got #{srg_queries.length}" + end + end + + describe ':navigator view' do + let(:json) { RuleBlueprint.render_as_hash(rule, view: :navigator) } + + it 'includes only sidebar-needed fields' do + %i[id rule_id title version status rule_severity locked + review_requestor_id changes_requested].each do |field| + expect(json).to have_key(field), "Missing navigator field: #{field}" + end + end + + it 'excludes heavy fields not needed for sidebar' do + %i[inspec_control_body inspec_control_file fixtext + vendor_comments artifact_description].each do |field| + expect(json).not_to have_key(field), "Navigator should not include: #{field}" + end + end + + it 'excludes nested associations' do + expect(json).not_to have_key(:reviews) + expect(json).not_to have_key(:rule_descriptions_attributes) + expect(json).not_to have_key(:disa_rule_descriptions_attributes) + expect(json).not_to have_key(:checks_attributes) + end + end + + describe 'collection rendering' do + it 'renders an array of rules' do + rules = component.rules.eager_load( + :reviews, :disa_rule_descriptions, :checks, + srg_rule: :security_requirements_guide + ).limit(5).to_a + + result = RuleBlueprint.render_as_hash(rules, view: :navigator) + + expect(result).to be_an(Array) + expect(result.length).to eq(5) + expect(result.first).to have_key(:id) + end + end +end diff --git a/spec/blueprints/stig_srg_blueprints_spec.rb b/spec/blueprints/stig_srg_blueprints_spec.rb new file mode 100644 index 000000000..f7001c445 --- /dev/null +++ b/spec/blueprints/stig_srg_blueprints_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Stig and SRG Blueprints' do + let_it_be(:srg) do + srg_xml = Rails.root.join('db/seeds/srgs/U_GPOS_SRG_V3R3_Manual-xccdf.xml').read + parsed = Xccdf::Benchmark.parse(srg_xml) + srg = SecurityRequirementsGuide.from_mapping(parsed) + srg.xml = srg_xml + srg.save! + srg + end + let_it_be(:stig) do + s = create(:stig) + s.update_columns(xml: '') + s + end + + describe StigBlueprint do + describe ':index view' do + let(:json) { StigBlueprint.render_as_hash(stig, view: :index) } + + it 'includes listing fields' do + %i[id stig_id name title version benchmark_date].each do |f| + expect(json).to have_key(f), "Missing :index field: #{f}" + end + end + + it 'includes severity_counts' do + expect(json).to have_key(:severity_counts) + expect(json[:severity_counts]).to have_key(:high) + expect(json[:severity_counts]).to have_key(:medium) + expect(json[:severity_counts]).to have_key(:low) + end + + it 'excludes xml from index view' do + expect(json).not_to have_key(:xml) + end + + it 'does NOT include description (not needed on index)' do + expect(json).not_to have_key(:description) + end + end + + describe ':show view' do + let(:json) { StigBlueprint.render_as_hash(stig, view: :show) } + + it 'includes detail fields' do + %i[id stig_id name title version benchmark_date description].each do |f| + expect(json).to have_key(f), "Missing :show field: #{f}" + end + end + + it 'excludes xml from show view' do + expect(json).not_to have_key(:xml) + end + + it 'includes stig_rules array' do + expect(json).to have_key(:stig_rules) + expect(json[:stig_rules]).to be_an(Array) + end + end + end + + describe SrgBlueprint do + describe ':index view' do + let(:json) { SrgBlueprint.render_as_hash(srg, view: :index) } + + it 'includes listing fields' do + %i[id srg_id name title version].each do |f| + expect(json).to have_key(f), "Missing :index field: #{f}" + end + end + + it 'includes severity_counts' do + expect(json).to have_key(:severity_counts) + end + + it 'excludes xml from SRG index view' do + expect(json).not_to have_key(:xml) + end + end + + describe ':show view' do + let(:json) { SrgBlueprint.render_as_hash(srg, view: :show) } + + it 'excludes xml from SRG show view' do + expect(json).not_to have_key(:xml) + end + + it 'includes srg_rules array' do + expect(json).to have_key(:srg_rules) + expect(json[:srg_rules]).to be_an(Array) + expect(json[:srg_rules].length).to be > 0 + end + end + end + + describe 'collection rendering' do + it 'renders a collection of STIGs without xml' do + stigs = Stig.with_severity_counts.limit(3).to_a + result = StigBlueprint.render_as_hash(stigs, view: :index) + + expect(result).to be_an(Array) + result.each do |item| + expect(item).not_to have_key(:xml) + expect(item).to have_key(:severity_counts) + end + end + end +end diff --git a/spec/factories/reviews.rb b/spec/factories/reviews.rb new file mode 100644 index 000000000..a54c6a05c --- /dev/null +++ b/spec/factories/reviews.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :review do + user + rule + action { 'request_review' } + comment { 'Requesting review' } + end +end diff --git a/spec/factories/rules.rb b/spec/factories/rules.rb new file mode 100644 index 000000000..1017fe01d --- /dev/null +++ b/spec/factories/rules.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :rule do + component { create(:component, :skip_rules) } + srg_rule { component.based_on.srg_rules.first || create(:srg_rule, security_requirements_guide: component.based_on) } + sequence(:rule_id) { |n| format('%06d', n) } + status { 'Not Yet Determined' } + rule_severity { 'medium' } + version { 'ABCD-00-000001' } + ident { 'CCI-000366' } + title { 'Test Rule' } + end +end diff --git a/spec/models/query_performance_spec.rb b/spec/models/query_performance_spec.rb new file mode 100644 index 000000000..2a049989f --- /dev/null +++ b/spec/models/query_performance_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# Performance regression tests for query optimizations (v2.3.3 perf cards H2, H3, M1-M4). +# Verify that optimized query patterns produce correct results with fewer queries. +RSpec.describe 'Query performance optimizations' do + # Shared SRG — reuse to avoid expensive XML import per test + let_it_be(:srg) { SecurityRequirementsGuide.first || create(:security_requirements_guide) } + + # H2: UsersController#index audit query must be bounded + describe 'UsersController#index audit limit', type: :request do + include Devise::Test::IntegrationHelpers + + let(:admin) { create(:user, admin: true) } + + before { Rails.application.reload_routes! } + + it 'returns a bounded number of audit history records' do + sign_in admin + get '/users' + expect(response).to have_http_status(:ok) + # Verify controller applies .limit() — @histories should be bounded + histories = controller.instance_variable_get(:@histories) + expect(histories).not_to be_nil + end + end + + # H3: Project#details — GROUP BY instead of 9 separate queries + describe 'Project#details' do + let(:project) { create(:project) } + let(:component) { create(:component, :skip_rules, project: project, based_on: srg) } + let(:srg_rule) { srg.srg_rules.first } + + before do + create(:rule, component: component, srg_rule: srg_rule, status: 'Applicable - Configurable', locked: false) + create(:rule, component: component, srg_rule: srg_rule, status: 'Applicable - Configurable', locked: true) + create(:rule, component: component, srg_rule: srg_rule, status: 'Applicable - Inherently Meets', locked: false) + create(:rule, component: component, srg_rule: srg_rule, status: 'Not Applicable', locked: false) + create(:rule, component: component, srg_rule: srg_rule, status: 'Not Yet Determined', locked: false) + end + + it 'returns correct status counts' do + details = project.details + expect(details[:ac]).to eq(2) + expect(details[:aim]).to eq(1) + expect(details[:adnm]).to eq(0) + expect(details[:na]).to eq(1) + expect(details[:nyd]).to eq(1) + expect(details[:total]).to eq(5) + end + + it 'returns correct lock and review counts' do + details = project.details + expect(details[:lck]).to eq(1) + expect(details[:nur]).to eq(4) # non-locked, no review_requestor + expect(details[:ur]).to eq(0) + end + + it 'uses at most 4 queries for rules table' do + # Warm up association + project.rules.to_a + + query_count = 0 + counter = lambda { |*, payload| + query_count += 1 if payload[:sql] =~ /SELECT.*"base_rules"/i && payload[:name] != 'SCHEMA' + } + ActiveSupport::Notifications.subscribed(counter, 'sql.active_record') do + project.details + end + expect(query_count).to be <= 4, "Expected at most 4 queries, got #{query_count}" + end + end + + # M1: Project#available_members — SQL subtraction + describe 'Project#available_members' do + let(:project) { create(:project) } + let!(:member) { create(:user) } + let!(:non_member) { create(:user) } + + before do + create(:membership, user: member, membership: project, membership_type: 'Project') + end + + it 'excludes project members' do + available = project.available_members + available_ids = available.map(&:id) + expect(available_ids).to include(non_member.id) + expect(available_ids).not_to include(member.id) + end + + it 'uses SQL subtraction (WHERE NOT IN), not Ruby set subtraction' do + # If properly implemented, available_members returns an ActiveRecord::Relation + # that can be further scoped, not a plain Array from Ruby subtraction + result = project.available_members + expect(result).to be_a(ActiveRecord::Relation) + end + end + + # M2: Project#available_components — column filtering + describe 'Project#available_components' do + let(:project) { create(:project) } + + it 'returns released components not already in project' do + released = create(:component, :skip_rules, released: true, based_on: srg) + _unreleased = create(:component, :skip_rules, released: false, based_on: srg) + + available = project.available_components + available_ids = available.map(&:id) + expect(available_ids).to include(released.id) + end + + it 'excludes components already in the project' do + existing = create(:component, :skip_rules, project: project, released: true, based_on: srg) + other = create(:component, :skip_rules, released: true, based_on: srg) + + available = project.available_components + available_ids = available.map(&:id) + expect(available_ids).not_to include(existing.id) + expect(available_ids).to include(other.id) + end + end + + # M3: Component#reviews — pluck instead of full rule load + describe 'Component#reviews' do + let(:component) { create(:component, :skip_rules, based_on: srg) } + let(:srg_rule) { srg.srg_rules.first } + let(:rule) { create(:rule, component: component, srg_rule: srg_rule, rule_id: '000001') } + let(:reviewer) { create(:user) } + + before do + Review.create!(user: reviewer, rule: rule, action: 'request_review', comment: 'Please review') + end + + it 'returns reviews with correct displayed_rule_name' do + reviews = component.reviews + expect(reviews.length).to eq(1) + expect(reviews.first['displayed_rule_name']).to eq("#{component.prefix}-000001") + end + + it 'limits to 20 reviews' do + # Already has 1 from before block + 19.times { |i| Review.create!(user: reviewer, rule: rule, action: 'comment', comment: "Comment #{i}") } + expect(component.reviews.length).to eq(20) + + # Add one more — should still be 20 + Review.create!(user: reviewer, rule: rule, action: 'comment', comment: 'Extra') + expect(component.reviews.length).to eq(20) + end + + it 'does not load full rule objects for name lookup' do + query_log = [] + counter = lambda { |*, payload| + query_log << payload[:sql] if payload[:sql] =~ /SELECT.*"base_rules"/i && payload[:name] != 'SCHEMA' + } + ActiveSupport::Notifications.subscribed(counter, 'sql.active_record') do + component.reviews + end + # Should use pluck or select, not SELECT * + query_log.each do |sql| + expect(sql).not_to match(/SELECT "base_rules"\.\*/), "Expected optimized SELECT, got: #{sql}" + end + end + end +end diff --git a/spec/models/rule_as_json_performance_spec.rb b/spec/models/rule_as_json_performance_spec.rb new file mode 100644 index 000000000..8a09276f7 --- /dev/null +++ b/spec/models/rule_as_json_performance_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'rails_helper' + +## +# Rule#as_json Performance Tests +# +# REQUIREMENT: Rule#as_json must NOT fire individual queries for +# SecurityRequirementsGuide. A component with 200 rules must not +# generate 200 separate SRG queries — the SRG version is the same +# for all rules in a component. +# +# The old code did: +# SecurityRequirementsGuide.find_by(id: srg_rule&.security_requirements_guide_id)&.version +# which loaded the FULL SRG record (including multi-MB xml) per rule. +# +RSpec.describe 'Rule#as_json performance' do + # Use real models to test actual query behavior + let_it_be(:srg) do + srg_xml = Rails.root.join('db/seeds/srgs/U_GPOS_SRG_V3R3_Manual-xccdf.xml').read + parsed = Xccdf::Benchmark.parse(srg_xml) + srg = SecurityRequirementsGuide.from_mapping(parsed) + srg.xml = srg_xml + srg.save! + srg + end + let_it_be(:component) { create(:component, based_on: srg) } + + describe 'srg_info.version' do + it 'returns the correct SRG version' do + rule = component.rules.eager_load(srg_rule: :security_requirements_guide).first + json = RuleBlueprint.render_as_hash(rule, view: :editor) + + expect(json[:srg_info]).to be_present + expect(json[:srg_info][:version]).to eq(srg.version) + end + + it 'does NOT query SecurityRequirementsGuide table during as_json' do + # Eager-load the rule with its associations as the controller does + rule = component.rules.eager_load( + :reviews, :disa_rule_descriptions, :checks, + :additional_answers, :satisfies, :satisfied_by, + srg_rule: :security_requirements_guide + ).first + + # Count queries during as_json — should be 0 for SRG + srg_queries = [] + callback = lambda { |_name, _start, _finish, _id, payload| + sql = payload[:sql] + srg_queries << sql if sql.include?('security_requirements_guides') && sql.exclude?('SCHEMA') + } + + ActiveSupport::Notifications.subscribed(callback, 'sql.active_record') do + rule.as_json + end + + expect(srg_queries).to be_empty, + "Expected 0 SRG queries during as_json, got #{srg_queries.length}:\n#{srg_queries.join("\n")}" + end + + it 'does NOT query SRG table when serializing a collection of rules' do + # Force-load the rules (and all eager-loaded associations) BEFORE measuring. + # We want to count only queries fired by as_json, not by the initial load. + rules = component.rules.eager_load( + :reviews, :disa_rule_descriptions, :checks, + :additional_answers, :satisfies, :satisfied_by, + srg_rule: :security_requirements_guide + ).limit(10).to_a + + srg_queries = [] + callback = lambda { |_name, _start, _finish, _id, payload| + sql = payload[:sql] + srg_queries << sql if sql.include?('security_requirements_guides') && sql.exclude?('SCHEMA') + } + + ActiveSupport::Notifications.subscribed(callback, 'sql.active_record') do + rules.map(&:as_json) + end + + expect(srg_queries).to be_empty, + "Expected 0 SRG queries for #{rules.length} rules, got #{srg_queries.length}" + end + + it 'handles rules without an srg_rule gracefully' do + rule = component.rules.first + allow(rule).to receive(:srg_rule).and_return(nil) + + json = RuleBlueprint.render_as_hash(rule, view: :editor) + expect(json[:srg_info]).to eq({ version: nil }) + end + end +end diff --git a/spec/models/rules_spec.rb b/spec/models/rules_spec.rb index 5753d7672..1cd4f644f 100644 --- a/spec/models/rules_spec.rb +++ b/spec/models/rules_spec.rb @@ -248,7 +248,7 @@ end end - context 'as_json satisfaction serialization' do + context 'RuleBlueprint satisfaction serialization' do # REQUIREMENT: Satisfaction relationships must include srg_id so the frontend # can display SRG requirement IDs (e.g., "SRG-OS-000480") for satisfied rules. # The srg_id field must be consistent with the parent rule's srg_id field. @@ -271,7 +271,7 @@ end it 'includes srg_id in the satisfies array' do - json = @p1r1.as_json + json = RuleBlueprint.render_as_hash(@p1r1, view: :editor) satisfies_list = json[:satisfies] expect(satisfies_list).to be_an(Array) expect(satisfies_list.size).to eq(1) @@ -284,7 +284,7 @@ it 'includes srg_id in the satisfied_by array' do @p1r2.reload - json = @p1r2.as_json + json = RuleBlueprint.render_as_hash(@p1r2, view: :editor) satisfied_by_list = json[:satisfied_by] expect(satisfied_by_list).to be_an(Array) expect(satisfied_by_list.size).to eq(1) @@ -295,17 +295,15 @@ expect(satisfier[:srg_id]).to eq(@p1r1.srg_rule.version) end - it 'overrides the DB srg_id column with srg_rule.version using string key' do - json = @p1r1.as_json - # Must be a string key to properly override super's string key from ActiveRecord - expect(json).to have_key('srg_id') - expect(json['srg_id']).to eq(@p1r1.srg_rule.version) - # Symbol key should NOT also exist (would cause dual-key bug) - expect(json).not_to have_key(:srg_id) + it 'includes srg_id derived from srg_rule.version' do + json = RuleBlueprint.render_as_hash(@p1r1, view: :editor) + # Blueprinter uses symbol keys consistently + expect(json).to have_key(:srg_id) + expect(json[:srg_id]).to eq(@p1r1.srg_rule.version) end it 'does NOT include version in satisfaction objects (frontend uses srg_id)' do - json = @p1r1.as_json + json = RuleBlueprint.render_as_hash(@p1r1, view: :editor) satisfies_list = json[:satisfies] satisfied = satisfies_list.first @@ -319,7 +317,7 @@ it 'does NOT include version in satisfied_by objects (frontend uses srg_id)' do @p1r2.reload - json = @p1r2.as_json + json = RuleBlueprint.render_as_hash(@p1r2, view: :editor) satisfied_by_list = json[:satisfied_by] satisfier = satisfied_by_list.first @@ -337,9 +335,9 @@ rule_severity: 'medium', srg_rule: nil ) - # Verify as_json handles nil srg_rule in map without error - json = rule_no_srg.as_json - expect(json['srg_id']).to be_nil + # Verify blueprint handles nil srg_rule without error + json = RuleBlueprint.render_as_hash(rule_no_srg, view: :editor) + expect(json[:srg_id]).to be_nil expect(json[:satisfies]).to eq([]) end end @@ -463,9 +461,8 @@ end end - context 'as_json with missing SRG data' do + context 'RuleBlueprint with missing SRG data' do it 'handles rule with nil srg_rule gracefully' do - # Create a rule without an srg_rule rule_without_srg = Rule.create( component: @p1_c1, rule_id: 'NO-SRG-001', @@ -473,15 +470,13 @@ rule_severity: 'medium', srg_rule: nil ) - # Should not raise an error json = nil - expect { json = rule_without_srg.as_json }.not_to raise_error + expect { json = RuleBlueprint.render_as_hash(rule_without_srg, view: :editor) }.not_to raise_error expect(json[:srg_rule_attributes]).to be_nil expect(json[:srg_info][:version]).to be_nil end it 'handles rule with srg_rule but nil security_requirements_guide_id' do - # Create an SRG rule without a security_requirements_guide_id orphan_srg_rule = SrgRule.create( version: 'SRG-TEST-001', title: 'Test SRG Rule', @@ -494,15 +489,14 @@ rule_severity: 'medium', srg_rule: orphan_srg_rule ) - # Should not raise an error json = nil - expect { json = rule_with_orphan_srg.as_json }.not_to raise_error + expect { json = RuleBlueprint.render_as_hash(rule_with_orphan_srg, view: :editor) }.not_to raise_error expect(json[:srg_info][:version]).to be_nil end it 'returns correct SRG version when all data is present' do # Use the existing @p1r1 which has a valid srg_rule - json = @p1r1.as_json + json = RuleBlueprint.render_as_hash(@p1r1, view: :editor) expect(json[:srg_info][:version]).not_to be_nil srg = @p1r1.srg_rule.security_requirements_guide expect(json[:srg_info][:version]).to eq(srg.version) diff --git a/spec/models/user_authentication_edge_cases_spec.rb b/spec/models/user_authentication_edge_cases_spec.rb index 9734968bb..41b71bfe3 100644 --- a/spec/models/user_authentication_edge_cases_spec.rb +++ b/spec/models/user_authentication_edge_cases_spec.rb @@ -72,6 +72,25 @@ end end + describe 'backtrace logging in all environments' do + it 'logs backtrace at debug level without environment gate' do + auth = base_auth + auth.info.email = 'error_test@example.com' + + # Force an error after email extraction succeeds but during user create/update + allow(User).to receive(:create_or_update_user_from_auth) + .and_raise(StandardError, 'simulated failure') + + allow(Rails.logger).to receive(:error) + allow(Rails.logger).to receive(:debug) + + expect { User.from_omniauth(auth) }.to raise_error(StandardError, 'simulated failure') + + # Verify backtrace was logged (contains file paths from the stack) + expect(Rails.logger).to have_received(:debug).with(a_string_including('.rb:')) + end + end + describe 'LDAP email array handling' do let(:ldap_auth) { mock_omniauth_response(build(:user), provider: 'ldap') } diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb index be39f9677..88fa84f72 100644 --- a/spec/requests/api/search_spec.rb +++ b/spec/requests/api/search_spec.rb @@ -614,4 +614,49 @@ expect(component_ids).to include(component1.id) end end + + describe 'performance: xml blob exclusion' do + # REQUIREMENT: Search must NOT load multi-MB xml columns from STIGs/SRGs. + # The search only needs id, name, title, version, description — not the + # full XCCDF XML document. Loading xml on every search keystroke caused + # Heroku dyno memory blowout (R14/R15). + + it 'search_stigs does not load the xml column' do + sign_in admin_user + + xml_queries = [] + callback = lambda { |_name, _start, _finish, _id, payload| + sql = payload[:sql] + # Detect if the query selects all columns (stigs.*) or explicitly includes xml + xml_queries << sql if sql.include?('stigs') && (sql.include?('.*') || sql.include?('"xml"')) + } + + ActiveSupport::Notifications.subscribed(callback, 'sql.active_record') do + get search_path, params: { q: 'STIG' } + end + + expect(response).to have_http_status(:success) + expect(xml_queries).to be_empty, + "Search loaded stigs.xml or stigs.* — should use .select() to exclude xml:\n#{xml_queries.join("\n")}" + end + + it 'search_srgs does not load the xml column' do + sign_in admin_user + + xml_queries = [] + callback = lambda { |_name, _start, _finish, _id, payload| + sql = payload[:sql] + xml_queries << sql if sql.include?('security_requirements_guides') && + (sql.include?('.*') || sql.include?('"xml"')) + } + + ActiveSupport::Notifications.subscribed(callback, 'sql.active_record') do + get search_path, params: { q: 'SRG' } + end + + expect(response).to have_http_status(:success) + expect(xml_queries).to be_empty, + 'Search loaded srgs.xml — should use .select() to exclude xml' + end + end end diff --git a/spec/requests/stigs_spec.rb b/spec/requests/stigs_spec.rb index bd361028a..aa7a6e0da 100644 --- a/spec/requests/stigs_spec.rb +++ b/spec/requests/stigs_spec.rb @@ -280,4 +280,33 @@ expect(rule).not_to have_key('rule_descriptions_attributes') end end + + describe 'GET /stigs/:id HTML format (performance)' do + # REQUIREMENT: The HTML show page must NOT embed the full xml column + # in the page body. Each STIG xml is 5-50MB; serializing it into a + # v-bind attribute would blow browser memory and page load time. + let(:stig_with_xml) do + s = create(:stig) + xml_marker = '' + s.update_columns(xml: xml_marker) + s + end + + before { sign_in user } + + it 'does not include the xml column in the HTML page body' do + get "/stigs/#{stig_with_xml.id}" + + expect(response).to have_http_status(:success) + expect(response.body).not_to include('UNIQUE_STIG_XML_MARKER_FOR_TEST'), + 'HTML page contains the xml column — use except: [:xml] in to_json' + end + + it 'still loads the STIG detail page with title' do + get "/stigs/#{stig_with_xml.id}" + + expect(response).to have_http_status(:success) + expect(response.body).to include(stig_with_xml.title) + end + end end