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.
- checksums.yaml +4 -4
- data/README.md +189 -9
- data/lib/generators/maquina/app/USAGE +4 -0
- data/lib/generators/maquina/app/app_generator.rb +30 -4
- data/lib/generators/maquina/app/templates/README.md.erb +21 -0
- data/lib/generators/maquina/app/templates/app/views/home/index.html.erb +1 -1
- data/lib/generators/maquina/backstage_signin/backstage_signin.rb +245 -0
- data/lib/generators/maquina/backstage_signin/templates/test/integration/backstage_access_test.rb +78 -0
- data/lib/generators/maquina/csp_reports/csp_reports_generator.rb +13 -1
- data/lib/generators/maquina/csp_reports/templates/app/models/mcp/tools/csp_reports.rb +71 -0
- data/lib/generators/maquina/csp_reports/templates/test/models/mcp/tools/csp_reports_test.rb +35 -0
- data/lib/generators/maquina/mcp/USAGE +37 -0
- data/lib/generators/maquina/mcp/mcp_generator.rb +188 -0
- data/lib/generators/maquina/mcp/mcp_sibling_tools.rb +104 -0
- data/lib/generators/maquina/mcp/templates/app/controllers/concerns/mcp_authenticatable.rb +63 -0
- data/lib/generators/maquina/mcp/templates/app/controllers/mcp_controller.rb +205 -0
- data/lib/generators/maquina/mcp/templates/app/models/mcp/tool_definitions.rb +38 -0
- data/lib/generators/maquina/mcp/templates/app/models/mcp/tool_error.rb +17 -0
- data/lib/generators/maquina/mcp/templates/app/models/mcp/tools/app.rb +51 -0
- data/lib/generators/maquina/mcp/templates/app/models/mcp/tools.rb +83 -0
- data/lib/generators/maquina/mcp/templates/app/models/mcp/user_scope.rb +50 -0
- data/lib/generators/maquina/mcp/templates/config/initializers/mcp_cors.rb +38 -0
- data/lib/generators/maquina/mcp/templates/test/integration/mcp_conformance_test.rb +223 -0
- data/lib/generators/maquina/mcp/templates/test/integration/mcp_flow_test.rb.tt +65 -0
- data/lib/generators/maquina/mcp/templates/test/models/mcp/tools_test.rb.tt +145 -0
- data/lib/generators/maquina/mcp/templates/test/test_helpers/mcp_test_helper.rb +89 -0
- data/lib/generators/maquina/mcp_tool/USAGE +26 -0
- data/lib/generators/maquina/mcp_tool/mcp_tool_generator.rb +239 -0
- data/lib/generators/maquina/mcp_tool/templates/test/models/mcp/tools/tool_test.rb.tt +49 -0
- data/lib/generators/maquina/mission_control_jobs/USAGE +3 -0
- data/lib/generators/maquina/mission_control_jobs/mission_control_jobs_generator.rb +19 -5
- data/lib/generators/maquina/mission_control_jobs/templates/app/views/backstage_dashboard/index.html.erb.tt +1 -1
- data/lib/generators/maquina/mission_control_jobs/templates/app/views/layouts/_admin_navigation.html.erb.tt +12 -11
- data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/jobs/_filters.html.erb +1 -1
- data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/jobs/_general_information.html.erb +1 -1
- data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/jobs/_job.html.erb +1 -1
- data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/jobs/failed/_job.html.erb +1 -1
- data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/queues/_job.html.erb +1 -1
- data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/shared/_job.html.erb +1 -1
- data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/workers/_worker.html.erb +1 -1
- data/lib/generators/maquina/oauth/USAGE +32 -0
- data/lib/generators/maquina/oauth/oauth_generator.rb +352 -0
- data/lib/generators/maquina/oauth/templates/app/controllers/connected_applications_controller.rb +26 -0
- data/lib/generators/maquina/oauth/templates/app/controllers/oauth/authorization_servers_controller.rb +24 -0
- data/lib/generators/maquina/oauth/templates/app/controllers/oauth/authorizations_controller.rb +92 -0
- data/lib/generators/maquina/oauth/templates/app/controllers/oauth/protected_resources_controller.rb +14 -0
- data/lib/generators/maquina/oauth/templates/app/controllers/oauth/revocations_controller.rb +20 -0
- data/lib/generators/maquina/oauth/templates/app/controllers/oauth/tokens_controller.rb +67 -0
- data/lib/generators/maquina/oauth/templates/app/models/access_token.rb +83 -0
- data/lib/generators/maquina/oauth/templates/app/models/connected_application.rb +49 -0
- data/lib/generators/maquina/oauth/templates/app/models/mcp.rb.tt +21 -0
- data/lib/generators/maquina/oauth/templates/app/models/oauth_authorization_code.rb +52 -0
- data/lib/generators/maquina/oauth/templates/app/models/oauth_client/metadata_fetcher.rb +195 -0
- data/lib/generators/maquina/oauth/templates/app/models/oauth_client.rb +50 -0
- data/lib/generators/maquina/oauth/templates/app/views/connected_applications/index.html.erb +43 -0
- data/lib/generators/maquina/oauth/templates/app/views/oauth/authorizations/error.html.erb +8 -0
- data/lib/generators/maquina/oauth/templates/app/views/oauth/authorizations/new.html.erb +36 -0
- data/lib/generators/maquina/oauth/templates/config/locales/oauth.en.yml +34 -0
- data/lib/generators/maquina/oauth/templates/config/locales/oauth.es.yml +34 -0
- data/lib/generators/maquina/oauth/templates/migration_create_access_tokens.rb.tt +20 -0
- data/lib/generators/maquina/oauth/templates/migration_create_oauth_authorization_codes.rb.tt +19 -0
- data/lib/generators/maquina/oauth/templates/migration_create_oauth_clients.rb.tt +14 -0
- data/lib/generators/maquina/oauth/templates/test/fixtures/access_tokens.yml +48 -0
- data/lib/generators/maquina/oauth/templates/test/fixtures/oauth_authorization_codes.yml +11 -0
- data/lib/generators/maquina/oauth/templates/test/fixtures/oauth_clients.yml +7 -0
- data/lib/generators/maquina/oauth/templates/test/integration/connected_applications_test.rb.tt +70 -0
- data/lib/generators/maquina/oauth/templates/test/integration/oauth_authorization_test.rb.tt +150 -0
- data/lib/generators/maquina/oauth/templates/test/integration/oauth_discovery_test.rb +44 -0
- data/lib/generators/maquina/oauth/templates/test/integration/oauth_tokens_test.rb +147 -0
- data/lib/generators/maquina/oauth/templates/test/models/access_token_test.rb +78 -0
- data/lib/generators/maquina/oauth/templates/test/models/connected_application_test.rb +41 -0
- data/lib/generators/maquina/oauth/templates/test/models/oauth_authorization_code_test.rb +58 -0
- data/lib/generators/maquina/oauth/templates/test/test_helpers/oauth_test_helper.rb.tt +82 -0
- data/lib/generators/maquina/rack_attack/rack_attack_generator.rb +4 -1
- data/lib/generators/maquina/rack_attack/templates/config/initializers/rack_attack.rb.tt +317 -4
- data/lib/generators/maquina/security/USAGE +10 -0
- data/lib/generators/maquina/security/security_generator.rb +77 -7
- data/lib/generators/maquina/security/templates/app/models/mcp/tools/security.rb.tt +114 -0
- data/lib/generators/maquina/security/templates/app/views/backstage_dashboard/index.html.erb.tt +1 -1
- data/lib/generators/maquina/security/templates/app/views/layouts/_admin_navigation.html.erb.tt +12 -11
- data/lib/generators/maquina/security/templates/test/models/mcp/tools/security_test.rb +99 -0
- data/lib/generators/maquina/solid_errors/USAGE +15 -9
- data/lib/generators/maquina/solid_errors/solid_errors_generator.rb +40 -31
- data/lib/generators/maquina/solid_errors/templates/app/agents/errors_query.rb +48 -27
- data/lib/generators/maquina/solid_errors/templates/app/models/mcp/tools/solid_errors.rb.tt +114 -0
- data/lib/generators/maquina/solid_errors/templates/app/views/backstage_dashboard/index.html.erb.tt +1 -1
- data/lib/generators/maquina/solid_errors/templates/app/views/layouts/_admin_navigation.html.erb.tt +12 -11
- data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/errors/show/_actions.html.erb +0 -15
- data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/errors/show/_error_details.html.erb +5 -5
- data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/occurrences/_collection.html.erb +1 -1
- data/lib/generators/maquina/solid_errors/templates/test/models/mcp/tools/solid_errors_test.rb +109 -0
- data/lib/generators/maquina/solid_queue/USAGE +11 -0
- data/lib/generators/maquina/solid_queue/solid_queue_generator.rb +25 -1
- data/lib/generators/maquina/solid_queue/templates/app/models/mcp/tools/solid_queue.rb.tt +163 -0
- data/lib/generators/maquina/solid_queue/templates/test/models/mcp/tools/solid_queue_test.rb +99 -0
- data/lib/maquina_generators/version.rb +1 -1
- data/test/generators/maquina/app_generator_test.rb +58 -0
- data/test/generators/maquina/backstage_signin_test.rb +209 -0
- data/test/generators/maquina/csp_reports_generator_test.rb +125 -6
- data/test/generators/maquina/mcp_generator_test.rb +426 -0
- data/test/generators/maquina/mcp_tool_generator_test.rb +235 -0
- data/test/generators/maquina/mission_control_jobs_generator_test.rb +23 -0
- data/test/generators/maquina/oauth_client_metadata_fetcher_test.rb +290 -0
- data/test/generators/maquina/oauth_generator_test.rb +520 -0
- data/test/generators/maquina/rack_attack_generator_test.rb +71 -0
- data/test/generators/maquina/rack_attack_rules_test.rb +304 -0
- data/test/generators/maquina/security_generator_test.rb +188 -13
- data/test/generators/maquina/solid_errors_generator_test.rb +243 -33
- data/test/generators/maquina/solid_queue_generator_test.rb +175 -25
- data/test/test_helper.rb +1 -0
- metadata +69 -34
- data/lib/generators/maquina/solid_errors/templates/app/controllers/failures_mcp_controller.rb.tt +0 -65
- data/test/tmp/Gemfile +0 -2
- data/test/tmp/app/controllers/application_controller.rb +0 -3
- data/test/tmp/app/controllers/concerns/authentication.rb +0 -63
- data/test/tmp/app/controllers/registration/verification_resends_controller.rb +0 -38
- data/test/tmp/app/controllers/registration/verifications_controller.rb +0 -64
- data/test/tmp/app/controllers/registrations_controller.rb +0 -63
- data/test/tmp/app/controllers/session/verification_resends_controller.rb +0 -44
- data/test/tmp/app/controllers/session/verifications_controller.rb +0 -64
- data/test/tmp/app/controllers/sessions_controller.rb +0 -56
- data/test/tmp/app/helpers/authentication_helper.rb +0 -20
- data/test/tmp/app/jobs/authentication_cleanup_job.rb +0 -13
- data/test/tmp/app/mailers/verification_mailer.rb +0 -15
- data/test/tmp/app/models/account.rb +0 -5
- data/test/tmp/app/models/current.rb +0 -5
- data/test/tmp/app/models/email_verification.rb +0 -58
- data/test/tmp/app/models/session.rb +0 -18
- data/test/tmp/app/models/user.rb +0 -37
- data/test/tmp/app/views/registration/verifications/new.html.erb +0 -43
- data/test/tmp/app/views/registrations/new.html.erb +0 -39
- data/test/tmp/app/views/session/verifications/new.html.erb +0 -43
- data/test/tmp/app/views/sessions/new.html.erb +0 -40
- data/test/tmp/app/views/verification_mailer/verification_code.html.erb +0 -37
- data/test/tmp/app/views/verification_mailer/verification_code.text.erb +0 -11
- data/test/tmp/config/locales/clave.en.yml +0 -102
- data/test/tmp/config/locales/clave.es.yml +0 -102
- data/test/tmp/config/routes.rb +0 -12
- data/test/tmp/db/migrate/20261019191386_create_accounts.rb +0 -9
- data/test/tmp/db/migrate/20261019191387_create_users.rb +0 -17
- data/test/tmp/db/migrate/20261019191388_create_sessions.rb +0 -12
- data/test/tmp/db/migrate/20261019191389_create_email_verifications.rb +0 -17
- 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
|
data/lib/generators/maquina/backstage_signin/templates/test/integration/backstage_access_test.rb
ADDED
|
@@ -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.
|
|
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
|