iron-cms 0.18.2 → 0.19.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -0
  3. data/app/assets/builds/iron.css +255 -218
  4. data/app/controllers/concerns/iron/api/token_authentication.rb +2 -7
  5. data/app/controllers/concerns/iron/bounded_request_body.rb +48 -0
  6. data/app/controllers/iron/api/base_controller.rb +3 -0
  7. data/app/controllers/iron/api/mcp_controller.rb +119 -0
  8. data/app/controllers/iron/api/openapi_controller.rb +1 -8
  9. data/app/controllers/iron/oauth/authorizations_controller.rb +120 -0
  10. data/app/controllers/iron/oauth/metadata_controller.rb +38 -0
  11. data/app/controllers/iron/oauth/registrations_controller.rb +54 -0
  12. data/app/controllers/iron/oauth/tokens_controller.rb +92 -0
  13. data/app/mcp/iron/mcp/error.rb +5 -0
  14. data/app/mcp/iron/mcp/forbidden.rb +9 -0
  15. data/app/mcp/iron/mcp/invalid_request.rb +5 -0
  16. data/app/mcp/iron/mcp/paginator.rb +19 -0
  17. data/app/mcp/iron/mcp/server.rb +38 -0
  18. data/app/mcp/iron/mcp/tool_context.rb +114 -0
  19. data/app/mcp/iron/mcp/tools/base.rb +61 -0
  20. data/app/mcp/iron/mcp/tools/create_entry.rb +28 -0
  21. data/app/mcp/iron/mcp/tools/delete_entry.rb +25 -0
  22. data/app/mcp/iron/mcp/tools/describe_schema.rb +20 -0
  23. data/app/mcp/iron/mcp/tools/get_entry.rb +25 -0
  24. data/app/mcp/iron/mcp/tools/list_content_types.rb +20 -0
  25. data/app/mcp/iron/mcp/tools/list_entries.rb +27 -0
  26. data/app/mcp/iron/mcp/tools/search.rb +25 -0
  27. data/app/mcp/iron/mcp/tools/update_entry.rb +28 -0
  28. data/app/mcp/iron/mcp/tools/upload_asset.rb +27 -0
  29. data/app/models/iron/api/openapi_spec.rb +7 -0
  30. data/app/models/iron/content/download_budget.rb +31 -0
  31. data/app/models/iron/content.rb +201 -32
  32. data/app/models/iron/field_definition.rb +7 -1
  33. data/app/models/iron/field_definitions/block.rb +3 -1
  34. data/app/models/iron/field_definitions/block_list.rb +3 -1
  35. data/app/models/iron/integration.rb +11 -0
  36. data/app/models/iron/oauth/client.rb +63 -0
  37. data/app/models/iron/oauth/grant.rb +67 -0
  38. data/app/models/iron/oauth/resource.rb +27 -0
  39. data/app/models/iron/oauth/scope.rb +13 -0
  40. data/app/models/iron/oauth/secret.rb +13 -0
  41. data/app/models/iron/oauth/token.rb +123 -0
  42. data/app/models/iron/ssrf_protection.rb +82 -0
  43. data/app/views/iron/authentication/_brandmark.html.erb +5 -0
  44. data/app/views/iron/oauth/authorizations/new.html.erb +31 -0
  45. data/app/views/iron/sessions/new.html.erb +1 -5
  46. data/app/views/layouts/iron/application.html.erb +3 -0
  47. data/app/views/layouts/iron/authentication.html.erb +3 -0
  48. data/config/locales/en.yml +12 -0
  49. data/config/locales/it.yml +12 -0
  50. data/config/routes.rb +1 -0
  51. data/db/migrate/20260626090000_create_iron_oauth_tables.rb +50 -0
  52. data/lib/generators/iron/install/install_generator.rb +19 -0
  53. data/lib/iron/engine.rb +6 -0
  54. data/lib/iron/oauth_body_limit.rb +52 -0
  55. data/lib/iron/version.rb +1 -1
  56. metadata +50 -2
@@ -13,15 +13,10 @@ module Iron
13
13
 
