maquina-generators 0.9.0 → 0.10.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +189 -9
  3. data/lib/generators/maquina/app/USAGE +4 -0
  4. data/lib/generators/maquina/app/app_generator.rb +30 -4
  5. data/lib/generators/maquina/app/templates/README.md.erb +21 -0
  6. data/lib/generators/maquina/app/templates/app/views/home/index.html.erb +1 -1
  7. data/lib/generators/maquina/backstage_signin/backstage_signin.rb +245 -0
  8. data/lib/generators/maquina/backstage_signin/templates/test/integration/backstage_access_test.rb +78 -0
  9. data/lib/generators/maquina/csp_reports/csp_reports_generator.rb +13 -1
  10. data/lib/generators/maquina/csp_reports/templates/app/models/mcp/tools/csp_reports.rb +71 -0
  11. data/lib/generators/maquina/csp_reports/templates/test/models/mcp/tools/csp_reports_test.rb +35 -0
  12. data/lib/generators/maquina/mcp/USAGE +37 -0
  13. data/lib/generators/maquina/mcp/mcp_generator.rb +188 -0
  14. data/lib/generators/maquina/mcp/mcp_sibling_tools.rb +104 -0
  15. data/lib/generators/maquina/mcp/templates/app/controllers/concerns/mcp_authenticatable.rb +63 -0
  16. data/lib/generators/maquina/mcp/templates/app/controllers/mcp_controller.rb +205 -0
  17. data/lib/generators/maquina/mcp/templates/app/models/mcp/tool_definitions.rb +38 -0
  18. data/lib/generators/maquina/mcp/templates/app/models/mcp/tool_error.rb +17 -0
  19. data/lib/generators/maquina/mcp/templates/app/models/mcp/tools/app.rb +51 -0
  20. data/lib/generators/maquina/mcp/templates/app/models/mcp/tools.rb +83 -0
  21. data/lib/generators/maquina/mcp/templates/app/models/mcp/user_scope.rb +50 -0
  22. data/lib/generators/maquina/mcp/templates/config/initializers/mcp_cors.rb +38 -0
  23. data/lib/generators/maquina/mcp/templates/test/integration/mcp_conformance_test.rb +223 -0
  24. data/lib/generators/maquina/mcp/templates/test/integration/mcp_flow_test.rb.tt +65 -0
  25. data/lib/generators/maquina/mcp/templates/test/models/mcp/tools_test.rb.tt +145 -0
  26. data/lib/generators/maquina/mcp/templates/test/test_helpers/mcp_test_helper.rb +89 -0
  27. data/lib/generators/maquina/mcp_tool/USAGE +26 -0
  28. data/lib/generators/maquina/mcp_tool/mcp_tool_generator.rb +239 -0
  29. data/lib/generators/maquina/mcp_tool/templates/test/models/mcp/tools/tool_test.rb.tt +49 -0
  30. data/lib/generators/maquina/mission_control_jobs/USAGE +3 -0
  31. data/lib/generators/maquina/mission_control_jobs/mission_control_jobs_generator.rb +19 -5
  32. data/lib/generators/maquina/mission_control_jobs/templates/app/views/backstage_dashboard/index.html.erb.tt +1 -1
  33. data/lib/generators/maquina/mission_control_jobs/templates/app/views/layouts/_admin_navigation.html.erb.tt +12 -11
  34. data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/jobs/_filters.html.erb +1 -1
  35. data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/jobs/_general_information.html.erb +1 -1
  36. data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/jobs/_job.html.erb +1 -1
  37. data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/jobs/failed/_job.html.erb +1 -1
  38. data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/queues/_job.html.erb +1 -1
  39. data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/shared/_job.html.erb +1 -1
  40. data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/workers/_worker.html.erb +1 -1
  41. data/lib/generators/maquina/oauth/USAGE +32 -0
  42. data/lib/generators/maquina/oauth/oauth_generator.rb +352 -0
  43. data/lib/generators/maquina/oauth/templates/app/controllers/connected_applications_controller.rb +26 -0
  44. data/lib/generators/maquina/oauth/templates/app/controllers/oauth/authorization_servers_controller.rb +24 -0
  45. data/lib/generators/maquina/oauth/templates/app/controllers/oauth/authorizations_controller.rb +92 -0
  46. data/lib/generators/maquina/oauth/templates/app/controllers/oauth/protected_resources_controller.rb +14 -0
  47. data/lib/generators/maquina/oauth/templates/app/controllers/oauth/revocations_controller.rb +20 -0
  48. data/lib/generators/maquina/oauth/templates/app/controllers/oauth/tokens_controller.rb +67 -0
  49. data/lib/generators/maquina/oauth/templates/app/models/access_token.rb +83 -0
  50. data/lib/generators/maquina/oauth/templates/app/models/connected_application.rb +49 -0
  51. data/lib/generators/maquina/oauth/templates/app/models/mcp.rb.tt +21 -0
  52. data/lib/generators/maquina/oauth/templates/app/models/oauth_authorization_code.rb +52 -0
  53. data/lib/generators/maquina/oauth/templates/app/models/oauth_client/metadata_fetcher.rb +195 -0
  54. data/lib/generators/maquina/oauth/templates/app/models/oauth_client.rb +50 -0
  55. data/lib/generators/maquina/oauth/templates/app/views/connected_applications/index.html.erb +43 -0
  56. data/lib/generators/maquina/oauth/templates/app/views/oauth/authorizations/error.html.erb +8 -0
  57. data/lib/generators/maquina/oauth/templates/app/views/oauth/authorizations/new.html.erb +36 -0
  58. data/lib/generators/maquina/oauth/templates/config/locales/oauth.en.yml +34 -0
  59. data/lib/generators/maquina/oauth/templates/config/locales/oauth.es.yml +34 -0
  60. data/lib/generators/maquina/oauth/templates/migration_create_access_tokens.rb.tt +20 -0
  61. data/lib/generators/maquina/oauth/templates/migration_create_oauth_authorization_codes.rb.tt +19 -0
  62. data/lib/generators/maquina/oauth/templates/migration_create_oauth_clients.rb.tt +14 -0
  63. data/lib/generators/maquina/oauth/templates/test/fixtures/access_tokens.yml +48 -0
  64. data/lib/generators/maquina/oauth/templates/test/fixtures/oauth_authorization_codes.yml +11 -0
  65. data/lib/generators/maquina/oauth/templates/test/fixtures/oauth_clients.yml +7 -0
  66. data/lib/generators/maquina/oauth/templates/test/integration/connected_applications_test.rb.tt +70 -0
  67. data/lib/generators/maquina/oauth/templates/test/integration/oauth_authorization_test.rb.tt +150 -0
  68. data/lib/generators/maquina/oauth/templates/test/integration/oauth_discovery_test.rb +44 -0
  69. data/lib/generators/maquina/oauth/templates/test/integration/oauth_tokens_test.rb +147 -0
  70. data/lib/generators/maquina/oauth/templates/test/models/access_token_test.rb +78 -0
  71. data/lib/generators/maquina/oauth/templates/test/models/connected_application_test.rb +41 -0
  72. data/lib/generators/maquina/oauth/templates/test/models/oauth_authorization_code_test.rb +58 -0
  73. data/lib/generators/maquina/oauth/templates/test/test_helpers/oauth_test_helper.rb.tt +82 -0
  74. data/lib/generators/maquina/rack_attack/rack_attack_generator.rb +4 -1
  75. data/lib/generators/maquina/rack_attack/templates/config/initializers/rack_attack.rb.tt +317 -4
  76. data/lib/generators/maquina/security/USAGE +10 -0
  77. data/lib/generators/maquina/security/security_generator.rb +77 -7
  78. data/lib/generators/maquina/security/templates/app/models/mcp/tools/security.rb.tt +114 -0
  79. data/lib/generators/maquina/security/templates/app/views/backstage_dashboard/index.html.erb.tt +1 -1
  80. data/lib/generators/maquina/security/templates/app/views/layouts/_admin_navigation.html.erb.tt +12 -11
  81. data/lib/generators/maquina/security/templates/test/models/mcp/tools/security_test.rb +99 -0
  82. data/lib/generators/maquina/solid_errors/USAGE +15 -9
  83. data/lib/generators/maquina/solid_errors/solid_errors_generator.rb +40 -31
  84. data/lib/generators/maquina/solid_errors/templates/app/agents/errors_query.rb +48 -27
  85. data/lib/generators/maquina/solid_errors/templates/app/models/mcp/tools/solid_errors.rb.tt +114 -0
  86. data/lib/generators/maquina/solid_errors/templates/app/views/backstage_dashboard/index.html.erb.tt +1 -1
  87. data/lib/generators/maquina/solid_errors/templates/app/views/layouts/_admin_navigation.html.erb.tt +12 -11
  88. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/errors/show/_actions.html.erb +0 -15
  89. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/errors/show/_error_details.html.erb +5 -5
  90. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/occurrences/_collection.html.erb +1 -1
  91. data/lib/generators/maquina/solid_errors/templates/test/models/mcp/tools/solid_errors_test.rb +109 -0
  92. data/lib/generators/maquina/solid_queue/USAGE +11 -0
  93. data/lib/generators/maquina/solid_queue/solid_queue_generator.rb +25 -1
  94. data/lib/generators/maquina/solid_queue/templates/app/models/mcp/tools/solid_queue.rb.tt +163 -0
  95. data/lib/generators/maquina/solid_queue/templates/test/models/mcp/tools/solid_queue_test.rb +99 -0
  96. data/lib/maquina_generators/version.rb +1 -1
  97. data/test/generators/maquina/app_generator_test.rb +58 -0
  98. data/test/generators/maquina/backstage_signin_test.rb +209 -0
  99. data/test/generators/maquina/csp_reports_generator_test.rb +125 -6
  100. data/test/generators/maquina/mcp_generator_test.rb +426 -0
  101. data/test/generators/maquina/mcp_tool_generator_test.rb +235 -0
  102. data/test/generators/maquina/mission_control_jobs_generator_test.rb +23 -0
  103. data/test/generators/maquina/oauth_client_metadata_fetcher_test.rb +290 -0
  104. data/test/generators/maquina/oauth_generator_test.rb +520 -0
  105. data/test/generators/maquina/rack_attack_generator_test.rb +71 -0
  106. data/test/generators/maquina/rack_attack_rules_test.rb +304 -0
  107. data/test/generators/maquina/security_generator_test.rb +188 -13
  108. data/test/generators/maquina/solid_errors_generator_test.rb +243 -33
  109. data/test/generators/maquina/solid_queue_generator_test.rb +175 -25
  110. data/test/test_helper.rb +1 -0
  111. metadata +69 -34
  112. data/lib/generators/maquina/solid_errors/templates/app/controllers/failures_mcp_controller.rb.tt +0 -65
  113. data/test/tmp/Gemfile +0 -2
  114. data/test/tmp/app/controllers/application_controller.rb +0 -3
  115. data/test/tmp/app/controllers/concerns/authentication.rb +0 -63
  116. data/test/tmp/app/controllers/registration/verification_resends_controller.rb +0 -38
  117. data/test/tmp/app/controllers/registration/verifications_controller.rb +0 -64
  118. data/test/tmp/app/controllers/registrations_controller.rb +0 -63
  119. data/test/tmp/app/controllers/session/verification_resends_controller.rb +0 -44
  120. data/test/tmp/app/controllers/session/verifications_controller.rb +0 -64
  121. data/test/tmp/app/controllers/sessions_controller.rb +0 -56
  122. data/test/tmp/app/helpers/authentication_helper.rb +0 -20
  123. data/test/tmp/app/jobs/authentication_cleanup_job.rb +0 -13
  124. data/test/tmp/app/mailers/verification_mailer.rb +0 -15
  125. data/test/tmp/app/models/account.rb +0 -5
  126. data/test/tmp/app/models/current.rb +0 -5
  127. data/test/tmp/app/models/email_verification.rb +0 -58
  128. data/test/tmp/app/models/session.rb +0 -18
  129. data/test/tmp/app/models/user.rb +0 -37
  130. data/test/tmp/app/views/registration/verifications/new.html.erb +0 -43
  131. data/test/tmp/app/views/registrations/new.html.erb +0 -39
  132. data/test/tmp/app/views/session/verifications/new.html.erb +0 -43
  133. data/test/tmp/app/views/sessions/new.html.erb +0 -40
  134. data/test/tmp/app/views/verification_mailer/verification_code.html.erb +0 -37
  135. data/test/tmp/app/views/verification_mailer/verification_code.text.erb +0 -11
  136. data/test/tmp/config/locales/clave.en.yml +0 -102
  137. data/test/tmp/config/locales/clave.es.yml +0 -102
  138. data/test/tmp/config/routes.rb +0 -12
  139. data/test/tmp/db/migrate/20261019191386_create_accounts.rb +0 -9
  140. data/test/tmp/db/migrate/20261019191387_create_users.rb +0 -17
  141. data/test/tmp/db/migrate/20261019191388_create_sessions.rb +0 -12
  142. data/test/tmp/db/migrate/20261019191389_create_email_verifications.rb +0 -17
  143. data/test/tmp/test/test_helpers/session_test_helper.rb +0 -19
