rhino-rails 4.9.0 → 4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f33582b9aac75614096047f46227c443f63a31634c40cf8825a92d5fb064f583
4
- data.tar.gz: 94f626caf29a1e66132524f649b8b4dd6f2aeb52c88b48eca5f20479af653316
3
+ metadata.gz: 7940c1894a48b568ac853c65c73597adc6e56128b3a4d5958c003c6bc0ab03e9
4
+ data.tar.gz: 4214c47df8a3caaa7bb7ef81957a77935b795d67c057b1797bf4945e66c677cb
5
5
  SHA512:
6
- metadata.gz: 27865b6bb3173908f8990a908f87aae8222f7237d691b83dd1fa2607fc1bb86eb88791a37a1b1de44db0d0f4e1d3b4a731ac4d89441775f2b77ce92f9d0ec2b8
7
- data.tar.gz: 51bd6475c50215ff50bfede53d09bc627e86d19033cc45a5e4e7a3035dd6050430efc61a4b13b77c4d3461185edb519f093f9855f8cb50b98fdb35621a682aee
6
+ metadata.gz: 0f8235a208d9e2b23be8e6a33f2ad0623d56f43fa2443ed0727c19aa8888e888ea88778ea4885bb8d38abc2d3d3de030e500767f9e372b0986d0ebfb6037bd76
7
+ data.tar.gz: d373f8eed1679caef1d7f2f6a9f516161eb8eb6bfae9e6e536add126cf8a3f923ed849fe3713865810cd2d9e943905bf3befbc0201d599bbb0ab8455808fb4f2
@@ -15,6 +15,7 @@ module Rhino
15
15
  menu.choice "Model (with migration and factory)", "model"
16
16
  menu.choice "Policy (extends ResourcePolicy)", "policy"
17
17
  menu.choice "Scope (for ScopedDB)", "scope"
18
+ menu.choice "Request (validation for store/update)", "request"
18
19
  end
19
20
 
20
21
  name = ask("What is the resource name? (PascalCase singular, e.g., Post):")
@@ -32,6 +33,8 @@ module Rhino
32
33
  generate_policy(name)
33
34
  when "scope"
34
35
  generate_scope(name)
36
+ when "request"
37
+ generate_request(name)
35
38
  end
36
39
  end
37
40
 
@@ -198,6 +201,50 @@ module Rhino
198
201
  say ""
199
202
  end
200
203
 
204
+ # ----------------------------------------------------------------
205
+ # Request generation
206
+ # ----------------------------------------------------------------
207
+
208
+ # Generates {Model}StoreRequest / {Model}UpdateRequest into app/requests/,
209
+ # which Zeitwerk autoloads like every other app/* directory — no
210
+ # initializer and no eager_load_paths entry is needed for the naming
211
+ # convention to find them.
212
+ def generate_request(name)
213
+ model_name = name.sub(/(Store|Update)?Request\z/, "")
214
+ model_name = name if model_name.blank?
215
+
216
+ which = select("Which request classes should be generated?") do |menu|
217
+ menu.choice "Store (POST /{resource})", "store"
218
+ menu.choice "Update (PUT /{resource}/:id)", "update"
219
+ menu.choice "Both", "both"
220
+ end
221
+
222
+ actions = which == "both" ? %w[store update] : [which]
223
+ created = []
224
+
225
+ actions.each do |request_action|
226
+ class_name = request_class_name(model_name, request_action)
227
+ task("Generating #{class_name}") do
228
+ created << write_request_file(model_name, request_action)
229
+ end
230
+ end
231
+
232
+ say ""
233
+ say "#{created.length == 1 ? 'Request' : 'Requests'} generated successfully!", :green
234
+ say ""
235
+ created.each { |path| say " Created: #{path}" }
236
+ say ""
237
+ say " Next steps:", :yellow
238
+ say " 1. Declare an `attribute` for EVERY field the action may write —"
239
+ say " an undeclared field is dropped, not persisted."
240
+ say " 2. Add validations, and override authorize?/prepare if needed."
241
+ say ""
242
+ end
243
+
244
+ def request_class_name(model_name, request_action)
245
+ "#{model_name}#{request_action == 'update' ? 'Update' : 'Store'}Request"
246
+ end
247
+
201
248
  # ----------------------------------------------------------------
202
249
  # Column collection
203
250
  # ----------------------------------------------------------------
@@ -389,6 +436,23 @@ module Rhino
389
436
  File.write(dest, content)
390
437
  end
391
438
 
439
+ def write_request_file(name, request_action)
440
+ template = File.expand_path("../../templates/generate/request.rb.erb", __FILE__)
441
+ class_name = request_class_name(name, request_action)
442
+ relative = "app/requests/#{class_name.underscore}.rb"
443
+ dest = Rails.root.join(relative)
444
+ FileUtils.mkdir_p(File.dirname(dest))
445
+
446
+ content = ERB.new(File.read(template), trim_mode: "-").result_with_hash(
447
+ name: name,
448
+ class_name: class_name,
449
+ action: request_action
450
+ )
451
+
452
+ File.write(dest, content)
453
+ relative
454
+ end
455
+
392
456
  def register_model_in_config(name)
393
457
  config_path = Rails.root.join("config/initializers/rhino.rb")
394
458
  return unless File.exist?(config_path)