14
14
  def authenticate_integration
15
15
  authenticate_with_http_token do |token|
16
- integration = Integration.find_by(token: token)
17
- return head(:unauthorized) unless integration
18
- return head(:unauthorized) if integration.expired?
19
-
20
- user = integration.user
21
- return head(:unauthorized) unless user&.active?
16
+ user = Integration.authenticate(token)
17
+ return head(:unauthorized) unless user
22
18
 
23
19
  Current.user = user
24
- integration.touch(:last_used_at)
25
20
  end || head(:unauthorized)
26
21
  end
27
22
  end
@@ -0,0 +1,48 @@
1
+ module Iron
2
+ module BoundedRequestBody
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class_attribute :max_request_bytes, default: 10.megabytes, instance_writer: false
7
+ end
8
+
9
+ class_methods do
10
+ def bound_request_body(to:)
11
+ self.max_request_bytes = to
12
+ end
13
+ end
14
+
15
+ # Action Controller's instrumentation reads the request parameters — fully
16
+ # buffering and parsing the body — before any controller callback runs, so
17
+ # the size guard must wrap process_action itself: a before_action fires too
18
+ # late to stop an oversized body from being buffered into memory.
19
+ def process_action(...)
20
+ if acceptable_request_body?
21
+ super
22
+ else
23
+ head :content_too_large
24
+ end
25
+ end
26
+
27
+ private
28
+ def acceptable_request_body?
29
+ request.get_header("CONTENT_LENGTH").to_i <= max_request_bytes && buffer_request_body
30
+ end
31
+
32
+ # Re-homes the body onto a capped in-memory copy so a chunked request
33
+ # that never declared its length still can't feed the parameter parser
34
+ # more than the limit.
35
+ def buffer_request_body
36
+ body = request.body_stream&.read(max_request_bytes + 1).to_s
37
+ return false if body.bytesize > max_request_bytes
38
+
39
+ replace_request_body(StringIO.new(body), body.bytesize)
40
+ true
41
+ end
42
+
43
+ def replace_request_body(input, length)
44
+ request.set_header("rack.input", input)
45
+ request.set_header("CONTENT_LENGTH", length.to_s)
46
+ end
47
+ end
48
+ end
@@ -3,7 +3,10 @@ module Iron
3
3
  class BaseController < ActionController::API
4
4
  include ActiveStorage::SetCurrent
5
5
  include TokenAuthentication, LocaleResolution, Authorization
6
+ include BoundedRequestBody
6
7
 
8
+ # Sized for base64-encoded uploads of MAX_DOWNLOAD_BYTES-sized files.
9
+ bound_request_body to: 75.megabytes
7
10
  rate_limit to: 60, within: 1.minute
8
11
 
9
12
  rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