@@ -0,0 +1,245 @@
1
+ module Maquina
2
+ module Generators
3
+ # Puts the backstage behind the app's own Clave sign-in instead of the
4
+ # shared Basic Auth login. The dashboard generators (solid_errors,
5
+ # mission_control_jobs, security) call install_backstage_signin after they
6
+ # write their files, so whichever runs, and in whatever order, the backstage
7
+ # ends up on the sign-in when Clave is installed.
8
+ #
9
+ # Who gets in is User#backstage_access?: an unblocked user whose email is
10
+ # listed under backstage.emails in credentials. The same predicate gates
11
+ # the admin-only MCP tools, which is why maquina:mcp calls
12
+ # add_backstage_access_to_user too.
13
+ #
14
+ # Each step edits a snippet the generators wrote. One that is not found
15
+ # (the app changed it) is left alone and named in backstage_signin_pending
16
+ # for the post-install message.
17
+ #
18
+ # Every method is private: a public method on a generator is a Thor step.
19
+ module BackstageSignin
20
+ TEMPLATES = File.expand_path("templates", __dir__)
21
+
22
+ USER_MODEL = "app/models/user.rb"
23
+ BACKSTAGE_CONTROLLER = "app/controllers/backstage_controller.rb"
24
+ DASHBOARD_CONTROLLER = "app/controllers/backstage_dashboard_controller.rb"
25
+ SECURITY_CONTROLLER = "app/controllers/backstage/security_controller.rb"
26
+ SOLID_ERRORS_INITIALIZER = "config/initializers/solid_errors.rb"
27
+ MISSION_CONTROL_INITIALIZER = "config/initializers/mission_control.rb"
28
+ ACCESS_TEST = "test/integration/backstage_access_test.rb"
29
+
30
+ BACKSTAGE_CLASS = /^class BackstageController < ActionController::Base\n/
31
+ CLASS_END = /^end\s*\z/
32
+ BASIC_AUTH_ACTIONS = /\n before_action :require_backstage_credentials\n before_action :authenticate(_backstage)?\n/
33
+ # The private section both controllers end with: nothing in it but the
34
+ # Basic Auth helpers.
35
+ DASHBOARD_BASIC_AUTH = /\n\n private\n\n # Closed \(503\) until credentials\.backstage is set\..*\n end\n(?=end\n?\z)/m
36
+ SECURITY_BASIC_AUTH = /\n\n private\n\n def authenticate\n.*\n end\n(?=end\n?\z)/m
37
+ SECURITY_HEADER = /# Rack::Attack's last week, read from the `security` database\. Behind the same\n.*\n.*so it is never left open\.\n/
38
+ SOLID_ERRORS_BASIC_AUTH = /\n backstage = Rails\.application\.credentials\.backstage \|\| \{\}\n\n config\.solid_errors\.username = .*\n config\.solid_errors\.password = .*\n/
39
+ MISSION_CONTROL_BASIC_AUTH = / backstage = Rails\.application\.credentials\.backstage \|\| \{\}\n\n MissionControl::Jobs\.http_basic_auth_user = .*\n MissionControl::Jobs\.http_basic_auth_password = .*\n/
40
+
41
+ # The people the generated access test signs in as.
42
+ USER_FIXTURES = {
43
+ "backstage_operator" => {"email_address" => "backstage-operator@example.com", "name" => "Backstage Operator", "role" => "admin"},
44
+ "backstage_member" => {"email_address" => "backstage-member@example.com", "name" => "Backstage Member", "role" => "admin"}
45
+ }.freeze
46
+
47
+ private
48
+
49
+ def clave_installed?
50
+ # EmailVerification is Clave's own. Rails' authentication generator
51
+ # (maquina:registration) also writes the other two, but its User has no
52
+ # blocked?, so that app keeps Basic Auth.
53
+ [USER_MODEL, "app/models/email_verification.rb", "app/controllers/concerns/authentication.rb"]
54
+ .all? { |path| backstage_file?(path) }
55
+ end
56
+
57
+ def backstage_signin_pending
58
+ @backstage_signin_pending ||= []
59
+ end
60
+
61
+ # Returns true when the backstage is (now) behind the sign-in.
62
+ def install_backstage_signin
63
+ return false unless clave_installed?
64
+
65
+ add_backstage_access_to_user
66
+ convert_backstage_controller
67
+ convert(DASHBOARD_CONTROLLER, done_unless: "require_backstage_credentials") do
68
+ gsub_file DASHBOARD_CONTROLLER, BASIC_AUTH_ACTIONS, ""
69
+ gsub_file DASHBOARD_CONTROLLER, DASHBOARD_BASIC_AUTH, "\n"
70
+ gsub_file DASHBOARD_CONTROLLER, "under the same backstage Basic auth.", "behind the same backstage sign-in."
71
+ end
72
+ convert(SECURITY_CONTROLLER, done_unless: "require_backstage_credentials") do
73
+ gsub_file SECURITY_CONTROLLER, BASIC_AUTH_ACTIONS, ""
74
+ gsub_file SECURITY_CONTROLLER, SECURITY_BASIC_AUTH, "\n"
75
+ gsub_file SECURITY_CONTROLLER, SECURITY_HEADER,
76
+ "# Rack::Attack's last week, read from the `security` database. Behind the\n" \
77
+ "# backstage sign-in, like every page BackstageController serves.\n"
78
+ end
79
+ convert(SOLID_ERRORS_INITIALIZER, done_unless: "config.solid_errors.password =") do
80
+ gsub_file SOLID_ERRORS_INITIALIZER, SOLID_ERRORS_BASIC_AUTH,
81
+ "\n # Signed-in backstage users only, through BackstageController. Leave\n" \
82
+ " # username and password unset: a password turns Basic Auth back on.\n"
83
+ end
84
+ convert(MISSION_CONTROL_INITIALIZER, done_unless: "http_basic_auth_user") do
85
+ gsub_file MISSION_CONTROL_INITIALIZER, MISSION_CONTROL_BASIC_AUTH,
86
+ " # Signed-in backstage users only, through BackstageController.\n" \
87
+ " MissionControl::Jobs.http_basic_auth_enabled = false\n"
88
+ end
89
+ create_backstage_access_test
90
+ true
91
+ end
92
+
93
+ # User.backstage_emails and User#backstage_access?. Also called by
94
+ # maquina:mcp, whose admin-only tools use the same gate.
95
+ def add_backstage_access_to_user
96
+ return unless backstage_file?(USER_MODEL)
97
+
98
+ content = File.read(File.join(destination_root, USER_MODEL))
99
+ return if content.include?("def backstage_access?")
100
+
101
+ if content.match?(/^ private$/)
102
+ inject_into_file USER_MODEL, backstage_access_methods, before: /^ private$/
103
+ elsif content.match?(CLASS_END)
104
+ inject_into_file USER_MODEL, "\n#{backstage_access_methods.chomp}", before: CLASS_END
105
+ else
106
+ backstage_signin_pending << USER_MODEL
107
+ end
108
+ end
109
+
110
+ def convert_backstage_controller
111
+ return unless backstage_file?(BACKSTAGE_CONTROLLER)
112
+
113
+ content = File.read(File.join(destination_root, BACKSTAGE_CONTROLLER))
114
+ return if content.include?("require_backstage_access")
115
+ return backstage_signin_pending << BACKSTAGE_CONTROLLER unless content.match?(BACKSTAGE_CLASS) && content.match?(CLASS_END)
116
+
117
+ inject_into_file BACKSTAGE_CONTROLLER, backstage_controller_header, after: BACKSTAGE_CLASS
118
+ inject_into_file BACKSTAGE_CONTROLLER, backstage_controller_private, before: CLASS_END
119
+ end
120
+
121
+ # Runs the edits when the file still has Basic Auth (`done_unless`), then
122
+ # checks they took. A file the app has reshaped keeps its Basic Auth and
123
+ # is named in the post-install message.
124
+ def convert(path, done_unless:)
125
+ full_path = File.join(destination_root, path)
126
+ return unless File.exist?(full_path)
127
+ return unless File.read(full_path).include?(done_unless)
128
+
129
+ yield
130
+ backstage_signin_pending << path if File.read(full_path).include?(done_unless)
131
+ end
132
+
133
+ def create_backstage_access_test
134
+ return unless backstage_file?("test/test_helpers/session_test_helper.rb")
135
+
136
+ create_file ACCESS_TEST, File.read(File.join(TEMPLATES, ACCESS_TEST)) unless backstage_file?(ACCESS_TEST)
137
+ append_backstage_fixture "test/fixtures/accounts.yml", "backstage", {"name" => "Backstage"}
138
+ USER_FIXTURES.each do |label, attributes|
139
+ append_backstage_fixture "test/fixtures/users.yml", label, attributes.merge("account" => "backstage")
140
+ end
141
+ end
142
+
143
+ # Appends a fixture under its label unless the label is already there,
144
+ # creating the file when it is missing.
145
+ def append_backstage_fixture(path, label, attributes)
146
+ entry = "#{label}:\n" + attributes.map { |key, value| " #{key}: #{value}\n" }.join
147
+ return create_file(path, entry) unless backstage_file?(path)
148
+
149
+ content = File.read(File.join(destination_root, path))
150
+ return if content.match?(/^#{Regexp.escape(label)}:/)
151
+
152
+ separator = if content.empty?
153
+ ""
154
+ else
155
+ (content.end_with?("\n") ? "\n" : "\n\n")
156
+ end
157
+ append_to_file path, separator + entry
158
+ end
159
+
160
+ def backstage_file?(path)
161
+ File.exist?(File.join(destination_root, path))
162
+ end
163
+
164
+ def say_backstage_access
165
+ say " - Backstage access: signed-in users listed in credentials (bin/rails credentials:edit)"
166
+ say " backstage:"
167
+ say " emails:"
168
+ say " - you@example.com"
169
+ say " Anyone else gets a 404. Do not set backstage.username or password: Solid Errors"
170
+ say " turns its Basic Auth back on when a password is set."
171
+ end
172
+
173
+ def say_backstage_signin_pending
174
+ return if backstage_signin_pending.empty?
175
+
176
+ say ""
177
+ say "These files were changed by hand, so they keep their Basic Auth. Move them to", :yellow
178
+ say "the backstage sign-in yourself (BackstageController#require_backstage_access):"
179
+ backstage_signin_pending.uniq.each { |path| say " - #{path}" }
180
+ end
181
+
182
+ def backstage_access_methods
183
+ <<-RUBY
184
+ # Who may open the backstage and use the admin-only MCP tools: an unblocked
185
+ # user whose email is listed in credentials (backstage.emails). An empty list
186
+ # is nobody. A list in config.x.backstage_emails is used instead, even an
187
+ # empty one; that is how the tests grant access. (Unset, config.x answers an
188
+ # empty OrderedOptions, not nil.)
189
+ def self.backstage_emails
190
+ emails = Rails.configuration.x.backstage_emails
191
+ emails = Rails.application.credentials.dig(:backstage, :emails) unless emails.is_a?(Array)
192
+ Array(emails).map { |email| email.to_s.strip.downcase }
193
+ end
194
+
195
+ def backstage_access?
196
+ !blocked? && self.class.backstage_emails.include?(email_address)
197
+ end
198
+
199
+ RUBY
200
+ end
201
+
202
+ def backstage_controller_header
203
+ <<-RUBY
204
+ # The app's sign-in rather than a shared Basic Auth password, for every
205
+ # backstage page: the overview, Security and, through base_controller_class,
206
+ # the Solid Errors and Mission Control Jobs engines. Who gets in is
207
+ # User#backstage_access?; anyone else gets a 404, so the pages never confirm
208
+ # they exist.
209
+ include Authentication
210
+
211
+ # After the include, so it runs behind require_authentication and can rely
212
+ # on Current.session being resolved.
213
+ before_action :require_backstage_access
214
+
215
+ RUBY
216
+ end
217
+
218
+ def backstage_controller_private
219
+ <<-RUBY
220
+
221
+ private
222
+
223
+ # ActiveRecord::RecordNotFound rather than a redirect with an alert: the
224
+ # alert would confirm there is a backstage.
225
+ def require_backstage_access
226
+ raise ActiveRecord::RecordNotFound unless Current.user&.backstage_access?
227
+ end
228
+
229
+ # Restated rather than inherited: this controller serves two mounted
230
+ # engines, and inside one a bare new_session_path generates against the
231
+ # engine's routes. main_app reaches the app's sign-in from anywhere.
232
+ #
233
+ # 303 for PATCH/PUT/DELETE: both engines submit those verbs (resolve, retry,
234
+ # discard), and Turbo keeps the verb through a 302, so an expired session on
235
+ # Discard would re-issue the DELETE against the sign-in page.
236
+ def request_authentication
237
+ session[:return_to_after_authenticating] = request.url
238
+ redirect_to main_app.new_session_path
239
+ response.status = :see_other if request.patch? || request.put? || request.delete?
240
+ end
241
+ RUBY
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,78 @@
1
+ require "test_helper"
2
+ require_relative "../test_helpers/session_test_helper"
3
+
4
+ # The backstage is behind the app's sign-in (generated by the maquina dashboard
5
+ # generators). Only a signed-in, unblocked user whose email is on the backstage
6
+ # list gets in; anyone else signed in gets a 404, so the pages never confirm
7
+ # they exist. The list lives in credentials (backstage.emails); these tests set
8
+ # config.x.backstage_emails, which User.backstage_emails reads first.
9
+ class BackstageAccessTest < ActionDispatch::IntegrationTest
10
+ # The engine pages mounted under the backstage prefix, whichever are installed.
11
+ PAGES = %w[errors jobs security].freeze
12
+
13
+ setup do
14
+ @emails_before = Rails.configuration.x.backstage_emails
15
+ Rails.configuration.x.backstage_emails = [users(:backstage_operator).email_address]
16
+ end
17
+
18
+ teardown do
19
+ Rails.configuration.x.backstage_emails = @emails_before
20
+ end
21
+
22
+ test "signed out, every backstage page asks to sign in" do
23
+ # Before any request: after one into a mounted engine, URL helpers in an
24
+ # integration test generate under that engine's mount point.
25
+ sign_in = new_session_url
26
+
27
+ backstage_paths.each do |path|
28
+ get path
29
+
30
+ assert_redirected_to sign_in, path
31
+ end
32
+ end
33
+
34
+ test "a user not on the list gets not found on every backstage page" do
35
+ sign_in_as users(:backstage_member)
36
+
37
+ backstage_paths.each do |path|
38
+ get path
39
+
40
+ assert_response :not_found, path
41
+ end
42
+ end
43
+
44
+ test "a blocked user on the list gets not found" do
45
+ users(:backstage_operator).block!(reason: "test")
46
+ sign_in_as users(:backstage_operator)
47
+
48
+ get backstage_dashboard_path
49
+
50
+ assert_response :not_found
51
+ end
52
+
53
+ test "an empty list lets nobody in" do
54
+ Rails.configuration.x.backstage_emails = []
55
+ sign_in_as users(:backstage_operator)
56
+
57
+ get backstage_dashboard_path
58
+
59
+ assert_response :not_found
60
+ end
61
+
62
+ test "a user on the list gets in" do
63
+ sign_in_as users(:backstage_operator)
64
+
65
+ get backstage_dashboard_path
66
+
67
+ assert_response :success
68
+ end
69
+
70
+ private
71
+
72
+ def backstage_paths
73
+ mounted = Rails.application.routes.routes.map { |route| route.path.spec.to_s }
74
+ pages = PAGES.map { |page| "#{backstage_dashboard_path}/#{page}" }.select { |path| mounted.any? { |spec| spec.start_with?(path) } }
75
+
76
+ [backstage_dashboard_path, *pages]
77
+ end
78
+ end
@@ -1,10 +1,12 @@
1
1
  require "rails/generators"
2
2
  require "rails/generators/migration"
3
+ require_relative "../mcp/mcp_sibling_tools"
3
4
 
4
5
  module Maquina
5
6
  module Generators
6
7
  class CspReportsGenerator < Rails::Generators::Base
7
8
  include Rails::Generators::Migration
9
+ include McpSiblingTools
8
10
 
9
11
  source_root File.expand_path("templates", __dir__)
10
12
 
@@ -130,7 +132,12 @@ module Maquina
130
132
  end
131
133
  end
132
134
 
133
- # 9. Post-install message
135
+ # 9. MCP tools (admin only, read-only), when maquina:mcp is installed
136
+ def create_mcp_tools
137
+ @mcp_tools_installed = install_mcp_tools(:csp_reports)
138
+ end
139
+
140
+ # 10. Post-install message
134
141
  def show_post_install
135
142
  return if options[:quiet]
136
143
 
@@ -141,6 +148,11 @@ module Maquina
141
148
  say " - Add the table: bin/rails db:migrate (db:prepare on deploy)"
142
149
  say " - Watch the Security page while the policy is report-only, then set"
143
150
  say " content_security_policy_report_only = false to enforce it."
151
+ if @mcp_tools_installed
152
+ say ""
153
+ say "MCP tools (admin only, read-only): app/models/mcp/tools/csp_reports.rb", :green
154
+ say " list_csp_reports, csp_report_summary"
155
+ end
144
156
  if @report_uri_pending
145
157
  say ""
146
158
  say "Point the policy at the endpoint in config/initializers/content_security_policy.rb:", :yellow
@@ -0,0 +1,71 @@
1
+ # MCP tools over the Content Security Policy violations browsers report
2
+ # (generated by maquina:csp_reports).
3
+ #
4
+ # Admin only: a member's token neither sees nor reaches these tools. Read-only.
5
+ # Reported URLs are stored without query or fragment; the reporter's IP address
6
+ # is not returned.
7
+ module Mcp
8
+ module Tools
9
+ module CspReports
10
+ ADMIN_ONLY = true
11
+ WRITE_TOOLS = %w[].freeze
12
+
13
+ MAX_LIMIT = 50
14
+ MAX_DAYS = 30
15
+ SCHEMA = "https://json-schema.org/draft/2020-12/schema"
16
+
17
+ DEFINITIONS = [
18
+ {
19
+ name: "list_csp_reports",
20
+ description: "Content Security Policy violations browsers reported, newest first. Each entry has " \
21
+ "the directive that was violated, the blocked URI, the page (document URI), the source file " \
22
+ "with line and column, the disposition (enforce or report), a code sample when the browser " \
23
+ "sent one, the user agent and at (ISO 8601 UTC). Reports are kept for 30 days. Returns at " \
24
+ "most 50 entries.",
25
+ inputSchema: {
26
+ "$schema": SCHEMA, type: "object",
27
+ properties: {limit: {type: "integer", description: "Number of reports to return, 1-50. Default 20."}},
28
+ required: [], additionalProperties: false
29
+ },
30
+ annotations: {readOnlyHint: true}
31
+ },
32
+ {
33
+ name: "csp_report_summary",
34
+ description: "Content Security Policy violations over the last days, grouped by directive, " \
35
+ "blocked URI and page, most frequent first: one group per thing to fix, with its count and " \
36
+ "last_seen (ISO 8601 UTC). Also returns the total number of reports in the window. Returns at " \
37
+ "most 50 groups.",
38
+ inputSchema: {
39
+ "$schema": SCHEMA, type: "object",
40
+ properties: {days: {type: "integer", description: "Window length in days, 1-30. Default 7."}},
41
+ required: [], additionalProperties: false
42
+ },
43
+ annotations: {readOnlyHint: true}
44
+ }
45
+ ].freeze
46
+
47
+ def self.list_csp_reports(scope:, limit: 20)
48
+ reports = ::Security::CspViolation.recent.limit(limit.to_i.clamp(1, MAX_LIMIT)).map do |violation|
49
+ {effective_directive: violation.effective_directive, blocked_uri: violation.blocked_uri,
50
+ document_uri: violation.document_uri, source_file: violation.source_file,
51
+ line_number: violation.line_number, column_number: violation.column_number,
52
+ disposition: violation.disposition, sample: violation.sample, user_agent: violation.user_agent,
53
+ at: violation.created_at.utc.iso8601}
54
+ end
55
+
56
+ {reports: reports}
57
+ end
58
+
59
+ def self.csp_report_summary(scope:, days: 7)
60
+ days = days.to_i.clamp(1, MAX_DAYS)
61
+ report = ::Security::CspViolationReport.new(window: days.days, limit: MAX_LIMIT)
62
+
63
+ groups = report.top.map do |group|
64
+ group.to_h.merge(last_seen: group.last_seen&.utc&.iso8601)
65
+ end
66
+
67
+ {days: days, total: report.total, groups: groups}
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,35 @@
1
+ require "test_helper"
2
+
3
+ # The CSP report MCP tools are admin only (generated by maquina:csp_reports).
4
+ # A member's token must not learn they exist: they are absent from tools/list
5
+ # and a call gets the same -32602 as an unknown tool. McpTestHelper turns
6
+ # any MCP feature flag on, so the role is the only gate tested.
7
+ class Mcp::Tools::CspReportsTest < ActionDispatch::IntegrationTest
8
+ TOOLS = Mcp::Tools::CspReports::DEFINITIONS.map { |definition| definition[:name] }
9
+ ARGUMENTS = {}.freeze
10
+
11
+ test "an admin sees the tools" do
12
+ assert_equal TOOLS, TOOLS & listed_tools(users(:mcp_admin))
13
+ end
14
+
15
+ test "a member does not see the tools" do
16
+ assert_empty TOOLS & listed_tools(users(:mcp_member))
17
+ end
18
+
19
+ test "a member calling any of the tools gets unknown tool" do
20
+ token = oauth_token_for(users(:mcp_member), scope: "read write")
21
+
22
+ TOOLS.each do |name|
23
+ mcp_post({jsonrpc: "2.0", id: 1, method: "tools/call", params: {name: name, arguments: ARGUMENTS.fetch(name, {})}}, token: token)
24
+
25
+ assert_equal(-32602, response.parsed_body.dig("error", "code"), name)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def listed_tools(user)
32
+ mcp_post({jsonrpc: "2.0", id: 1, method: "tools/list", params: {}}, token: oauth_token_for(user, scope: "read write"))
33
+ response.parsed_body.dig("result", "tools").map { |tool| tool["name"] }
34
+ end
35
+ end
@@ -0,0 +1,37 @@
1
+ Description:
2
+ Adds an MCP server at POST /mcp for protocol revision 2026-07-28, for
3
+ clients signed in through maquina:oauth. Needs maquina:oauth. Hand-rolled:
4
+ no mcp gem and no CORS gem in the app.
5
+
6
+ Stateless JSON-RPC: every request carries its protocol version in
7
+ params._meta and mirrors it, the method and the tool name in the
8
+ MCP-Protocol-Version, Mcp-Method and Mcp-Name headers. server/discover
9
+ answers without a token; tools/list and tools/call need a bearer token
10
+ issued for this server's /mcp. initialize gets a 404 naming 2026-07-28,
11
+ GET and DELETE get a 405. Tools that change data need the write scope
12
+ (403 insufficient_scope otherwise). Calls are rate limited per token.
13
+
14
+ Installs McpController, the McpAuthenticatable concern, a CORS middleware
15
+ (config/initializers/mcp_cors.rb), the tool registry in app/models/mcp/
16
+ (Tools, ToolDefinitions, ToolError, UserScope) and app/models/mcp/tools/app.rb
17
+ with a whoami tool, written only if missing. Tools reach the token's own
18
+ user's data through Mcp::UserScope. If solid_errors, solid_queue, security
19
+ or csp_reports are already installed, their admin-only MCP tools are added
20
+ too. Admin-only tools are for users on the backstage list (backstage.emails
21
+ in credentials, User#backstage_access?, added to app/models/user.rb); an
22
+ account's admin role is not enough. Generates a test helper (mcp_post, oauth_token_for), an end-to-end
23
+ flow test, a protocol conformance test and a registry test. Anything it
24
+ cannot place safely is listed after the install.
25
+
26
+ Options:
27
+ --flag NAME Gate MCP behind a feature flag: rewrites Mcp::FLAG = nil in
28
+ app/models/mcp.rb and adds flag-off tests
29
+ --prefix PATH Where maquina:oauth put Connected apps (default /settings)
30
+ --skip-views maquina:oauth ran with --skip-views; the flow test skips its pages
31
+ --quiet Suppress post-install instructions
32
+
33
+ Examples:
34
+ rails g maquina:oauth && rails g maquina:mcp
35
+ rails g maquina:mcp --flag beta_mcp
36
+
37
+ Then: npx @modelcontextprotocol/inspector, connect to http://localhost:3000/mcp