@@ -33,6 +33,11 @@ module Rhino
33
33
  # Filters to only permitted fields, then runs ActiveModel validations
34
34
  # and cross-tenant FK validation.
35
35
  #
36
+ # @deprecated Model-level validation is superseded by request classes
37
+ # ({Model}StoreRequest / {Model}UpdateRequest, see Rhino::ResourceRequest).
38
+ # It still works unchanged for every model that has no request class for
39
+ # the action and will be removed in 5.0.
40
+ #
36
41
  # @param params [Hash] The request data
37
42
  # @param permitted_fields [Array<String>] Fields the user is allowed to set (['*'] for all)
38
43
  # @param organization [Object, nil] Current organization for FK scoping (optional)
@@ -80,6 +85,25 @@ module Rhino
80
85
  end
81
86
  end
82
87
 
88
+ # Public entry point for cross-tenant FK validation.
89
+ #
90
+ # The request-class path (Rhino::ResourceRequest) runs its own validations
91
+ # and therefore never calls validate_for_action, but it still needs the
92
+ # cross-tenant FK check — including the indirect case, where the referenced
93
+ # table reaches the organization through a FK chain rather than an
94
+ # organization_id column. This wraps the existing private implementation so
95
+ # the chain walk and its class-level caches stay in one place.
96
+ #
97
+ # @param data [Hash] the write payload (string-keyed)
98
+ # @param organization [Object, nil]
99
+ # @return [Hash<String, Array<String>>] {} when there is no organization
100
+ def rhino_validate_foreign_keys(data, organization)
101
+ return {} unless organization
102
+ return {} unless data.is_a?(Hash)
103
+
104
+ validate_foreign_keys_for_organization(data, organization)
105
+ end
106
+
83
107
  private
84
108
 
85
109
  # Cache for FK chain lookups (class-level)
@@ -16,9 +16,14 @@ module Rhino
16
16
  # Default 3 — a base scope, a window, and one more predicate.
17
17
  attr_reader :max_scopes_per_request
18
18
  attr_reader :auth
19
+ # Explicit per-model request-class registrations, kept OUT of @models so its
20
+ # `slug => "ClassName"` shape (read all over the library) is untouched.
21
+ # Shape: { slug_sym => { store: "ClassName" | nil, update: "ClassName" | nil } }
22
+ attr_reader :model_requests
19
23
 
20
24
  def initialize
21
25
  @models = {}
26
+ @model_requests = {}
22
27
  @route_groups = {}
23
28
  @multi_tenant = {
24
29
  organization_identifier_column: "id"
@@ -63,9 +68,58 @@ module Rhino
63
68
 
64
69
  # Register a model with its slug
65
70
  # Usage: config.model :posts, 'Post'
66
- def model(slug, klass_name)
67
- @models[slug.to_sym] = klass_name.to_s
71
+ #
72
+ # The optional `store_request:` / `update_request:` keywords override the
73
+ # `{Model}StoreRequest` / `{Model}UpdateRequest` naming convention for that
74
+ # model's POST / PUT action:
75
+ #
76
+ # config.model :tasks, "Task", store_request: "CreateTask", update_request: "EditTask"
77
+ #
78
+ # Class NAMES (strings) are stored, never constants, so a dev-mode Zeitwerk
79
+ # reload never hands back an unloaded class. A registration that cannot be
80
+ # constantized at request time raises Rhino::ConfigurationError rather than
81
+ # silently skipping validation.
82
+ def model(slug, klass_name, store_request: nil, update_request: nil)
83
+ key = slug.to_sym
84
+ @models[key] = klass_name.to_s
85
+
86
+ requests = {
87
+ store: normalize_request_class_name(store_request),
88
+ update: normalize_request_class_name(update_request)
89
+ }
90
+
91
+ if requests[:store].nil? && requests[:update].nil?
92
+ @model_requests.delete(key)
93
+ else
94
+ @model_requests[key] = requests
95
+ end
96
+ end
97
+
98
+ # The explicitly registered request class NAME for a model slug + action,
99
+ # or nil when the model relies on the naming convention.
100
+ #
101
+ # @param slug [String, Symbol, nil]
102
+ # @param action [String, Symbol] "store" or "update"
103
+ # @return [String, nil]
104
+ def request_class_for(slug, action)
105
+ return nil if slug.nil? || slug.to_s.empty?
106
+
107
+ entry = @model_requests[slug.to_sym]
108
+ return nil unless entry
109
+
110
+ entry[action.to_s == "update" ? :update : :store]
111
+ end
112
+
113
+ # Coerce a request-class registration to a String class name. A Class is
114
+ # accepted for convenience but stored by name (reloader safety); blank
115
+ # values register nothing.
116
+ def normalize_request_class_name(value)
117
+ return nil if value.nil?
118
+
119
+ name = value.is_a?(Class) ? value.name.to_s : value.to_s
120
+ name.strip.empty? ? nil : name.strip
68
121
  end
122
+ private :normalize_request_class_name
69
123
 
70
124
  # Register a route group with its configuration
71
125
  # Usage: config.route_group :tenant, prefix: ':organization', middleware: [Rhino::Middleware::ResolveOrganizationFromRoute], models: :all
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ # Raised when Rhino is configured with something it cannot resolve at
5
+ # request time — today only an explicit request-class registration
6
+ # (`config.model :tasks, "Task", store_request: "..."`) whose class name
7
+ # cannot be constantized, or which does not inherit from
8
+ # Rhino::ResourceRequest.
9
+ #
10
+ # This is deliberately NOT rescued into a JSON response: a silently ignored
11
+ # validation class is a security hole, so a misconfigured app must fail
12
+ # loudly (500) on first use rather than quietly skipping validation.
13
+ class ConfigurationError < StandardError; end
14
+ end
@@ -95,6 +95,34 @@ module Rhino
95
95
  }, status: :forbidden
