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