maquina-generators 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +125 -15
  3. data/lib/generators/maquina/app/app_generator.rb +9 -4
  4. data/lib/generators/maquina/app/templates/README.md.erb +6 -4
  5. data/lib/generators/maquina/app/templates/app/views/home/index.html.erb +18 -5
  6. data/lib/generators/maquina/app/templates/config/database.yml.tt +12 -0
  7. data/lib/generators/maquina/clave/clave_generator.rb +48 -0
  8. data/lib/generators/maquina/clave/templates/app/controllers/registration/verifications_controller.rb.tt +10 -1
  9. data/lib/generators/maquina/clave/templates/app/controllers/session/verifications_controller.rb.tt +10 -1
  10. data/lib/generators/maquina/clave/templates/app/models/email_verification.rb.tt +18 -0
  11. data/lib/generators/maquina/clave/templates/config/locales/clave.en.yml +2 -0
  12. data/lib/generators/maquina/clave/templates/config/locales/clave.es.yml +2 -0
  13. data/lib/generators/maquina/csp_reports/USAGE +18 -0
  14. data/lib/generators/maquina/csp_reports/csp_reports_generator.rb +231 -0
  15. data/lib/generators/maquina/csp_reports/templates/app/controllers/csp_reports_controller.rb +24 -0
  16. data/lib/generators/maquina/csp_reports/templates/app/models/security/csp_violation.rb +89 -0
  17. data/lib/generators/maquina/csp_reports/templates/app/models/security/csp_violation_report.rb +30 -0
  18. data/lib/generators/maquina/csp_reports/templates/app/views/backstage/security/_csp_violations.html.erb +42 -0
  19. data/lib/generators/maquina/csp_reports/templates/db/create_csp_violations.rb.tt +23 -0
  20. data/lib/generators/maquina/mission_control_jobs/mission_control_jobs_generator.rb +32 -1
  21. data/lib/generators/maquina/mission_control_jobs/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
  22. data/lib/generators/maquina/mission_control_jobs/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
  23. data/lib/generators/maquina/mission_control_jobs/templates/app/views/layouts/_admin_navigation.html.erb.tt +18 -6
  24. data/lib/generators/maquina/mission_control_jobs/templates/app/views/layouts/admin.html.erb +21 -0
  25. data/lib/generators/maquina/rack_attack/USAGE +8 -6
  26. data/lib/generators/maquina/rack_attack/rack_attack_generator.rb +6 -2
  27. data/lib/generators/maquina/rack_attack/templates/config/initializers/rack_attack.rb.tt +139 -57
  28. data/lib/generators/maquina/security/USAGE +15 -0
  29. data/lib/generators/maquina/security/security_generator.rb +278 -0
  30. data/lib/generators/maquina/security/templates/app/controllers/backstage/security_controller.rb.tt +45 -0
  31. data/{test/tmp/app/controllers/backstage_controller.rb → lib/generators/maquina/security/templates/app/controllers/backstage_controller.rb.tt} +0 -1
  32. data/lib/generators/maquina/security/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
  33. data/lib/generators/maquina/security/templates/app/models/security/abuse_event.rb +36 -0
  34. data/lib/generators/maquina/security/templates/app/models/security/abuse_report.rb +112 -0
  35. data/lib/generators/maquina/security/templates/app/models/security/record.rb +6 -0
  36. data/lib/generators/maquina/security/templates/app/views/backstage/security/_addresses.html.erb +37 -0
  37. data/lib/generators/maquina/security/templates/app/views/backstage/security/_blocked.html.erb +53 -0
  38. data/lib/generators/maquina/security/templates/app/views/backstage/security/_by_day.html.erb +33 -0
  39. data/lib/generators/maquina/security/templates/app/views/backstage/security/_hosts.html.erb +31 -0
  40. data/lib/generators/maquina/security/templates/app/views/backstage/security/_paths.html.erb +33 -0
  41. data/lib/generators/maquina/security/templates/app/views/backstage/security/_recent.html.erb +37 -0
  42. data/lib/generators/maquina/security/templates/app/views/backstage/security/_utc_time.html.erb +2 -0
  43. data/lib/generators/maquina/security/templates/app/views/backstage/security/show.html.erb +47 -0
  44. data/lib/generators/maquina/security/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
  45. data/lib/generators/maquina/security/templates/app/views/layouts/_admin_navigation.html.erb.tt +40 -0
  46. data/lib/generators/maquina/security/templates/app/views/layouts/admin.html.erb +21 -0
  47. data/{test/tmp/app/views/layouts/solid_errors/application.html.erb → lib/generators/maquina/security/templates/app/views/layouts/backstage/security.html.erb} +1 -1
  48. data/lib/generators/maquina/security/templates/config/initializers/rack_attack_events.rb +34 -0
  49. data/lib/generators/maquina/security/templates/db/security_schema.rb.tt +27 -0
  50. data/lib/generators/maquina/solid_errors/solid_errors_generator.rb +32 -1
  51. data/lib/generators/maquina/solid_errors/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
  52. data/lib/generators/maquina/solid_errors/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
  53. data/lib/generators/maquina/solid_errors/templates/app/views/layouts/_admin_navigation.html.erb.tt +18 -6
  54. data/lib/generators/maquina/solid_errors/templates/app/views/layouts/admin.html.erb +21 -0
  55. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/error_mailer/error_occurred.html.erb +65 -0
  56. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/error_mailer/error_occurred.text.erb +30 -0
  57. data/lib/generators/maquina/solid_errors/templates/config/initializers/solid_errors.rb.tt +12 -0
  58. data/lib/maquina_generators/version.rb +1 -1
  59. data/test/generators/maquina/app_generator_test.rb +21 -0
  60. data/test/generators/maquina/clave_generator_test.rb +74 -0
  61. data/test/generators/maquina/csp_reports_generator_test.rb +151 -0
  62. data/test/generators/maquina/mission_control_jobs_generator_test.rb +28 -4
  63. data/test/generators/maquina/rack_attack_generator_test.rb +41 -19
  64. data/test/generators/maquina/security_generator_test.rb +380 -0
  65. data/test/generators/maquina/solid_errors_generator_test.rb +59 -4
  66. data/test/tmp/.gitignore +5 -0
  67. data/test/tmp/.rubocop.yml +11 -0
  68. data/test/tmp/.standard.yml +1 -0
  69. data/test/tmp/Gemfile +12 -2
  70. data/test/tmp/Procfile.dev +3 -0
  71. data/test/tmp/README.md +89 -0
  72. data/test/tmp/app/controllers/home_controller.rb +6 -0
  73. data/test/tmp/app/javascript/application.js +1 -0
  74. data/test/tmp/app/views/home/index.html.erb +204 -0
  75. data/test/tmp/app/views/layouts/application.html.erb +10 -0
  76. data/test/tmp/config/application.rb +15 -0
  77. data/test/tmp/config/database.yml +90 -0
  78. data/test/tmp/config/database.yml.example +90 -0
  79. data/test/tmp/config/environments/development.rb +8 -0
  80. data/test/tmp/config/environments/production.rb +6 -0
  81. data/test/tmp/config/initializers/generators.rb +4 -0
  82. data/test/tmp/config/routes.rb +1 -1
  83. metadata +60 -32
  84. data/test/tmp/app/agents/error_redactor.rb +0 -34
  85. data/test/tmp/app/agents/errors_query.rb +0 -84
  86. data/test/tmp/app/agents/failure_triage.rb +0 -21
  87. data/test/tmp/app/agents/get_exception_tool.rb +0 -26
  88. data/test/tmp/app/agents/list_failures_tool.rb +0 -20
  89. data/test/tmp/app/agents/top_exceptions_tool.rb +0 -18
  90. data/test/tmp/app/helpers/solid_errors_helper.rb +0 -9
  91. data/test/tmp/app/javascript/controllers/backtrace_filter_controller.js +0 -27
  92. data/test/tmp/app/javascript/controllers/clipboard_controller.js +0 -36
  93. data/test/tmp/app/views/layouts/_admin_navigation.html.erb +0 -28
  94. data/test/tmp/app/views/solid_errors/errors/_actions.html.erb +0 -22
  95. data/test/tmp/app/views/solid_errors/errors/_delete_button.html.erb +0 -10
  96. data/test/tmp/app/views/solid_errors/errors/_error_card.html.erb +0 -63
  97. data/test/tmp/app/views/solid_errors/errors/_resolve_button.html.erb +0 -11
  98. data/test/tmp/app/views/solid_errors/errors/index.html.erb +0 -37
  99. data/test/tmp/app/views/solid_errors/errors/show/_actions.html.erb +0 -33
  100. data/test/tmp/app/views/solid_errors/errors/show/_error_details.html.erb +0 -95
  101. data/test/tmp/app/views/solid_errors/errors/show/_header.html.erb +0 -18
  102. data/test/tmp/app/views/solid_errors/errors/show/_properties.html.erb +0 -65
  103. data/test/tmp/app/views/solid_errors/errors/show.html.erb +0 -23
  104. data/test/tmp/app/views/solid_errors/occurrences/_backtrace_line.html.erb +0 -27
  105. data/test/tmp/app/views/solid_errors/occurrences/_collection.html.erb +0 -80
  106. data/test/tmp/app/views/solid_errors/occurrences/_occurrence.html.erb +0 -129
  107. data/test/tmp/bin/failures +0 -37
  108. data/test/tmp/bin/failures-mcp +0 -21
  109. data/test/tmp/config/initializers/solid_errors.rb +0 -12