96
96
  end
97
97
 
98
+ # Request class (Rhino::ResourceRequest) for this model + action, if any.
99
+ # Resolved AFTER the forbidden-field gate so `prepare` can never launder a
100
+ # field past the policy, and BEFORE the legacy model rules, which are not
101
+ # consulted at all when a request class is present for this action.
102
+ if (request_class = request_class_for("store"))
103
+ status, payload = run_resource_request(request_class, data, "store", nil, model_class)
104
+ return render_request_class_forbidden if status == :forbidden
105
+ return render json: { errors: payload }, status: :unprocessable_entity if status == :invalid
106
+
107
+ add_organization_to_data(payload)
108
+
109
+ begin
110
+ created = model_class.create!(payload)
111
+ rescue ActiveRecord::RecordInvalid => e
112
+ # A model-level `validates` rule the request class did not reproduce
113
+ # still runs inside create!. Render it in the same envelope the
114
+ # request class's own failures use instead of letting it escape as a
115
+ # 500. The legacy path cannot reach here — it ran those very rules up
116
+ # front — so this rescue is confined to the request-class branch.
117
+ return render json: { errors: record_validation_errors(e.record) },
118
+ status: :unprocessable_entity
119
+ end
120
+
121
+ return render json: serialize_record(created), status: :created
122
+ end
123
+
124
+ # @deprecated Legacy model-level validation path. Byte-for-byte unchanged;
125
+ # reached only when no request class exists for this model + action.
98
126
  model_instance = model_class.new
99
127
  validation = model_instance.validate_for_action(
100
128
  data, permitted_fields: permitted_fields, organization: current_organization
@@ -159,6 +187,29 @@ module Rhino
159
187
  }, status: :forbidden
160
188
  end
161
189
 
190
+ # Request class for this model + action, if any. `record` is the already
191
+ # loaded, ORGANIZATION-SCOPED row (never re-fetched by bare id), so a
192
+ # record-dependent rule can never see another tenant's state.
193
+ if (request_class = request_class_for("update"))
194
+ status, payload = run_resource_request(request_class, data, "update", record, model_class)
195
+ return render_request_class_forbidden if status == :forbidden
196
+ return render json: { errors: payload }, status: :unprocessable_entity if status == :invalid
197
+
198
+ begin
199
+ record.update!(payload)
200
+ rescue ActiveRecord::RecordInvalid => e
201
+ # See the note in `store`: a model rule stricter than the request
202
+ # class must surface as the standard 422, not a 500.
203
+ return render json: { errors: record_validation_errors(e.record) },
204
+ status: :unprocessable_entity
205
+ end
206
+
207
+ record.reload
208
+ return render json: serialize_record(record)
209
+ end
210
+
211
+ # @deprecated Legacy model-level validation path. Byte-for-byte unchanged;
212
+ # reached only when no request class exists for this model + action.
162
213
  model_instance = model_class.new