@@ -0,0 +1,119 @@
1
+ module Iron
2
+ module Api
3
+ class McpController < BaseController
4
+ skip_before_action :authenticate_integration
5
+ # The default locale resolution reads params; resolve from the query
6
+ # string instead so nothing needs the (detached) body before `handle`.
7
+ skip_before_action :set_locale
8
+ before_action :verify_request_origin
9
+ before_action :verify_protocol_version
10
+ before_action :authenticate_mcp!
11
+ before_action :resolve_mcp_locale
12
+
13
+ def handle
14
+ return head :method_not_allowed unless request.post?
15
+
16
+ body = bounded_body
17
+ return head :content_too_large unless body
18
+ return reject_batch if batch?(body)
19
+
20
+ reply = server.handle_json(body)
21
+
22
+ if reply.nil?
23
+ head :accepted
24
+ else
25
+ render json: reply
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ # The JSON-RPC server consumes the raw body, so nothing should parse it
32
+ # into params: detach it before the framework's instrumentation runs,
33
+ # and read it — capped — only once the request is authenticated.
34
+ def buffer_request_body
35
+ request.set_header("iron.mcp_input", request.body_stream)
36
+ replace_request_body(StringIO.new(""), 0)
37
+ true
38
+ end
39
+
40
+ def bounded_body
41
+ body = request.get_header("iron.mcp_input")&.read(max_request_bytes + 1).to_s
42
+ body unless body.bytesize > max_request_bytes
43
+ end
44
+
45
+ # JSON-RPC batching was removed from the MCP spec (2025-06-18), and a
46
+ # batch would let one rate-limited request amplify into arbitrarily
47
+ # many tool calls.
48
+ def batch?(body)
49
+ body.match?(/\A\s*\[/)
50
+ end
51
+
52
+ def reject_batch
53
+ render json: {
54
+ jsonrpc: "2.0",
55
+ error: { code: -32600, message: "JSON-RPC batch requests are not supported" },
56
+ id: nil
57
+ }, status: :bad_request
58
+ end
59
+
60
+ # This endpoint has no legitimate browser client: Claude Code, the
61
+ # claude.ai backend, and scripts all call it server-to-server with no
62
+ # Origin header. Any Origin therefore marks a browser-originated
63
+ # request — including a DNS-rebinding page — and is refused. Matching
64
+ # the request Host instead would be no defense, since a rebinding
65
+ # attacker controls the Host as well as the Origin.
66
+ def verify_request_origin
67
+ head :forbidden if request.headers["Origin"].present?
68
+ end
69
+
70
+ # An explicit MCP-Protocol-Version must be one the server speaks; an
71
+ # absent header falls back to the negotiated default per the spec.
72
+ def verify_protocol_version
73
+ version = request.headers["MCP-Protocol-Version"]
74
+ return if version.blank? || MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(version)
75
+
76
+ head :bad_request
77
+ end
78
+
79
+ def resolve_mcp_locale
80
+ code = request.query_parameters[:locale]
81
+ Current.locale = (Locale.find_by(code: code) if code.present?) || Locale.default
82
+ end
83
+
84
+ def server
85
+ Iron::Mcp::Server.build(context: Iron::Mcp::ToolContext.new)
86
+ end
87
+
88
+ def authenticate_mcp!
89
+ token = bearer_token
90
+ user = token.present? && (user_from_oauth(token) || Integration.authenticate(token))
91
+
92
+ return Current.user = user if user
93
+
94
+ challenge_for_authorization
95
+ end
96
+
97
+ def bearer_token
98
+ ActionController::HttpAuthentication::Token.token_and_options(request)&.first
99
+ end
100
+
101
+ def user_from_oauth(token)
102
+ access = Iron::Oauth::Token.authenticate(token)
103
+ return unless access&.scopes_include?(Iron::Oauth::Scope::MCP_ACCESS)
104
+ return unless access.audience == Oauth::Resource.url(request.base_url)
105
+
106
+ user = access.user
107
+ user if user&.active?
108
+ rescue ActiveRecord::StatementInvalid
109
+ nil
110
+ end
111
+
112
+ def challenge_for_authorization
113
+ metadata_url = "#{request.base_url}/.well-known/oauth-protected-resource"
114
+ response.set_header("WWW-Authenticate", %(Bearer resource_metadata="#{metadata_url}"))
115
+ head :unauthorized
116
+ end
117
+ end
118
+ end
119
+ end
@@ -4,19 +4,12 @@ module Iron
4
4
  skip_before_action :authenticate_integration
5
5
 
6
6
  def show
7
- spec = Rails.cache.fetch(cache_key, expires_in: 1.hour) do
7
+ spec = Rails.cache.fetch(OpenapiSpec.cache_key, expires_in: 1.hour) do
8
8
  OpenapiSpec.new.to_h
9
9
  end
10
10
 
11
11
  render json: spec
12
12
  end
13
-
14
- private
15
-
16
- def cache_key
17
- latest = [ ContentType, FieldDefinition, BlockDefinition ].filter_map { |m| m.maximum(:updated_at) }.max
18
- "iron/openapi/#{latest&.to_i}"
19
- end
20
13
  end
21
14
  end
22
15
  end
@@ -0,0 +1,120 @@
1
+ module Iron
2
+ module Oauth
3
+ class AuthorizationsController < Iron::ApplicationController
4
+ PKCE_CHALLENGE = /\A[A-Za-z0-9\-_]{43}\z/
5
+
6
+ layout "iron/authentication"
7
+
8
+ before_action :load_client
9
+ before_action :validate_authorization_request
10
+
11
+ def new
12
+ end
13
+
14
+ def create
15
+ if approved?
16
+ _grant, code = Grant.issue!(
17
+ client: @client,
18
+ user: Current.user,
19
+ redirect_uri: @redirect_uri,
20
+ scopes: @scopes,
21
+ code_challenge: @code_challenge,
22
+ code_challenge_method: @code_challenge_method,
23
+ resource: @resource
24
+ )
25
+ redirect_to_client(code: code)
26
+ else
27
+ redirect_to_client(error: "access_denied")
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def load_client
34
+ @client = Client.find_by(uid: params[:client_id])
35
+ @redirect_uri = params[:redirect_uri].to_s
36
+
37
+ render_invalid_request unless @client&.redirect_uri_allowed?(@redirect_uri)
38
+ end
39
+
40
+ def validate_authorization_request
41
+ return redirect_to_client(error: "unsupported_response_type") unless params[:response_type] == "code"
42
+ return redirect_to_client(error: "invalid_request") if pkce_missing?
43
+ return redirect_to_client(error: "invalid_request") unless scope_param_valid?
44
+ return redirect_to_client(error: "invalid_scope") unless scopes_subset?
45
+ return redirect_to_client(error: "invalid_target") unless resource_permitted?
46
+
47
+ @scopes = requested_scopes.join(" ")
48
+ @code_challenge = params[:code_challenge]
49
+ @code_challenge_method = params[:code_challenge_method]
50
+ @resource = Resource.normalize(params[:resource])
51
+ end
52
+
53
+ def approved?
54
+ params[:decision] == "approve"
55
+ end
56
+
57
+ def pkce_missing?
58
+ params[:code_challenge_method] != "S256" || !params[:code_challenge].to_s.match?(PKCE_CHALLENGE)
59
+ end
60
+
61
+ def scope_param_valid?
62
+ params[:scope].nil? || params[:scope].is_a?(String)
63
+ end
64
+
65
+ def scopes_subset?
66
+ (requested_scopes - @client.scopes.split).empty?
67
+ end
68
+
69
+ def requested_scopes
70
+ params[:scope].presence&.split || @client.scopes.split
71
+ end
72
+
73
+ def resource_permitted?
74
+ Resource.permitted?(params[:resource], base_url: request.base_url)
75
+ end
76
+
77
+ def redirect_to_client(query)
78
+ redirect_to authorization_response_uri(query), allow_other_host: true
79
+ end
80
+
81
+ def authorization_response_uri(query)
82
+ uri = URI.parse(@redirect_uri)
83
+ pairs = URI.decode_www_form(uri.query.to_s)
84
+ pairs += query.merge(state: state_param).compact.to_a
85
+ uri.query = URI.encode_www_form(pairs)
86
+ uri.to_s
87
+ end
88
+
89
+ def state_param
90
+ params[:state] if params[:state].is_a?(String)
91
+ end
92
+
93
+ def render_invalid_request
94
+ render plain: t("iron.oauth.authorizations.invalid_request"), status: :bad_request
95
+ end
96
+
97
+ def request_authentication
98
+ session[:return_to_after_authenticating] = resumable_authorization_url
99
+ redirect_to Iron::Engine.routes.url_helpers.sign_in_path
100
+ end
101
+
102
+ def resumable_authorization_url
103
+ "/oauth/authorize?" + authorization_request_params.to_query
104
+ end
105
+
106
+ def authorization_request_params
107
+ {
108
+ client_id: params[:client_id],
109
+ redirect_uri: params[:redirect_uri],
110
+ response_type: params[:response_type],
111
+ scope: params[:scope],
112
+ state: params[:state],
113
+ code_challenge: params[:code_challenge],
114
+ code_challenge_method: params[:code_challenge_method],
115
+ resource: params[:resource]
116
+ }.compact
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,38 @@
1
+ module Iron
2
+ module Oauth
3
+ class MetadataController < ActionController::API
4
+ def protected_resource
5
+ render json: {
6
+ resource: mcp_resource_url,
7
+ authorization_servers: [ issuer ],
8
+ bearer_methods_supported: %w[ header ],
9
+ scopes_supported: Scope::SUPPORTED
10
+ }
11
+ end
12
+
13
+ def authorization_server
14
+ render json: {
15
+ issuer: issuer,
16
+ authorization_endpoint: "#{issuer}/oauth/authorize",
17
+ token_endpoint: "#{issuer}/oauth/token",
18
+ registration_endpoint: "#{issuer}/oauth/register",
19
+ response_types_supported: %w[ code ],
20
+ grant_types_supported: %w[ authorization_code refresh_token ],
21
+ token_endpoint_auth_methods_supported: %w[ none ],
22
+ code_challenge_methods_supported: %w[ S256 ],
23
+ scopes_supported: Scope::SUPPORTED
24
+ }
25
+ end
26
+
27
+ private
28
+
29
+ def issuer
30
+ request.base_url
31
+ end
32
+
33
+ def mcp_resource_url
34
+ "#{issuer}#{Iron::Engine.routes.url_helpers.api_mcp_path}"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,54 @@
1
+ module Iron
2
+ module Oauth
3
+ class RegistrationsController < ActionController::API
4
+ include Iron::BoundedRequestBody
5
+
6
+ bound_request_body to: 64.kilobytes
7
+ rate_limit to: 10, within: 1.minute, only: :create
8
+
9
+ def create
10
+ client = Client.new(
11
+ name: client_name,
12
+ redirect_uris: redirect_uris.join("\n"),
13
+ scopes: Scope.default
14
+ )
15
+
16
+ if client.save
17
+ render json: client_information(client), status: :created
18
+ else
19
+ render json: registration_error(client), status: :bad_request
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def client_name
26
+ params[:client_name].presence || "MCP Client"
27
+ end
28
+
29
+ def redirect_uris
30
+ Array(params[:redirect_uris]).map(&:to_s).reject(&:blank?)
31
+ end
32
+
33
+ def client_information(client)
34
+ {
35
+ client_id: client.uid,
36
+ client_id_issued_at: client.created_at.to_i,
37
+ client_name: client.name,
38
+ redirect_uris: client.redirect_uri_list,
39
+ grant_types: %w[ authorization_code refresh_token ],
40
+ response_types: %w[ code ],
41
+ token_endpoint_auth_method: "none",
42
+ scope: client.scopes.to_s
43
+ }
44
+ end
45
+
46
+ def registration_error(client)
47
+ {
48
+ error: "invalid_client_metadata",
49
+ error_description: client.errors.full_messages.join(", ")
50
+ }
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,92 @@
1
+ module Iron
2
+ module Oauth
3
+ class TokensController < ActionController::API
4
+ include Iron::BoundedRequestBody
5
+
6
+ PKCE_VERIFIER = /\A[A-Za-z0-9\-._~]{43,128}\z/
7
+
8
+ bound_request_body to: 64.kilobytes
9
+ rate_limit to: 30, within: 1.minute, only: :create
10
+ before_action :prevent_token_caching, only: :create
11
+
12
+ def create
13
+ case params[:grant_type]
14
+ when "authorization_code"
15
+ issue_from_authorization_code
16
+ when "refresh_token"
17
+ issue_from_refresh_token
18
+ else
19
+ render_error("unsupported_grant_type")
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def issue_from_authorization_code
26
+ code = string_param(:code)
27
+ verifier = string_param(:code_verifier)
28
+ redirect_uri = string_param(:redirect_uri)
29
+ resource = string_param(:resource)
30
+ return render_error("invalid_request") if code.blank? || redirect_uri.blank?
31
+ return render_error("invalid_request") unless verifier&.match?(PKCE_VERIFIER)
32
+ return render_error("invalid_target") unless Resource.permitted?(resource, base_url: request.base_url)
33
+
34
+ client = Client.find_by(uid: string_param(:client_id))
35
+ return render_error("invalid_client") unless client
36
+
37
+ issued = Grant.redeem(code, client: client, redirect_uri: redirect_uri, verifier: verifier, resource: Resource.normalize(resource)) do |grant|
38
+ Token.issue!(client: client, user: grant.user, scopes: grant.scopes, audience: grant.resource)
39
+ end
40
+ return render_error("invalid_grant") unless issued
41
+
42
+ render_token issued
43
+ end
44
+
45
+ # The resource indicator is optional on refresh: the rotated token
46
+ # keeps the grant's audience either way, so omitting it can't broaden
47
+ # what the token is good for — but naming a foreign resource is an
48
+ # explicit request this server must refuse.
49
+ def issue_from_refresh_token
50
+ refresh_token = string_param(:refresh_token)
51
+ return render_error("invalid_request") if refresh_token.blank?
52
+ return render_error("invalid_target") unless refresh_resource_permitted?
53
+
54
+ client = Client.find_by(uid: string_param(:client_id))
55
+ return render_error("invalid_client") unless client
56
+
57
+ issued = Token.exchange_refresh(refresh_token, client: client)
58
+ return render_error("invalid_grant") unless issued
59
+
60
+ render_token issued
61
+ end
62
+
63
+ def refresh_resource_permitted?
64
+ params[:resource].nil? || Resource.permitted?(string_param(:resource), base_url: request.base_url)
65
+ end
66
+
67
+ def string_param(key)
68
+ value = params[key]
69
+ value if value.is_a?(String)
70
+ end
71
+
72
+ def render_token(issued)
73
+ render json: {
74
+ access_token: issued.access_token,
75
+ token_type: "Bearer",
76
+ expires_in: issued.expires_in,
77
+ refresh_token: issued.refresh_token,
78
+ scope: issued.scope
79
+ }
80
+ end
81
+
82
+ def render_error(code)
83
+ render json: { error: code }, status: :bad_request
84
+ end
85
+
86
+ def prevent_token_caching
87
+ response.headers["Cache-Control"] = "no-store"
88
+ response.headers["Pragma"] = "no-cache"
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,5 @@
1
+ module Iron
2
+ module Mcp
3
+ class Error < StandardError; end
4
+ end
5
+ end
@@ -0,0 +1,9 @@
1
+ module Iron
2
+ module Mcp
3
+ class Forbidden < Error
4
+ def initialize(message = "Your token is not allowed to write content")
5
+ super
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ module Iron
2
+ module Mcp
3
+ class InvalidRequest < Error; end
4
+ end
5
+ end
@@ -0,0 +1,19 @@
1
+ module Iron
2
+ module Mcp
3
+ class Paginator
4
+ include Iron::Api::CursorPagination
5
+
6
+ def initialize(after: nil, per_page: nil)
7
+ @params = { after:, per_page: }.compact.with_indifferent_access
8
+ end
9
+
10
+ def call(scope)
11
+ paginate(scope)
12
+ end
13
+
14
+ private
15
+
16
+ attr_reader :params
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,38 @@
1
+ module Iron
2
+ module Mcp
3
+ module Server
4
+ module_function
5
+
6
+ def build(context:)
7
+ ::MCP::Server.new(
8
+ name: "iron-cms",
9
+ title: "Iron CMS",
10
+ version: Iron::VERSION,
11
+ tools: tools,
12
+ server_context: { iron: context },
13
+ configuration: configuration
14
+ )
15
+ end
16
+
17
+ def tools
18
+ [
19
+ Tools::ListContentTypes,
20
+ Tools::DescribeSchema,
21
+ Tools::ListEntries,
22
+ Tools::GetEntry,
23
+ Tools::CreateEntry,
24
+ Tools::UpdateEntry,
25
+ Tools::DeleteEntry,
26
+ Tools::UploadAsset,
27
+ Tools::Search
28
+ ]
29
+ end
30
+
31
+ def configuration
32
+ ::MCP::Configuration.new(
33
+ exception_reporter: ->(exception, _server_context) { Rails.error.report(exception, handled: true, source: "iron.mcp") }
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end