@@ -0,0 +1,231 @@
1
+ require "rails/generators"
2
+ require "rails/generators/migration"
3
+
4
+ module Maquina
5
+ module Generators
6
+ class CspReportsGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ THROTTLE_LIMIT = 100
12
+ THROTTLE_PERIOD = "5.minutes"
13
+ # Rails' default nonce generator, empty for a request without a session.
14
+ SESSION_NONCE = "->(request) { request.session.id.to_s }"
15
+ POLICY_BLOCK = /^ config\.content_security_policy do \|policy\|\n/
16
+ # The general throttle's exemptions, in the two shapes maquina:rack_attack
17
+ # has shipped: a start_with? list, and a STATIC_FILE_PREFIXES word array.
18
+ COUNTED_IP_EXEMPTIONS = /req\.path\.start_with\?\("\/assets", "\/rails\/active_storage"/
19
+ STATIC_FILE_PREFIXES = /(STATIC_FILE_PREFIXES = %w\[)([^\]]*)(\])/
20
+ SECURITY_PAGE_END = /\n<\/div>\n\z/
21
+
22
+ class_option :path, type: :string, default: "/csp-reports",
23
+ desc: "Path browsers post violation reports to"
24
+ class_option :quiet, type: :boolean, default: false,
25
+ desc: "Suppress post-install instructions"
26
+
27
+ def self.next_migration_number(dirname)
28
+ current = Time.now.utc.strftime("%Y%m%d%H%M%S")
29
+ existing = Dir.glob("#{dirname}/[0-9]*_*.rb").map { |f| File.basename(f).split("_", 2).first }.max
30
+ (existing && existing >= current) ? existing.succ : current
31
+ end
32
+
33
+ # 1. maquina:security owns the database the reports go in.
34
+ def require_security_database
35
+ return if File.exist?(File.join(destination_root, "db/security_schema.rb"))
36
+
37
+ raise Thor::Error, "maquina:csp_reports stores reports in the `security` database. Run `rails g maquina:security --prefix /admin` first."
38
+ end
39
+
40
+ # 2. Models
41
+ def create_models
42
+ copy_file "app/models/security/csp_violation.rb", "app/models/security/csp_violation.rb"
43
+ copy_file "app/models/security/csp_violation_report.rb", "app/models/security/csp_violation_report.rb"
44
+ end
45
+
46
+ # 3. Controller and route
47
+ def create_controller
48
+ copy_file "app/controllers/csp_reports_controller.rb", "app/controllers/csp_reports_controller.rb"
49
+ route %(post "#{options[:path]}", to: "csp_reports#create", as: :csp_reports)
50
+ end
51
+
52
+ # 4. The table: a migration for an existing security database, the schema
53
+ # for a fresh one (db:prepare loads the schema only on create).
54
+ def create_table
55
+ return if File.read(schema_path).include?(%("csp_violations"))
56
+
57
+ migration_template "db/create_csp_violations.rb.tt", "db/security_migrate/create_csp_violations.rb"
58
+ version = Dir.glob(File.join(destination_root, "db/security_migrate/*_create_csp_violations.rb"))
59
+ .map { |f| File.basename(f).split("_", 2).first }.max
60
+ gsub_file "db/security_schema.rb", /define\(version: [\d_]+\)/, "define(version: #{version_with_underscores(version)})"
61
+ inject_into_file "db/security_schema.rb", csp_violations_table, before: /^end\s*\z/
62
+ end
63
+
64
+ # 5. The policy reports to the endpoint, and anonymous requests get a nonce.
65
+ def configure_content_security_policy
66
+ path = "config/initializers/content_security_policy.rb"
67
+ content = File.exist?(File.join(destination_root, path)) ? File.read(File.join(destination_root, path)) : ""
68
+
69
+ if content.include?("report_uri")
70
+ @report_uri_pending = !content.include?(%(report_uri "#{options[:path]}"))
71
+ elsif content.match?(POLICY_BLOCK)
72
+ inject_into_file path, report_uri_line, after: POLICY_BLOCK
73
+ else
74
+ @report_uri_pending = true
75
+ end
76
+
77
+ if content.include?(SESSION_NONCE)
78
+ gsub_file path, SESSION_NONCE, "->(request) { request.session.id.to_s.presence || SecureRandom.base64(16) }"
79
+ end
80
+ end
81
+
82
+ # 6. Rack::Attack: out of the general throttle, capped on its own.
83
+ def configure_rack_attack
84
+ path = "config/initializers/rack_attack.rb"
85
+ full_path = File.join(destination_root, path)
86
+ return @rack_attack_pending = true unless File.exist?(full_path)
87
+
88
+ content = File.read(full_path)
89
+ return if content.include?(%("csp/ip"))
90
+
91
+ if content.match?(COUNTED_IP_EXEMPTIONS)
92
+ # The match stops before the call's closing paren, which stays in place.
93
+ gsub_file path, COUNTED_IP_EXEMPTIONS, 'req.path.start_with?("/assets", "/rails/active_storage", ' + %("#{options[:path]}")
94
+ elsif content.match?(STATIC_FILE_PREFIXES)
95
+ gsub_file path, STATIC_FILE_PREFIXES, "\\1\\2 #{options[:path]}\\3"
96
+ else
97
+ @rack_attack_pending = true
98
+ end
99
+
100
+ append_to_file path, csp_throttle
101
+ end
102
+
103
+ # 7. Daily purge
104
+ def add_recurring_purge
105
+ path = File.join(destination_root, "config/recurring.yml")
106
+ content = File.exist?(path) ? File.read(path) : ""
107
+ return if content.include?("purge_csp_violations")
108
+
109
+ if content.match?(/^production:\n/)
110
+ inject_into_file "config/recurring.yml", recurring_entry, after: /^production:\n/
111
+ else
112
+ @recurring_pending = true
113
+ end
114
+ end
115
+
116
+ # 8. Security page section
117
+ def add_security_page_section
118
+ copy_file "app/views/backstage/security/_csp_violations.html.erb",
119
+ "app/views/backstage/security/_csp_violations.html.erb"
120
+
121
+ page = "app/views/backstage/security/show.html.erb"
122
+ full_path = File.join(destination_root, page)
123
+ content = File.exist?(full_path) ? File.read(full_path) : ""
124
+ return if content.include?("backstage/security/csp_violations")
125
+
126
+ if content.match?(SECURITY_PAGE_END)
127
+ inject_into_file page, security_page_section, before: SECURITY_PAGE_END
128
+ else
129
+ @page_pending = true
130
+ end
131
+ end
132
+
133
+ # 9. Post-install message
134
+ def show_post_install
135
+ return if options[:quiet]
136
+
137
+ say ""
138
+ say "Content Security Policy reports are set up at POST #{options[:path]}.", :green
139
+ say ""
140
+ say "Next steps:", :yellow
141
+ say " - Add the table: bin/rails db:migrate (db:prepare on deploy)"
142
+ say " - Watch the Security page while the policy is report-only, then set"
143
+ say " content_security_policy_report_only = false to enforce it."
144
+ if @report_uri_pending
145
+ say ""
146
+ say "Point the policy at the endpoint in config/initializers/content_security_policy.rb:", :yellow
147
+ say " #{report_uri_line.strip}"
148
+ end
149
+ if @rack_attack_pending
150
+ say ""
151
+ say "Exempt #{options[:path]} from the general throttle in config/initializers/rack_attack.rb and add:", :yellow
152
+ say csp_throttle
153
+ end
154
+ if @recurring_pending
155
+ say ""
156
+ say "Add to config/recurring.yml under your production (or shared) key:", :yellow
157
+ say recurring_entry
158
+ end
159
+ if @page_pending
160
+ say ""
161
+ say "Render the section on the Security page:", :yellow
162
+ say security_page_section
163
+ end
164
+ say ""
165
+ end
166
+
167
+ private
168
+
169
+ def schema_path = File.join(destination_root, "db/security_schema.rb")
170
+
171
+ def migration_version
172
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
173
+ end
174
+
175
+ def version_with_underscores(version)
176
+ version.sub(/\A(\d{4})(\d{2})(\d{2})(\d{6})\z/, '\1_\2_\3_\4')
177
+ end
178
+
179
+ def report_uri_line
180
+ %( # Violations go to CspReportsController (maquina:csp_reports). report-uri\n) +
181
+ %( # alone: adding report-to switches Chrome to the batched Reporting API.\n) +
182
+ %( policy.report_uri "#{options[:path]}"\n)
183
+ end
184
+
185
+ def csp_throttle
186
+ <<~RUBY
187
+
188
+ # Content Security Policy reports (maquina:csp_reports): exempt from the
189
+ # general throttle above — one page with a dozen violations would spend a
190
+ # reader's budget — and capped on their own.
191
+ Rack::Attack.throttle("csp/ip", limit: #{THROTTLE_LIMIT}, period: #{THROTTLE_PERIOD}) do |req|
192
+ (req.env["action_dispatch.remote_ip"]&.to_s || req.ip) if req.path == "#{options[:path]}" && req.post?
193
+ end
194
+ RUBY
195
+ end
196
+
197
+ def recurring_entry
198
+ <<~YAML.gsub(/^/, " ")
199
+ purge_csp_violations:
200
+ command: "Security::CspViolation.expired.delete_all"
201
+ schedule: every day at 4:30am
202
+ YAML
203
+ end
204
+
205
+ def security_page_section
206
+ %(\n\n <%= render "backstage/security/csp_violations", report: Security::CspViolationReport.new %>)
207
+ end
208
+
209
+ def csp_violations_table
210
+ <<~RUBY.gsub(/^(?=.)/, " ")
211
+
212
+ create_table "csp_violations", force: :cascade do |t|
213
+ t.integer "column_number"
214
+ t.datetime "created_at", null: false
215
+ t.string "blocked_uri"
216
+ t.string "disposition"
217
+ t.string "document_uri"
218
+ t.string "effective_directive", null: false
219
+ t.string "ip"
220
+ t.integer "line_number"
221
+ t.string "sample"
222
+ t.string "source_file"
223
+ t.string "user_agent"
224
+ t.index ["created_at"], name: "index_csp_violations_on_created_at"
225
+ t.index ["effective_directive", "created_at"], name: "index_csp_violations_on_effective_directive_and_created_at"
226
+ end
227
+ RUBY
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,24 @@
1
+ # Receives Content Security Policy violation reports. Browsers post them with no
2
+ # cookie and no CSRF token, in either the legacy report-uri shape
3
+ # (application/csp-report) or the Reporting API's (application/reports+json),
4
+ # so this is an API controller that reads the raw body itself.
5
+ #
6
+ # Always 204 for a report it could read — a browser does nothing with the
7
+ # answer — and best effort: a locked security database must never turn a
8
+ # report into a 500 that the browser retries.
9
+ class CspReportsController < ActionController::API
10
+ MAX_BYTES = 64.kilobytes
11
+
12
+ def create
13
+ body = request.body.read(MAX_BYTES + 1).to_s
14
+ return head 413 if body.bytesize > MAX_BYTES
15
+
16
+ Security::CspViolation.record_report!(body, ip: request.remote_ip, user_agent: request.user_agent)
17
+ head :no_content
18
+ rescue JSON::ParserError
19
+ head :bad_request
20
+ rescue => error
21
+ Rails.logger.error "[CSP] report not recorded: #{error.class}: #{error.message}"
22
+ head :no_content
23
+ end
24
+ end
@@ -0,0 +1,89 @@
1
+ # One Content Security Policy violation a browser reported to CspReportsController.
2
+ # Rows are written once and never updated; they are what tells you whether the
3
+ # policy is ready to enforce (report-only) or what it is breaking (enforced).
4
+ #
5
+ # Browsers send two shapes. The legacy `report-uri` one is a single object,
6
+ # `{"csp-report": {"blocked-uri": ..., ...}}` (Content-Type application/csp-report);
7
+ # the Reporting API one is an array of `{"type": "csp-violation", "body": {...}}`
8
+ # (application/reports+json) with camelCase keys. Both normalize to the same row.
9
+ class Security::CspViolation < Security::Record
10
+ RETENTION = 30.days
11
+
12
+ # Violations caused by the reader's own browser extensions say nothing about
13
+ # the app, and they are most of the volume.
14
+ NOISE_SCHEMES = %w[chrome-extension moz-extension safari-extension safari-web-extension ms-browser-extension].freeze
15
+
16
+ FIELD_LIMIT = 1024
17
+ SAMPLE_LIMIT = 256
18
+
19
+ LEGACY_KEYS = {
20
+ "document-uri" => :document_uri, "blocked-uri" => :blocked_uri,
21
+ "effective-directive" => :effective_directive, "violated-directive" => :effective_directive,
22
+ "disposition" => :disposition, "source-file" => :source_file,
23
+ "line-number" => :line_number, "column-number" => :column_number, "script-sample" => :sample
24
+ }.freeze
25
+
26
+ REPORTING_API_KEYS = {
27
+ "documentURL" => :document_uri, "blockedURL" => :blocked_uri,
28
+ "effectiveDirective" => :effective_directive, "disposition" => :disposition,
29
+ "sourceFile" => :source_file, "lineNumber" => :line_number,
30
+ "columnNumber" => :column_number, "sample" => :sample
31
+ }.freeze
32
+
33
+ validates :effective_directive, presence: true
34
+
35
+ scope :recent, -> { order(created_at: :desc, id: :desc) }
36
+ scope :within, ->(window) { where(created_at: window.ago..) }
37
+ scope :expired, -> { where(created_at: ...RETENTION.ago) }
38
+
39
+ # Records every violation in a raw report body; returns how many were kept.
40
+ # Raises JSON::ParserError on a body that isn't JSON.
41
+ def self.record_report!(raw, ip:, user_agent:)
42
+ violations = parse(raw).reject { |attrs| noise?(attrs) }
43
+ violations.each do |attrs|
44
+ create!(attrs.merge(ip:, user_agent: user_agent&.truncate(FIELD_LIMIT, omission: "")))
45
+ end
46
+ violations.size
47
+ end
48
+
49
+ def self.parse(raw)
50
+ payload = JSON.parse(raw)
51
+ reports = payload.is_a?(Array) ? payload : [payload]
52
+
53
+ reports.filter_map do |report|
54
+ next unless report.is_a?(Hash)
55
+
56
+ if report.key?("csp-report")
57
+ normalize(report["csp-report"], LEGACY_KEYS)
58
+ elsif report["type"] == "csp-violation"
59
+ normalize(report["body"], REPORTING_API_KEYS)
60
+ end
61
+ end
62
+ end
63
+
64
+ def self.noise?(attrs)
65
+ [attrs[:blocked_uri], attrs[:source_file]].compact.any? do |uri|
66
+ NOISE_SCHEMES.any? { |scheme| uri.start_with?("#{scheme}:") }
67
+ end
68
+ end
69
+
70
+ def self.normalize(body, keys)
71
+ return unless body.is_a?(Hash)
72
+
73
+ attrs = keys.each_with_object({}) do |(key, attribute), acc|
74
+ acc[attribute] ||= body[key] if body.key?(key)
75
+ end
76
+ # The directive alone (`script-src`), not the whole violated policy line.
77
+ attrs[:effective_directive] = attrs[:effective_directive].to_s.split.first
78
+ # URLs lose their query and fragment: a reported URL can carry a token.
79
+ %i[document_uri blocked_uri source_file].each { |key| attrs[key] = strip_url(attrs[key]) }
80
+ attrs[:sample] = attrs[:sample]&.to_s&.truncate(SAMPLE_LIMIT, omission: "")
81
+ %i[line_number column_number].each { |key| attrs[key] = attrs[key]&.to_i }
82
+ attrs.transform_values { |value| value.is_a?(String) ? value.truncate(FIELD_LIMIT, omission: "") : value }
83
+ end
84
+
85
+ def self.strip_url(value)
86
+ value&.to_s&.split(/[?#]/, 2)&.first
87
+ end
88
+ private_class_method :normalize, :strip_url
89
+ end
@@ -0,0 +1,30 @@
1
+ # What the Security page shows about Content Security Policy: the last `window`
2
+ # of reported violations, grouped by what was blocked, where and why.
3
+ class Security::CspViolationReport
4
+ Group = Data.define(:effective_directive, :blocked_uri, :document_uri, :count, :last_seen)
5
+
6
+ attr_reader :window, :limit
7
+
8
+ def initialize(window: 7.days, limit: 10)
9
+ @window = window
10
+ @limit = limit
11
+ end
12
+
13
+ def violations = Security::CspViolation.within(window)
14
+
15
+ def empty? = violations.none?
16
+
17
+ def total = violations.count
18
+
19
+ # The same blocked resource on the same page under the same directive, most
20
+ # frequent first — one row per thing to fix, however many readers hit it.
21
+ def top
22
+ violations.group(:effective_directive, :blocked_uri, :document_uri)
23
+ .order(Arel.sql("COUNT(*) DESC, MAX(created_at) DESC")).limit(limit)
24
+ .pluck(:effective_directive, :blocked_uri, :document_uri, Arel.sql("COUNT(*)"), Arel.sql("MAX(created_at)"))
25
+ .map do |directive, blocked, document, count, last|
26
+ Group.new(effective_directive: directive, blocked_uri: blocked, document_uri: document,
27
+ count:, last_seen: last.is_a?(String) ? ActiveSupport::TimeZone["UTC"].parse(last) : last)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,42 @@
1
+ <%# locals: (report:) %>
2
+ <%# Content Security Policy violations browsers reported to /csp-reports
3
+ (maquina:csp_reports), grouped so each row is one thing to fix. %>
4
+ <%= render "components/card" do %>
5
+ <%= render "components/card/header" do %>
6
+ <%= render "components/card/title", text: "Content Security Policy violations" %>
7
+ <%= render "components/card/description",
8
+ text: "#{number_with_delimiter(report.total)} reported in the last #{report.window.inspect}, kept for #{Security::CspViolation::RETENTION.inspect}. Most frequent first." %>
9
+ <% end %>
10
+ <%= render "components/card/content", spacing: :full do %>
11
+ <% if report.empty? %>
12
+ <%= empty_state(title: "No violation was reported.", icon: :check_circle, size: :compact) %>
13
+ <% else %>
14
+ <div class="overflow-x-auto">
15
+ <%= render "components/table", container: false, "aria-label": "Content Security Policy violations" do %>
16
+ <%= render "components/table/header" do %>
17
+ <%= render "components/table/row" do %>
18
+ <%= render "components/table/head", text: "Directive" %>
19
+ <%= render "components/table/head", text: "Blocked" %>
20
+ <%= render "components/table/head", text: "Page" %>
21
+ <%= render "components/table/head", text: "Reports", css_classes: "text-right" %>
22
+ <%= render "components/table/head", text: "Last seen" %>
23
+ <% end %>
24
+ <% end %>
25
+ <%= render "components/table/body" do %>
26
+ <% report.top.each do |group| %>
27
+ <%= render "components/table/row" do %>
28
+ <%= render "components/table/cell", css_classes: "font-mono", text: group.effective_directive %>
29
+ <%= render "components/table/cell", css_classes: "font-mono whitespace-normal break-all", text: group.blocked_uri.presence || "—" %>
30
+ <%= render "components/table/cell", css_classes: "font-mono whitespace-normal break-all", text: group.document_uri.presence || "—" %>
31
+ <%= render "components/table/cell", css_classes: "text-right tabular-nums", text: number_with_delimiter(group.count) %>
32
+ <%= render "components/table/cell" do %>
33
+ <%= render "backstage/security/utc_time", time: group.last_seen %>
34
+ <% end %>
35
+ <% end %>
36
+ <% end %>
37
+ <% end %>
38
+ <% end %>
39
+ </div>
40
+ <% end %>
41
+ <% end %>
42
+ <% end %>
@@ -0,0 +1,23 @@
1
+ # Content Security Policy violation reports (maquina:csp_reports). Lives in the
2
+ # `security` database next to abuse_events; db/security_schema.rb carries the same
3
+ # table for a fresh database, this migration adds it to an existing one.
4
+ class CreateCspViolations < ActiveRecord::Migration<%= migration_version %>
5
+ def change
6
+ create_table :csp_violations do |t|
7
+ t.string :effective_directive, null: false
8
+ t.string :blocked_uri
9
+ t.string :document_uri
10
+ t.string :source_file
11
+ t.integer :line_number
12
+ t.integer :column_number
13
+ t.string :disposition
14
+ t.string :sample
15
+ t.string :ip
16
+ t.string :user_agent
17
+ t.datetime :created_at, null: false
18
+ end
19
+
20
+ add_index :csp_violations, :created_at
21
+ add_index :csp_violations, [:effective_directive, :created_at]
22
+ end
23
+ end
@@ -49,7 +49,7 @@ module Maquina
49
49
 
50
50
  # 5. Routes
51
51
  def add_route
52
- mount_path = "#{options[:prefix]}/mission_control_jobs"
52
+ mount_path = "#{options[:prefix]}/jobs"
53
53
 
54
54
  route "mount MissionControl::Jobs::Engine, at: \"#{mount_path}\""
55
55
  end
@@ -67,6 +67,13 @@ module Maquina
67
67
  layout_files.each { |f| copy_file f, f }
68
68
  end
69
69
 
70
+ # 7b. Backstage dashboard — admin overview at the prefix root, linking to
71
+ # the installed engines. Shared with the solid_errors generator, so
72
+ # guard every step for idempotency when both run.
73
+ def create_backstage_dashboard
74
+ install_backstage_dashboard
75
+ end
76
+
70
77
  # 8. Custom views
71
78
  def copy_views
72
79
  return unless options[:copy_views]
@@ -105,6 +112,30 @@ module Maquina
105
112
  File.exist?(File.join(destination_root, "bin/rails"))
106
113
  end
107
114
 
115
+ # Idempotent install of the shared backstage dashboard (controller, layout,
116
+ # prefix-aware view, and the prefix-root route). Safe to run from both this
117
+ # generator and solid_errors.
118
+ def install_backstage_dashboard
119
+ unless File.exist?(File.join(destination_root, "app/controllers/backstage_dashboard_controller.rb"))
120
+ copy_file "app/controllers/backstage_dashboard_controller.rb",
121
+ "app/controllers/backstage_dashboard_controller.rb"
122
+ end
123
+
124
+ unless File.exist?(File.join(destination_root, "app/views/layouts/admin.html.erb"))
125
+ copy_file "app/views/layouts/admin.html.erb", "app/views/layouts/admin.html.erb"
126
+ end
127
+
128
+ unless File.exist?(File.join(destination_root, "app/views/backstage_dashboard/index.html.erb"))
129
+ template "app/views/backstage_dashboard/index.html.erb.tt",
130
+ "app/views/backstage_dashboard/index.html.erb"
131
+ end
132
+
133
+ routes_path = File.join(destination_root, "config/routes.rb")
134
+ if File.exist?(routes_path) && !File.read(routes_path).include?("backstage_dashboard#index")
135
+ route %(get "#{options[:prefix]}" => "backstage_dashboard#index", as: :backstage_dashboard)
136
+ end
137
+ end
138
+
108
139
  def layout_files
109
140
  layouts_dir = File.join(self.class.source_root, "app/views/layouts/mission_control")
110
141
  Dir.glob("**/*.erb", base: layouts_dir).map do |file|
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Admin overview — a backstage surface alongside the SolidErrors and Mission
4
+ # Control Jobs engines, under the same backstage Basic auth.
5
+ #
6
+ # Out of the box it shows the installed engine entry points and an empty metrics
7
+ # area. Populate @metrics in #index with aggregate, non-sensitive counts for
8
+ # your app — counts, not names or per-record detail.
9
+ class BackstageDashboardController < BackstageController
10
+ layout "admin"
11
+
12
+ before_action :require_backstage_credentials
13
+ before_action :authenticate_backstage
14
+
15
+ def index
16
+ @generated_at = Time.current
17
+
18
+ # Add overview metrics here. Each entry renders as a card:
19
+ #
20
+ # @metrics = [
21
+ # {label: "Accounts", icon: :folder, value: Account.count, hint: "tenants"},
22
+ # {label: "Users", icon: :user, value: User.count, hint: "members"}
23
+ # ]
24
+ #
25
+ # Keep them aggregate-only. :total and :hint are optional.
26
+ @metrics = []
27
+ end
28
+
29
+ private
30
+
31
+ # Closed (503) until credentials.backstage is set. Without this an unset
32
+ # username and password are both "", and an empty Basic header matches them.
33
+ def require_backstage_credentials
34
+ backstage = Rails.application.credentials.backstage || {}
35
+ head :service_unavailable if backstage[:username].blank? || backstage[:password].blank?
36
+ end
37
+
38
+ # Same backstage credentials the engines use.
39
+ def authenticate_backstage
40
+ authenticate_or_request_with_http_basic("Admin") do |username, password|
41
+ backstage = Rails.application.credentials.backstage || {}
42
+
43
+ ActiveSupport::SecurityUtils.secure_compare(username.to_s, backstage[:username].to_s) &
44
+ ActiveSupport::SecurityUtils.secure_compare(password.to_s, backstage[:password].to_s)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,93 @@
1
+ <div class="py-8">
2
+ <!-- Header -->
3
+ <div class="mb-6 flex items-end justify-between border-b border-border pb-4">
4
+ <h1 class="text-2xl font-bold text-foreground">Overview</h1>
5
+ <%% if @generated_at %>
6
+ <p class="font-mono text-[11px] tabular-nums text-muted-foreground">as of <%%= @generated_at.strftime("%H:%M %Z") %></p>
7
+ <%% end %>
8
+ </div>
9
+
10
+ <!-- Metrics: populate @metrics in BackstageDashboardController#index. Aggregate-only. -->
11
+ <%% if @metrics.present? %>
12
+ <div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
13
+ <%% @metrics.each do |metric| %>
14
+ <%%= render "components/card" do %>
15
+ <div class="px-5 py-4">
16
+ <div class="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
17
+ <%% if metric[:icon] %><%%= icon_for(metric[:icon], class: "size-3.5") %><%% end %>
18
+ <%%= metric[:label] %>
19
+ </div>
20
+ <div class="font-display text-4xl font-semibold tabular-nums text-foreground">
21
+ <%%= number_with_delimiter(metric[:value]) %><%% if metric[:total] %><span class="text-xl text-muted-foreground">/<%%= number_with_delimiter(metric[:total]) %></span><%% end %>
22
+ </div>
23
+ <%% if metric[:hint] %>
24
+ <div class="mt-1 text-[11px] tabular-nums text-muted-foreground"><%%= metric[:hint] %></div>
25
+ <%% end %>
26
+ </div>
27
+ <%% end %>
28
+ <%% end %>
29
+ </div>
30
+ <%% else %>
31
+ <%%= empty_state(
32
+ title: "No metrics yet",
33
+ description: "Add aggregate counts in BackstageDashboardController#index to populate this overview.",
34
+ icon: :layout_dashboard,
35
+ size: :default
36
+ ) %>
37
+ <%% end %>
38
+
39
+ <!-- Operations: entry points to the installed engines -->
40
+ <h2 class="mt-10 mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Operations</h2>
41
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
42
+ <%% if defined?(SolidErrors::Engine) %>
43
+ <%%= link_to "<%= options[:prefix] %>/errors", class: "group block" do %>
44
+ <%%= render "components/card", css_classes: "transition-shadow hover:shadow-md" do %>
45
+ <div class="flex items-center gap-4 px-5 py-4">
46
+ <span class="inline-flex size-10 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
47
+ <%%= icon_for(:triangle_alert, class: "size-5") %>
48
+ </span>
49
+ <div class="min-w-0 flex-1">
50
+ <div class="font-semibold text-foreground">Errors</div>
51
+ <div class="text-sm text-muted-foreground">Exceptions, backtraces, and resolution</div>
52
+ </div>
53
+ <%%= icon_for(:arrow_right, class: "size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5") %>
54
+ </div>
55
+ <%% end %>
56
+ <%% end %>
57
+ <%% end %>
58
+
59
+ <%% if defined?(MissionControl::Jobs::Engine) %>
60
+ <%%= link_to "<%= options[:prefix] %>/jobs", class: "group block" do %>
61
+ <%%= render "components/card", css_classes: "transition-shadow hover:shadow-md" do %>
62
+ <div class="flex items-center gap-4 px-5 py-4">
63
+ <span class="inline-flex size-10 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
64
+ <%%= icon_for(:inbox, class: "size-5") %>
65
+ </span>
66
+ <div class="min-w-0 flex-1">
67
+ <div class="font-semibold text-foreground">Background jobs</div>
68
+ <div class="text-sm text-muted-foreground">Queues, workers, and recurring tasks</div>
69
+ </div>
70
+ <%%= icon_for(:arrow_right, class: "size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5") %>
71
+ </div>
72
+ <%% end %>
73
+ <%% end %>
74
+ <%% end %>
75
+
76
+ <%% if defined?(Security::AbuseEvent) %>
77
+ <%%= link_to "<%= options[:prefix] %>/security", class: "group block" do %>
78
+ <%%= render "components/card", css_classes: "transition-shadow hover:shadow-md" do %>
79
+ <div class="flex items-center gap-4 px-5 py-4">
80
+ <span class="inline-flex size-10 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
81
+ <%%= icon_for(:slash, class: "size-5") %>
82
+ </span>
83
+ <div class="min-w-0 flex-1">
84
+ <div class="font-semibold text-foreground">Security</div>
85
+ <div class="text-sm text-muted-foreground">Throttled, blocked, and banned requests</div>
86
+ </div>
87
+ <%%= icon_for(:arrow_right, class: "size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5") %>
88
+ </div>
89
+ <%% end %>
90
+ <%% end %>
91
+ <%% end %>
92
+ </div>
93
+ </div>