163
214
  validation = model_instance.validate_for_action(
164
215
  data, permitted_fields: permitted_fields, organization: current_organization
@@ -329,6 +380,10 @@ module Rhino
329
380
 
330
381
  # Execute all operations in a transaction
331
382
  results = execute_nested_operations(operations, validated_per_op, auth_results)
383
+ # execute_nested_operations renders (and rolls back) when a model-level
384
+ # rule fails at save time on the request-class path.
385
+ return if performed?
386
+
332
387
  render json: { results: results }
333
388
  end
334
389
 
@@ -968,6 +1023,39 @@ module Rhino
968
1023
  return nil
969
1024
  end
970
1025
 
1026
+ # Request class for this operation. `action` maps to the request-class
1027
+ # vocabulary: a nested "create" op is the "store" action.
1028
+ request_action = action == "create" ? "store" : "update"
1029
+ if (request_class = request_class_for(request_action, op_model_class, slug))
1030
+ # Non-failing, organization-scoped lookup: `nil` on a miss so the
1031
+ # existing authorize_nested_operation step still produces today's
1032
+ # 403/404 in today's order.
1033
+ op_record = request_action == "update" ? find_nested_operation_record(op_model_class, operation["id"]) : nil
1034
+
1035
+ status, payload = run_resource_request(
1036
+ request_class, operation["data"], request_action, op_record, op_model_class
1037
+ )
1038
+
1039
+ if status == :forbidden
1040
+ render json: { message: "This action is unauthorized." }, status: :forbidden
1041
+ return nil
1042
+ end
1043
+
1044
+ if status == :invalid
1045
+ errors = {}
1046
+ payload.each do |key, messages|
1047
+ errors["operations.#{index}.data.#{key}"] = messages
1048
+ end
1049
+ render json: { message: "Validation failed.", errors: errors }, status: :unprocessable_entity
1050
+ return nil
1051
+ end
1052
+
1053
+ return payload
1054
+ end
1055
+
1056
+ # @deprecated Legacy model-level validation path for nested operations.
1057
+ # Unchanged; reached only when no request class exists for this
1058
+ # operation's model + action.
971
1059
  model_instance = op_model_class.new
972
1060
  validation = model_instance.validate_for_action(operation["data"], permitted_fields: permitted_fields)
973
1061
 
@@ -995,7 +1083,20 @@ module Rhino
995
1083
  end
996
1084
  nil
997
1085
  else
998
- record = op_model_class.find(operation["id"])
1086
+ # ORGANIZATION-SCOPED lookup. A bare `op_model_class.find` let org B
1087
+ # update org A's rows through POST /nested: only models including
1088
+ # Rhino::BelongsToOrganization were protected, and then only by that
1089
+ # concern's default_scope, so a plain organization_id column, a
1090
+ # for_organization scope and every indirect belongs_to chain
1091
+ # (task -> project -> org) leaked on the WRITE path while the member
1092
+ # endpoints 404'd. Uses the same lenient mechanism find_record and
1093
+ # index use, so a model with no org mechanism stays reachable and a
1094
+ # request with no org context is unscoped exactly as before. A
1095
+ # cross-org id now raises RecordNotFound, indistinguishable from an id
1096
+ # that does not exist.
1097
+ record = Rhino::ScopesToOrganization.scope_to_organization(
1098
+ op_model_class.all, op_model_class, current_organization
1099
+ ).find(operation["id"])
999
1100
  unless policy.new(current_user, record).update?
1000
1101
  render json: { message: "This action is unauthorized." }, status: :forbidden
1001
1102
  return nil
@@ -1006,39 +1107,74 @@ module Rhino
1006
1107
 
1007
1108
  def execute_nested_operations(operations, validated_per_op, auth_results)
1008
1109
  results = []
1110
+ # [index, record] for an operation whose MODEL rules failed at save time
1111
+ # on the request-class path. Set inside the transaction, rendered after it
1112
+ # has rolled back.
1113
+ save_failure = nil
1009
1114
 
1010
1115
  ActiveRecord::Base.transaction do
1011
1116
  operations.each_with_index do |op, index|
1012
1117
  validated = validated_per_op[index]
1013
1118
  model_or_nil = auth_results[index]
1014
1119
 
1015
- if op["action"] == "create"
1016
- op_model_class = Rhino.config.resolve_model(op["model"])
1017
- data = validated.dup
1018
- add_organization_to_data(data)
1019
- record = op_model_class.create!(data)
1020
- results << {
1021
- model: op["model"],
1022
- action: "create",
1023
- id: record.id,
1024
- data: serialize_record(record)
1025
- }
1026
- else
1027
- model_or_nil.update!(validated)
1028
- model_or_nil.reload
1029
- results << {
1030
- model: op["model"],
1031
- action: "update",
1032
- id: model_or_nil.id,
1033
- data: serialize_record(model_or_nil)
1034
- }
1120
+ begin
1121
+ if op["action"] == "create"
1122
+ op_model_class = Rhino.config.resolve_model(op["model"])
1123
+ data = validated.dup
1124
+ add_organization_to_data(data)
1125
+ record = op_model_class.create!(data)
1126
+ results << {
1127
+ model: op["model"],
1128
+ action: "create",
1129
+ id: record.id,
1130
+ data: serialize_record(record)
1131
+ }
1132
+ else
1133
+ model_or_nil.update!(validated)
1134
+ model_or_nil.reload
1135
+ results << {
1136
+ model: op["model"],
1137
+ action: "update",
1138
+ id: model_or_nil.id,
1139
+ data: serialize_record(model_or_nil)
1140
+ }
1141
+ end
1142
+ rescue ActiveRecord::RecordInvalid => e
1143
+ # Only the request-class path converts a save-time model-rule
1144
+ # failure into a 422. The legacy path ran those same rules in
1145
+ # validate_for_action before getting here, so it keeps raising
1146
+ # exactly as it does today.
1147
+ raise unless nested_operation_uses_request_class?(op)
1148
+
1149
+ save_failure = [index, e.record]
1150
+ raise ActiveRecord::Rollback
1035
1151
  end
1036
1152
  end
1037
1153
  end
1038
1154
 
1155
+ if save_failure
1156
+ index, invalid_record = save_failure
1157
+ errors = {}
1158
+ record_validation_errors(invalid_record).each do |key, messages|
1159
+ errors["operations.#{index}.data.#{key}"] = messages
1160
+ end
1161
+ render json: { message: "Validation failed.", errors: errors }, status: :unprocessable_entity
1162
+ return nil
1163
+ end
1164
+
1039
1165
  results
1040
1166
  end
1041
1167
 
1168
+ # Whether a nested operation was validated by a request class. Re-resolved
1169
+ # (never memoized) rather than threaded through, and only ever called from
1170
+ # the RecordInvalid rescue, so the happy path pays nothing.
1171
+ def nested_operation_uses_request_class?(operation)
1172
+ op_model_class = Rhino.config.resolve_model(operation["model"])
1173
+ request_action = operation["action"] == "create" ? "store" : "update"
1174
+
1175
+ !request_class_for(request_action, op_model_class, operation["model"]).nil?
1176
+ end
1177
+
1042
1178
  # ------------------------------------------------------------------
1043
1179
  # Permitted fields resolution
1044
1180
  # ------------------------------------------------------------------
@@ -1059,6 +1195,140 @@ module Rhino
1059
1195
  end
1060
1196
  end
1061
1197
 
1198
+ # ------------------------------------------------------------------
1199
+ # Request classes (Rhino::ResourceRequest)
1200
+ # ------------------------------------------------------------------
1201
+
1202
+ # Resolve the request class for a model + action, or nil.
1203
+ #
1204
+ # Precedence: explicit registration (config.model ..., store_request:) →
1205
+ # "{ModelBasename}StoreRequest" / "{ModelBasename}UpdateRequest" → none.
1206
+ #
1207
+ # Resolution happens on EVERY request and is never memoized: caching the
1208
+ # constant would hand back a stale, unloaded class after a dev-mode Zeitwerk
1209
+ # reload.
1210
+ #
1211
+ # An EXPLICIT registration that cannot be resolved — missing constant, or a
1212
+ # constant that is not a Rhino::ResourceRequest — raises. A silently ignored
1213
+ # validation class the developer asked for by name is a security hole.
1214
+ #
1215
+ # A CONVENTION hit that is not a Rhino::ResourceRequest is LOGGED and
1216
+ # ignored, falling through to the legacy path exactly as if no class
1217
+ # existed. The convention is a guess: an app upgrading from 4.9.0 that
1218
+ # happens to own an unrelated top-level `PostStoreRequest` must keep
1219
+ # working, not start returning 500s. A convention miss falls through
1220
+ # silently; that is what "convention" means.
1221
+ #
1222
+ # @param action [String] "store" or "update"
1223
+ # @return [Class, nil]
1224
+ def request_class_for(action, klass = model_class, slug = model_slug)
1225
+ explicit = Rhino.config.request_class_for(slug, action)
1226
+
1227
+ if explicit.present?
1228
+ const = explicit.safe_constantize
1229
+ raise_request_class_configuration_error(explicit, slug, action) unless const
1230
+ unless resource_request_class?(const)
1231
+ raise_request_class_configuration_error(explicit, slug, action)
1232
+ end
1233
+
1234
+ return const
1235
+ end
1236
+
1237
+ return nil unless klass.respond_to?(:name) && klass.name.present?
1238
+
1239
+ suffix = action.to_s == "update" ? "UpdateRequest" : "StoreRequest"
1240
+ name = "#{klass.name.demodulize}#{suffix}"
1241
+ const = name.safe_constantize
1242
+ return nil unless const
1243
+ return const if resource_request_class?(const)
1244
+
1245
+ warn_ignored_request_class(name, slug, action)
1246
+ nil
1247
+ end
1248
+
1249
+ def resource_request_class?(const)
1250
+ const.is_a?(Class) && const < Rhino::ResourceRequest
1251
+ end
1252
+
1253
+ def warn_ignored_request_class(name, slug, action)
1254
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
1255
+
1256
+ Rails.logger&.warn(
1257
+ "Rhino: ignoring #{name} for [#{slug}.#{action}]: " \
1258
+ "it does not inherit from Rhino::ResourceRequest"
1259
+ )
1260
+ end
1261
+
1262
+ def raise_request_class_configuration_error(label, slug, action)
1263
+ raise Rhino::ConfigurationError,
1264
+ "Rhino: request class [#{label}] configured for [#{slug}.#{action}] does not exist."
1265
+ end
1266
+
1267
+ # Instantiate and run a resolved request class.
1268
+ #
1269
+ # @return [Array] [:ok, validated_hash] | [:forbidden, nil] | [:invalid, errors_hash]
1270
+ def run_resource_request(request_class, data, action, record, fk_model_class)
1271
+ request = request_class.new(
1272
+ input: data,
1273
+ user: current_user,
1274
+ organization: current_organization,
1275
+ route_group: current_route_group,
1276
+ action: action,
1277
+ record: record
1278
+ )
1279
+
1280
+ return [:forbidden, nil] unless request.authorize?
1281
+
1282
+ result = request.run
1283
+ errors = result[:errors] || {}
1284
+
1285
+ # Cross-tenant FK validation runs ON TOP of the request class's own rules,
1286
+ # through the same (direct + indirect FK chain) implementation the legacy
1287
+ # path uses. Errors merge into the same 422 body.
1288
+ if current_organization
1289
+ fk_instance = fk_model_class.new
1290
+ if fk_instance.respond_to?(:rhino_validate_foreign_keys)
1291
+ errors = errors.merge(
1292
+ fk_instance.rhino_validate_foreign_keys(result[:validated], current_organization)
1293
+ )
1294
+ end
1295
+ end
1296
+
1297
+ return [:invalid, errors] if errors.any?
1298
+
1299
+ [:ok, result[:validated]]
1300
+ end
1301
+
1302
+ # Model-level validations still run inside create!/update!. On the
1303
+ # request-class path a rule the request class did not reproduce would
1304
+ # otherwise escape as ActiveRecord::RecordInvalid (a 500), so it is rendered
1305
+ # in the same shape the request class's own failures use: String keys,
1306
+ # arrays of messages.
1307
+ def record_validation_errors(record)
1308
+ return {} unless record.respond_to?(:errors)
1309
+
1310
+ record.errors.to_hash.each_with_object({}) do |(attribute, messages), memo|
1311
+ memo[attribute.to_s] = Array(messages)
1312
+ end
1313
+ end
1314
+
1315
+ # Byte-identical to a policy denial on purpose: a client must not be able to
1316
+ # tell an `authorize?` refusal from a policy refusal.
1317
+ def render_request_class_forbidden
1318
+ render json: { message: "This action is unauthorized." }, status: :forbidden
1319
+ end
1320
+
1321
+ # Organization-scoped, non-failing primary-key lookup used to populate
1322
+ # `record` for a nested update operation.
1323
+ def find_nested_operation_record(op_model_class, id)
1324
+ return nil if id.nil?
1325
+
1326
+ scope = Rhino::ScopesToOrganization.scope_to_organization(
1327
+ op_model_class.all, op_model_class, current_organization
1328
+ )
1329
+ scope.find_by(op_model_class.primary_key => id)
1330
+ end
1331
+
1062
1332
  def find_forbidden_fields(params_data, permitted_fields)
1063
1333
  return [] if permitted_fields == ["*"]
1064
1334
 
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+ require "active_support/core_ext/object/deep_dup"
5
+
6
+ module Rhino
7
+ # Base class for per-model, per-action request classes.
8
+ #
9
+ # A request class owns the entire shape/format contract for ONE action of ONE
10
+ # model. Unlike model-level validations it receives the full request context —
11
+ # the authenticated user, the resolved organization, the matched route group,
12
+ # the action, and (on update) the pre-update record — so a rule can branch on
13
+ # any of them.
14
+ #
15
+ # Discovery is by convention: `{Model}StoreRequest` / `{Model}UpdateRequest`,
16
+ # autoloaded from `app/requests/` (Zeitwerk autoloads every `app/*`
17
+ # directory, so no initializer or eager_load_paths entry is required). An
18
+ # explicit registration overrides the convention:
19
+ #
20
+ # Rhino.configure do |config|
21
+ # config.model :tasks, "Task", store_request: "CreateTask", update_request: "EditTask"
22
+ # end
23
+ #
24
+ # Usage:
25
+ #
26
+ # # app/requests/task_store_request.rb
27
+ # class TaskStoreRequest < Rhino::ResourceRequest
28
+ # attribute :title, :string
29
+ # attribute :status, :string
30
+ # attribute :project_id, :integer
31
+ #
32
+ # validates :title, presence: true, length: { maximum: 255 }
33
+ # validates :status, inclusion: { in: %w[todo doing] }, unless: -> { user&.admin? }
34
+ #
35
+ # def authorize?
36
+ # route_group != "public"
37
+ # end
38
+ #
39
+ # def prepare(input)
40
+ # input.merge("title" => input["title"].to_s.strip)
41
+ # end
42
+ # end
43
+ #
44
+ # Rules are ordinary ActiveModel declarations. There is no `rules` method and
45
+ # no `messages` method: dynamic rules use `validate :method_name` or
46
+ # `validates ..., if: -> { ... }` with the context readers in scope, and
47
+ # messages use the standard `message:` option / i18n.
48
+ #
49
+ # THE WRITE PAYLOAD IS `validated`. Only DECLARED attributes that are PRESENT
50
+ # in the prepared input are persisted, with their cast values. A field with no
51
+ # `attribute` declaration is silently dropped — including a field added by
52
+ # `prepare` that no `attribute` covers. This fails closed: when a policy
53
+ # permits `['*']`, the request class is the only field filter left.
54
+ class ResourceRequest
55
+ include ActiveModel::Model
56
+ include ActiveModel::Attributes
57
+ include ActiveModel::Validations
58
+
59
+ # The request context. All six values are available to `authorize?`,
60
+ # `prepare` and every validation.
61
+ #
62
+ # @return [Object, nil] the authenticated user, nil when unauthenticated
63
+ attr_reader :user
64
+ # @return [Object, nil] the resolved organization, nil outside a tenant context
65
+ attr_reader :organization
66
+ # @return [String, nil] the matched route's route group ("tenant", "public", ...)
67
+ attr_reader :route_group
68
+ # @return [String] "store" or "update"
69
+ attr_reader :action
70
+ # @return [Object, nil] the PRE-UPDATE record on update, nil on store
71
+ attr_reader :record
72
+ # @return [Hash] the prepared input, string-keyed
73
+ attr_reader :input
74
+
75
+ # @param input [Hash] the raw request data, AFTER the policy forbidden-field
76
+ # gate has run (so `prepare` can never launder a field past the policy)
77
+ # @param user [Object, nil]
78
+ # @param organization [Object, nil]
79
+ # @param route_group [String, nil]
80
+ # @param action [String] "store" or "update"
81
+ # @param record [Object, nil]
82
+ def initialize(input:, user: nil, organization: nil, route_group: nil, action: "store", record: nil)
83
+ @user = user
84
+ @organization = organization
85
+ @route_group = route_group.nil? ? nil : route_group.to_s
86
+ @action = action.to_s
87
+ @record = record
88
+
89
+ # ActiveModel::Attributes defaults must exist before anything reads or
90
+ # writes an attribute — including a `prepare` override that touches one.
91
+ super()
92
+
93
+ raw = self.class.normalize_input_keys(input)
94
+ prepared = prepare(raw.deep_dup)
95
+ # A `prepare` that returns a non-Hash (or nil) is treated as "no change".
96
+ @input = prepared.is_a?(Hash) ? self.class.normalize_input_keys(prepared) : raw
97
+
98
+ assign_declared_attributes
99
+ end
100
+
101
+ # Override point: return false to refuse the request with a 403 whose body
102
+ # is byte-identical to a policy denial, so `authorize?` cannot be used to
103
+ # enumerate anything about the model.
104
+ #
105
+ # @return [Boolean]
106
+ def authorize?
107
+ true
108
+ end
109
+
110
+ # Override point: normalize the input before validation. Runs BEFORE
111
+ # `authorize?`, so `authorize?` sees normalized input.
112
+ #
113
+ # Fields added here are SERVER-AUTHORED and are not re-checked against the
114
+ # policy's permitted attributes — the forbidden-field gate already ran on
115
+ # exactly what the client sent. Never copy a client value into a different
116
+ # key here; that writes a field the policy denied.
117
+ #
118
+ # A return value that is not a Hash is ignored.
119
+ #
120
+ # @param input [Hash] string-keyed copy of the raw input
121
+ # @return [Hash]
122
+ def prepare(input)
123
+ input
124
+ end
125
+
126
+ # Run the validations.
127
+ #
128
+ # @return [Hash] { valid: Boolean, errors: Hash<String, Array<String>>, validated: Hash }
129
+ def run
130
+ ok = valid?
131
+
132
+ { valid: ok, errors: error_messages, validated: validated }
133
+ end
134
+
135
+ # The write payload: declared attribute names that are present in the
136
+ # prepared input, mapped to their CAST values.
137
+ #
138
+ # @return [Hash<String, Object>]
139
+ def validated
140
+ attributes.select { |name, _| @input.key?(name) }
141
+ end
142
+
143
+ # Errors in the shape Rhino renders at 422:
144
+ # { "title" => ["can't be blank"], ... }
145
+ #
146
+ # Built exactly the way HasValidation#validate_for_action builds it, but
147
+ # WITHOUT its "only report errors on fields the client sent" guard — on a
148
+ # request class an error on an absent-but-required field is the point.
149
+ #
150
+ # @return [Hash<String, Array<String>>]
151
+ def error_messages
152
+ messages = {}
153
+ errors.each do |error|
154
+ field_name = error.attribute.to_s
155
+ messages[field_name] ||= []
156
+ messages[field_name] << error.message
157
+ end
158
+ messages
159
+ end
160
+
161
+ # Stringify top-level keys so `input["title"]` works regardless of whether
162
+ # the caller handed us a Hash, a HashWithIndifferentAccess or symbol keys.
163
+ #
164
+ # @api private
165
+ def self.normalize_input_keys(hash)
166
+ return {} unless hash.is_a?(Hash)
167
+
168
+ hash.each_with_object({}) { |(key, value), memo| memo[key.to_s] = value }
169
+ end
170
+
171
+ private
172
+
173
+ # Assign ONLY declared attributes, and only those actually present in the
174
+ # prepared input, so an absent attribute keeps its declared default rather
175
+ # than being overwritten with nil.
176
+ def assign_declared_attributes
177
+ self.class.attribute_names.each do |name|
178
+ next unless @input.key?(name)
179
+
180
+ public_send("#{name}=", @input[name])
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Validation for <%= action == "update" ? "PUT /{resource}/:id" : "POST /{resource}" %> on <%= name %>.
4
+ #
5
+ # Found by convention — Rhino looks for <%= class_name %> whenever
6
+ # <%= name %> handles its <%= action %> action, and falls back to the model's own
7
+ # validations when no request class exists. Override the convention with:
8
+ #
9
+ # config.model :<%= name.underscore.pluralize %>, "<%= name %>", <%= action %>_request: "My<%= action == "update" ? "Update" : "Store" %>Class"
10
+ #
11
+ # Context available to every method below:
12
+ # user — the authenticated user (or nil)
13
+ # organization — the resolved organization (or nil outside a tenant context)
14
+ # route_group — the matched route's group ("tenant", "public", ...)
15
+ # action — "<%= action %>"
16
+ # record — <%= action == "update" ? "the PRE-UPDATE record" : "nil (there is no record yet on store)" %>
17
+ # input — the prepared, string-keyed request data
18
+ class <%= class_name %> < Rhino::ResourceRequest
19
+ # Declare an attribute for EVERY field this action may write.
20
+ #
21
+ # The declared attributes present in the input ARE the write payload: a field
22
+ # with no `attribute` here is silently dropped, never persisted — even if the
23
+ # policy permits it, and even if `prepare` added it.
24
+ #
25
+ # attribute :title, :string
26
+ # attribute :status, :string
27
+ # attribute :project_id, :integer
28
+ # attribute :due_date, :date
29
+
30
+ # Ordinary ActiveModel validations. Unlike the model-level path, these are NOT
31
+ # relaxed for partial updates — declare what is optional yourself.
32
+ #
33
+ # validates :title, presence: true, length: { maximum: 255 }
34
+ # validates :status, inclusion: { in: %w[todo doing done] }, allow_nil: true
35
+
36
+ # Rules that depend on WHO is asking: branch on `user` instead of keeping a
37
+ # role-keyed map on the model. Remember that the policy already decided which
38
+ # fields the role may write — this decides what a valid value looks like.
39
+ #
40
+ # validates :status,
41
+ # inclusion: { in: %w[todo doing] },
42
+ # if: -> { user.nil? || user.role_slug_for_validation(organization) != "admin" }
43
+
44
+ # Rules that depend on WHERE the request came from.
45
+ #
46
+ # validates :assignee_id, presence: true, if: -> { route_group == "tenant" }
47
+
48
+ <% if action == "update" -%>
49
+ # Rules that depend on the record's CURRENT state. `record` is the
50
+ # organization-scoped row as it exists BEFORE this update is applied.
51
+ #
52
+ # validate :status_may_not_move_backwards
53
+
54
+ <% end -%>
55
+ # Refuse the request outright. Returning false renders 403 with exactly the
56
+ # body a policy denial renders, so it leaks nothing about this model.
57
+ #
58
+ # def authorize?
59
+ # route_group != "public"
60
+ # end
61
+
62
+ # Normalize the input before validation. Runs BEFORE authorize?, and AFTER the
63
+ # policy's forbidden-field check — so anything added here is server-authored
64
+ # and trusted. Never copy a client value into a different key here; that would
65
+ # write a field the policy denied. A field added here still needs an
66
+ # `attribute` above to be persisted. A non-Hash return value is ignored.
67
+ #
68
+ # Because it runs before the validations, `prepare` sees RAW client input: a
69
+ # client can send {"title": ["x"]} or {"title": 5}. Guard every string
70
+ # operation with is_a?(String) and leave anything else untouched, so a
71
+ # validation rejects it with a 422 instead of prepare blowing up (or silently
72
+ # stringifying it into something that passes).
73
+ #
74
+ # def prepare(input)
75
+ # title = input["title"]
76
+ # input.merge("title" => title.is_a?(String) ? title.strip : title)
77
+ # end
78
+
79
+ # An `attribute :title, :string` casts ANY value to a String, so ["x"] becomes
80
+ # '["x"]' and would sail past presence/length. When the shape matters, check
81
+ # the RAW input rather than the cast value.
82
+ #
83
+ # validate :title_must_be_text
84
+ <% if action != "update" -%>
85
+
86
+ # private
87
+
88
+ # def title_must_be_text
89
+ # return if input["title"].nil? || input["title"].is_a?(String)
90
+ #
91
+ # errors.add(:title, "must be a string")
92
+ # end
93
+ <% end -%>
94
+ <% if action == "update" -%>
95
+
96
+ # private
97
+
98
+ # def title_must_be_text
99
+ # return if input["title"].nil? || input["title"].is_a?(String)
100
+ #
101
+ # errors.add(:title, "must be a string")
102
+ # end
103
+
104
+ # def status_may_not_move_backwards
105
+ # return if record.nil? || status.nil?
106
+ # return unless record.status == "done" && status != "done"
107
+ #
108
+ # errors.add(:status, "cannot be reopened once the task is done")
109
+ # end
110
+ <% end -%>
111
+ end
data/lib/rhino/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rhino
4
- VERSION = "4.9.0"
4
+ VERSION = "4.10.0"
5
5
  end
data/lib/rhino.rb CHANGED
@@ -6,6 +6,7 @@ require "rhino/auth_rejected"
6
6
  require "rhino/scope_not_allowed_error"
7
7
  require "rhino/invalid_scope_arguments_error"
8
8
  require "rhino/invalid_computed_attribute_arguments_error"
9
+ require "rhino/configuration_error"
9
10
  require "rhino/argument_binder"
10
11
  require "rhino/scope_spec"
11
12
  require "rhino/computed_attribute_spec"
@@ -14,6 +15,7 @@ require "rhino/auth_hooks"
14
15
  require "rhino/group_membership"
15
16
  require "rhino/resource_scope"
16
17
  require "rhino/scopes_to_organization"
18
+ require "rhino/resource_request"
17
19
  require "rhino/context"
18
20
  require "rhino/query"
19
21
  require "rhino/routing/domain_constraint"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rhino-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.9.0
4
+ version: 4.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Cipolla
@@ -219,6 +219,7 @@ files:
219
219
  - lib/rhino/concerns/hidable_columns.rb
220
220
  - lib/rhino/concerns/route_group_context.rb
221
221
  - lib/rhino/configuration.rb
222
+ - lib/rhino/configuration_error.rb
222
223
  - lib/rhino/context.rb
223
224
  - lib/rhino/controllers/auth_controller.rb
224
225
  - lib/rhino/controllers/invitations_controller.rb
@@ -239,6 +240,7 @@ files:
239
240
  - lib/rhino/query.rb
240
241
  - lib/rhino/query_builder.rb
241
242
  - lib/rhino/railtie.rb
243
+ - lib/rhino/resource_request.rb
242
244
  - lib/rhino/resource_scope.rb
243
245
  - lib/rhino/routes.rb
244
246
  - lib/rhino/routing/domain_constraint.rb
@@ -252,6 +254,7 @@ files:
252
254
  - lib/rhino/templates/generate/migration.rb.erb
253
255
  - lib/rhino/templates/generate/model.rb.erb
254
256
  - lib/rhino/templates/generate/policy.rb.erb
257
+ - lib/rhino/templates/generate/request.rb.erb
255
258
  - lib/rhino/templates/generate/scope.rb.erb
256
259
  - lib/rhino/templates/multi_tenant/factories/organizations.rb.erb
257
260
  - lib/rhino/templates/multi_tenant/factories/roles.rb.erb