agentcode 0.9.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 +7 -0
- data/README.md +59 -0
- data/lib/agentcode/blueprint/blueprint_parser.rb +198 -0
- data/lib/agentcode/blueprint/blueprint_validator.rb +209 -0
- data/lib/agentcode/blueprint/generators/factory_generator.rb +74 -0
- data/lib/agentcode/blueprint/generators/policy_generator.rb +154 -0
- data/lib/agentcode/blueprint/generators/seeder_generator.rb +160 -0
- data/lib/agentcode/blueprint/generators/test_generator.rb +291 -0
- data/lib/agentcode/blueprint/manifest_manager.rb +81 -0
- data/lib/agentcode/commands/base_command.rb +57 -0
- data/lib/agentcode/commands/blueprint_command.rb +549 -0
- data/lib/agentcode/commands/export_postman_command.rb +328 -0
- data/lib/agentcode/commands/generate_command.rb +563 -0
- data/lib/agentcode/commands/install_command.rb +441 -0
- data/lib/agentcode/commands/invitation_link_command.rb +107 -0
- data/lib/agentcode/concerns/belongs_to_organization.rb +49 -0
- data/lib/agentcode/concerns/has_agentcode.rb +93 -0
- data/lib/agentcode/concerns/has_audit_trail.rb +125 -0
- data/lib/agentcode/concerns/has_auto_scope.rb +91 -0
- data/lib/agentcode/concerns/has_permissions.rb +117 -0
- data/lib/agentcode/concerns/has_uuid.rb +26 -0
- data/lib/agentcode/concerns/has_validation.rb +250 -0
- data/lib/agentcode/concerns/hidable_columns.rb +180 -0
- data/lib/agentcode/configuration.rb +98 -0
- data/lib/agentcode/controllers/auth_controller.rb +242 -0
- data/lib/agentcode/controllers/invitations_controller.rb +231 -0
- data/lib/agentcode/controllers/resources_controller.rb +813 -0
- data/lib/agentcode/engine.rb +65 -0
- data/lib/agentcode/mailers/invitation_mailer.rb +22 -0
- data/lib/agentcode/middleware/resolve_organization_from_route.rb +72 -0
- data/lib/agentcode/models/agentcode_model.rb +387 -0
- data/lib/agentcode/models/audit_log.rb +17 -0
- data/lib/agentcode/models/organization_invitation.rb +57 -0
- data/lib/agentcode/policies/invitation_policy.rb +54 -0
- data/lib/agentcode/policies/resource_policy.rb +197 -0
- data/lib/agentcode/query_builder.rb +278 -0
- data/lib/agentcode/railtie.rb +11 -0
- data/lib/agentcode/resource_scope.rb +59 -0
- data/lib/agentcode/routes.rb +124 -0
- data/lib/agentcode/tasks/agentcode.rake +39 -0
- data/lib/agentcode/templates/agentcode.rb +71 -0
- data/lib/agentcode/templates/agentcode_model.rb +104 -0
- data/lib/agentcode/templates/audit_trail/create_audit_logs.rb.erb +26 -0
- data/lib/agentcode/templates/generate/factory.rb.erb +43 -0
- data/lib/agentcode/templates/generate/migration.rb.erb +26 -0
- data/lib/agentcode/templates/generate/model.rb.erb +55 -0
- data/lib/agentcode/templates/generate/policy.rb.erb +52 -0
- data/lib/agentcode/templates/generate/scope.rb.erb +31 -0
- data/lib/agentcode/templates/multi_tenant/factories/organizations.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/factories/roles.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/factories/user_roles.rb.erb +10 -0
- data/lib/agentcode/templates/multi_tenant/factories/users.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_organizations.rb.erb +15 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_roles.rb.erb +15 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_user_roles.rb.erb +16 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_users.rb.erb +15 -0
- data/lib/agentcode/templates/multi_tenant/models/organization.rb.erb +18 -0
- data/lib/agentcode/templates/multi_tenant/models/role.rb.erb +11 -0
- data/lib/agentcode/templates/multi_tenant/models/user.rb.erb +14 -0
- data/lib/agentcode/templates/multi_tenant/models/user_role.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/policies/organization_policy.rb.erb +6 -0
- data/lib/agentcode/templates/multi_tenant/policies/role_policy.rb.erb +6 -0
- data/lib/agentcode/templates/multi_tenant/seeders/organization_seeder.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/seeders/role_seeder.rb.erb +19 -0
- data/lib/agentcode/templates/routes.rb +13 -0
- data/lib/agentcode/version.rb +5 -0
- data/lib/agentcode/views/lumina/invitation_mailer/invite.html.erb +29 -0
- data/lib/agentcode-rails.rb +3 -0
- data/lib/agentcode.rb +26 -0
- metadata +281 -0
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentCode
|
|
4
|
+
# Global CRUD controller that handles all registered models.
|
|
5
|
+
# Mirrors the Laravel GlobalController exactly.
|
|
6
|
+
#
|
|
7
|
+
# Routes pass the model slug via route defaults, and this controller
|
|
8
|
+
# resolves the appropriate ActiveRecord class to operate on.
|
|
9
|
+
class ResourcesController < ActionController::API
|
|
10
|
+
include Pundit::Authorization
|
|
11
|
+
|
|
12
|
+
# Disable parameter wrapping — AgentCode expects flat JSON params
|
|
13
|
+
wrap_parameters false
|
|
14
|
+
|
|
15
|
+
rescue_from Pundit::NotAuthorizedError do |_exception|
|
|
16
|
+
render json: { message: "This action is unauthorized." }, status: :forbidden
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Cache for auto-detected organization paths (class-level, survives across requests)
|
|
20
|
+
@@organization_path_cache = {}
|
|
21
|
+
|
|
22
|
+
before_action :set_model_class
|
|
23
|
+
before_action :resolve_organization
|
|
24
|
+
before_action :authenticate_user!, unless: :public_route_group?
|
|
25
|
+
|
|
26
|
+
# GET /api/{slug}
|
|
27
|
+
def index
|
|
28
|
+
authorize model_class, :index?, policy_class: policy_for(model_class)
|
|
29
|
+
|
|
30
|
+
builder = QueryBuilder.new(model_class, params: params)
|
|
31
|
+
apply_organization_scope(builder)
|
|
32
|
+
builder.build
|
|
33
|
+
|
|
34
|
+
per_page = params[:per_page]
|
|
35
|
+
pagination_enabled = model_class.try(:pagination_enabled) || false
|
|
36
|
+
|
|
37
|
+
if per_page.present? || pagination_enabled
|
|
38
|
+
result = builder.paginate
|
|
39
|
+
set_pagination_headers(result[:pagination])
|
|
40
|
+
render json: serialize_collection(result[:items])
|
|
41
|
+
else
|
|
42
|
+
render json: serialize_collection(builder.to_scope)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# POST /api/{slug}
|
|
47
|
+
def store
|
|
48
|
+
authorize model_class, :create?, policy_class: policy_for(model_class)
|
|
49
|
+
|
|
50
|
+
data = params_hash
|
|
51
|
+
|
|
52
|
+
# Strip organization_id — it's auto-set by the framework
|
|
53
|
+
data.delete("organization_id") if current_organization
|
|
54
|
+
|
|
55
|
+
permitted_fields = resolve_permitted_fields(current_user, "create")
|
|
56
|
+
|
|
57
|
+
# Check for forbidden fields → 403
|
|
58
|
+
forbidden = find_forbidden_fields(data, permitted_fields)
|
|
59
|
+
if forbidden.any?
|
|
60
|
+
return render json: {
|
|
61
|
+
message: "You are not allowed to set the following field(s): #{forbidden.join(', ')}"
|
|
62
|
+
}, status: :forbidden
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
model_instance = model_class.new
|
|
66
|
+
validation = model_instance.validate_for_action(
|
|
67
|
+
data, permitted_fields: permitted_fields, organization: current_organization
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
unless validation[:valid]
|
|
71
|
+
return render json: { errors: validation[:errors] }, status: :unprocessable_entity
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
validated = validation[:validated]
|
|
75
|
+
add_organization_to_data(validated)
|
|
76
|
+
|
|
77
|
+
record = model_class.create!(validated)
|
|
78
|
+
render json: serialize_record(record), status: :created
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# GET /api/{slug}/:id
|
|
82
|
+
def show
|
|
83
|
+
record = find_record
|
|
84
|
+
authorize record, :show?, policy_class: policy_for(record)
|
|
85
|
+
|
|
86
|
+
# Apply includes if requested
|
|
87
|
+
if params[:include].present?
|
|
88
|
+
auth_response = authorize_includes
|
|
89
|
+
return auth_response if auth_response
|
|
90
|
+
|
|
91
|
+
builder = QueryBuilder.new(model_class, params: params)
|
|
92
|
+
builder.instance_variable_set(:@scope, model_class.where(id: record.id))
|
|
93
|
+
apply_organization_scope(builder)
|
|
94
|
+
builder.build
|
|
95
|
+
record = builder.to_scope.first!
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
render json: serialize_record(record)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# PUT /api/{slug}/:id
|
|
102
|
+
def update
|
|
103
|
+
record = find_record
|
|
104
|
+
authorize record, :update?, policy_class: policy_for(record)
|
|
105
|
+
|
|
106
|
+
data = params_hash
|
|
107
|
+
|
|
108
|
+
# Reject organization_id changes — cross-tenant reassignment is not allowed
|
|
109
|
+
if current_organization && data.key?("organization_id")
|
|
110
|
+
return render json: {
|
|
111
|
+
message: "You are not allowed to change the organization_id."
|
|
112
|
+
}, status: :forbidden
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
permitted_fields = resolve_permitted_fields(current_user, "update")
|
|
116
|
+
|
|
117
|
+
# Check for forbidden fields → 403
|
|
118
|
+
forbidden = find_forbidden_fields(data, permitted_fields)
|
|
119
|
+
if forbidden.any?
|
|
120
|
+
return render json: {
|
|
121
|
+
message: "You are not allowed to set the following field(s): #{forbidden.join(', ')}"
|
|
122
|
+
}, status: :forbidden
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
model_instance = model_class.new
|
|
126
|
+
validation = model_instance.validate_for_action(
|
|
127
|
+
data, permitted_fields: permitted_fields, organization: current_organization
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
unless validation[:valid]
|
|
131
|
+
return render json: { errors: validation[:errors] }, status: :unprocessable_entity
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
record.update!(validation[:validated])
|
|
135
|
+
record.reload
|
|
136
|
+
|
|
137
|
+
render json: serialize_record(record)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# DELETE /api/{slug}/:id
|
|
141
|
+
def destroy
|
|
142
|
+
record = find_record
|
|
143
|
+
authorize record, :destroy?, policy_class: policy_for(record)
|
|
144
|
+
|
|
145
|
+
if record.respond_to?(:discard!)
|
|
146
|
+
record.discard!
|
|
147
|
+
else
|
|
148
|
+
record.destroy!
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
head :no_content
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# Soft Delete Endpoints
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
# GET /api/{slug}/trashed
|
|
159
|
+
def trashed
|
|
160
|
+
authorize model_class, :view_trashed?, policy_class: policy_for(model_class)
|
|
161
|
+
|
|
162
|
+
builder = QueryBuilder.new(model_class.discarded, params: params)
|
|
163
|
+
apply_organization_scope(builder)
|
|
164
|
+
builder.build
|
|
165
|
+
|
|
166
|
+
per_page = params[:per_page]
|
|
167
|
+
pagination_enabled = model_class.try(:pagination_enabled) || false
|
|
168
|
+
|
|
169
|
+
if per_page.present? || pagination_enabled
|
|
170
|
+
result = builder.paginate
|
|
171
|
+
set_pagination_headers(result[:pagination])
|
|
172
|
+
render json: serialize_collection(result[:items])
|
|
173
|
+
else
|
|
174
|
+
render json: serialize_collection(builder.to_scope)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# POST /api/{slug}/:id/restore
|
|
179
|
+
def restore
|
|
180
|
+
record = model_class.discarded.find(params[:id])
|
|
181
|
+
authorize record, :restore?, policy_class: policy_for(record)
|
|
182
|
+
|
|
183
|
+
record.undiscard!
|
|
184
|
+
record.reload
|
|
185
|
+
|
|
186
|
+
render json: serialize_record(record)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# DELETE /api/{slug}/:id/force-delete
|
|
190
|
+
def force_delete
|
|
191
|
+
record = model_class.discarded.find(params[:id])
|
|
192
|
+
authorize record, :force_delete?, policy_class: policy_for(record)
|
|
193
|
+
|
|
194
|
+
record.destroy!
|
|
195
|
+
|
|
196
|
+
head :no_content
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
# Nested Operations
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
# POST /api/nested
|
|
204
|
+
def nested
|
|
205
|
+
operations = validate_nested_structure
|
|
206
|
+
return if performed?
|
|
207
|
+
|
|
208
|
+
nested_config = AgentCode.config.nested
|
|
209
|
+
max_ops = nested_config[:max_operations]
|
|
210
|
+
|
|
211
|
+
if max_ops && operations.length > max_ops
|
|
212
|
+
return render json: {
|
|
213
|
+
message: "Too many operations.",
|
|
214
|
+
errors: { operations: ["Maximum #{max_ops} operations allowed."] }
|
|
215
|
+
}, status: :unprocessable_entity
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
allowed_models = nested_config[:allowed_models]
|
|
219
|
+
if allowed_models.is_a?(Array)
|
|
220
|
+
operations.each_with_index do |op, index|
|
|
221
|
+
unless allowed_models.include?(op["model"])
|
|
222
|
+
return render json: {
|
|
223
|
+
message: "Operation not allowed.",
|
|
224
|
+
errors: { "operations.#{index}.model" => ["Model \"#{op['model']}\" is not allowed for nested operations."] }
|
|
225
|
+
}, status: :unprocessable_entity
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Validate and authorize each operation
|
|
231
|
+
validated_per_op = []
|
|
232
|
+
auth_results = []
|
|
233
|
+
|
|
234
|
+
operations.each_with_index do |operation, index|
|
|
235
|
+
validated = validate_nested_operation(operation, index)
|
|
236
|
+
return if performed?
|
|
237
|
+
validated_per_op << validated
|
|
238
|
+
|
|
239
|
+
auth_result = authorize_nested_operation(operation, validated, index)
|
|
240
|
+
return if performed?
|
|
241
|
+
auth_results << auth_result
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Execute all operations in a transaction
|
|
245
|
+
results = execute_nested_operations(operations, validated_per_op, auth_results)
|
|
246
|
+
render json: { results: results }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
# ------------------------------------------------------------------
|
|
252
|
+
# Model resolution
|
|
253
|
+
# ------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
def set_model_class
|
|
256
|
+
slug = params[:model_slug] || request.env["agentcode.model_slug"]
|
|
257
|
+
@model_class = AgentCode.config.resolve_model(slug)
|
|
258
|
+
rescue ActiveRecord::RecordNotFound => e
|
|
259
|
+
render json: { message: e.message }, status: :not_found
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def model_class
|
|
263
|
+
@model_class
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def model_slug
|
|
267
|
+
params[:model_slug] || request.env["agentcode.model_slug"]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# ------------------------------------------------------------------
|
|
271
|
+
# Authentication
|
|
272
|
+
# ------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
def public_route_group?
|
|
275
|
+
current_route_group == "public"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def current_route_group
|
|
279
|
+
params[:route_group]
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def authenticate_user!
|
|
283
|
+
unless current_user
|
|
284
|
+
render json: { message: "Unauthenticated." }, status: :unauthorized
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def current_user
|
|
289
|
+
# Override in host app or use token auth
|
|
290
|
+
@current_user ||= begin
|
|
291
|
+
token = request.headers["Authorization"]&.sub(/\ABearer /, "")
|
|
292
|
+
return nil unless token
|
|
293
|
+
|
|
294
|
+
# Look for user by API token
|
|
295
|
+
user_class = "User".safe_constantize
|
|
296
|
+
return nil unless user_class
|
|
297
|
+
|
|
298
|
+
user = if user_class.respond_to?(:find_by_api_token)
|
|
299
|
+
user_class.find_by_api_token(token)
|
|
300
|
+
elsif user_class.column_names.include?("api_token")
|
|
301
|
+
user_class.find_by(api_token: token)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Store in RequestStore so scopes and concerns can access it
|
|
305
|
+
RequestStore.store[:agentcode_current_user] = user if defined?(RequestStore) && user
|
|
306
|
+
|
|
307
|
+
user
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# ------------------------------------------------------------------
|
|
312
|
+
# Organization (multi-tenant)
|
|
313
|
+
# ------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
def resolve_organization
|
|
316
|
+
org_identifier = params[:organization]
|
|
317
|
+
return unless org_identifier.present?
|
|
318
|
+
|
|
319
|
+
org_class = "Organization".safe_constantize
|
|
320
|
+
return unless org_class
|
|
321
|
+
|
|
322
|
+
column = AgentCode.config.multi_tenant[:organization_identifier_column] || "id"
|
|
323
|
+
organization = org_class.find_by(column => org_identifier)
|
|
324
|
+
|
|
325
|
+
unless organization
|
|
326
|
+
render json: { message: "Organization not found" }, status: :not_found
|
|
327
|
+
return
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Check if authenticated user belongs to this organization
|
|
331
|
+
user = current_user
|
|
332
|
+
if user && user.respond_to?(:user_roles) && !user.user_roles.exists?(organization_id: organization.id)
|
|
333
|
+
render json: { message: "Organization not found" }, status: :not_found
|
|
334
|
+
return
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
request.env["agentcode.organization"] = organization
|
|
338
|
+
|
|
339
|
+
if defined?(RequestStore)
|
|
340
|
+
RequestStore.store[:agentcode_organization] = organization
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def current_organization
|
|
345
|
+
request.env["agentcode.organization"]
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def apply_organization_scope(builder)
|
|
349
|
+
org = current_organization
|
|
350
|
+
return unless org
|
|
351
|
+
|
|
352
|
+
# When the resource IS the Organization model
|
|
353
|
+
if org.class == model_class
|
|
354
|
+
builder.instance_variable_set(
|
|
355
|
+
:@scope,
|
|
356
|
+
builder.scope.where(model_class.primary_key => org.send(model_class.primary_key))
|
|
357
|
+
)
|
|
358
|
+
return
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Check for scopeForOrganization
|
|
362
|
+
if model_class.respond_to?(:for_organization)
|
|
363
|
+
builder.instance_variable_set(:@scope, model_class.for_organization(org))
|
|
364
|
+
return
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Check for organization_id column
|
|
368
|
+
if model_class.column_names.include?("organization_id")
|
|
369
|
+
builder.instance_variable_set(
|
|
370
|
+
:@scope,
|
|
371
|
+
builder.scope.where(organization_id: org.id)
|
|
372
|
+
)
|
|
373
|
+
return
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Auto-detect from belongs_to relationships
|
|
377
|
+
detected_path = discover_organization_path(model_class)
|
|
378
|
+
if detected_path.present?
|
|
379
|
+
apply_organization_scope_through_relationship(builder, org, detected_path)
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def apply_organization_scope_through_relationship(builder, organization, relationship_path)
|
|
384
|
+
if relationship_path.include?(".")
|
|
385
|
+
# Nested path: 'post.blog' -> joins(post: :blog).where(blogs: { organization_id: org.id })
|
|
386
|
+
parts = relationship_path.split(".")
|
|
387
|
+
join_chain = parts.reverse.inject(:organization) { |inner, outer| { outer.to_sym => inner } }
|
|
388
|
+
|
|
389
|
+
builder.instance_variable_set(
|
|
390
|
+
:@scope,
|
|
391
|
+
builder.scope.joins(join_chain.is_a?(Symbol) ? join_chain : parts.first.to_sym => join_chain)
|
|
392
|
+
.where(organizations: { id: organization.id })
|
|
393
|
+
)
|
|
394
|
+
else
|
|
395
|
+
# Single relationship
|
|
396
|
+
assoc = model_class.reflect_on_association(relationship_path.to_sym)
|
|
397
|
+
return unless assoc
|
|
398
|
+
|
|
399
|
+
if assoc.klass.column_names.include?("organization_id")
|
|
400
|
+
builder.instance_variable_set(
|
|
401
|
+
:@scope,
|
|
402
|
+
builder.scope.joins(relationship_path.to_sym)
|
|
403
|
+
.where(assoc.klass.table_name => { organization_id: organization.id })
|
|
404
|
+
)
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def add_organization_to_data(data)
|
|
410
|
+
org = current_organization
|
|
411
|
+
return unless org
|
|
412
|
+
|
|
413
|
+
if model_class.column_names.include?("organization_id")
|
|
414
|
+
data["organization_id"] = org.id
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Recursively discover the relationship path from a model to Organization
|
|
419
|
+
# by introspecting BelongsTo associations. Returns dot-notation path or nil.
|
|
420
|
+
#
|
|
421
|
+
# Results are cached per model class to avoid repeated reflection.
|
|
422
|
+
def discover_organization_path(klass, visited = [], max_depth = 3)
|
|
423
|
+
# Return cached result (including nil)
|
|
424
|
+
if @@organization_path_cache.key?(klass.name)
|
|
425
|
+
return @@organization_path_cache[klass.name]
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
result = _discover_organization_path_recursive(klass, visited, max_depth)
|
|
429
|
+
@@organization_path_cache[klass.name] = result
|
|
430
|
+
result
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def _discover_organization_path_recursive(klass, visited, max_depth)
|
|
434
|
+
return nil if max_depth <= 0 || visited.include?(klass.name)
|
|
435
|
+
|
|
436
|
+
visited = visited + [klass.name]
|
|
437
|
+
|
|
438
|
+
begin
|
|
439
|
+
associations = klass.reflect_on_all_associations(:belongs_to)
|
|
440
|
+
rescue StandardError
|
|
441
|
+
return nil
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
matching_paths = []
|
|
445
|
+
|
|
446
|
+
associations.each do |assoc|
|
|
447
|
+
begin
|
|
448
|
+
related_class = assoc.klass
|
|
449
|
+
rescue StandardError
|
|
450
|
+
next
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Direct match: related model IS Organization
|
|
454
|
+
if related_class.name == "Organization"
|
|
455
|
+
matching_paths << assoc.name.to_s
|
|
456
|
+
next
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Related model has organization_id column
|
|
460
|
+
begin
|
|
461
|
+
if related_class.column_names.include?("organization_id")
|
|
462
|
+
matching_paths << assoc.name.to_s
|
|
463
|
+
next
|
|
464
|
+
end
|
|
465
|
+
rescue StandardError
|
|
466
|
+
# Table may not exist yet
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Related model includes BelongsToOrganization concern
|
|
470
|
+
if related_class.include?(AgentCode::BelongsToOrganization)
|
|
471
|
+
matching_paths << assoc.name.to_s
|
|
472
|
+
next
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Recurse into related model's BelongsTo associations
|
|
476
|
+
sub_path = _discover_organization_path_recursive(related_class, visited, max_depth - 1)
|
|
477
|
+
if sub_path.present?
|
|
478
|
+
matching_paths << "#{assoc.name}.#{sub_path}"
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
return nil if matching_paths.empty?
|
|
483
|
+
|
|
484
|
+
if matching_paths.length > 1
|
|
485
|
+
Rails.logger&.debug(
|
|
486
|
+
"AgentCode: Model #{klass.name} has multiple BelongsTo paths to Organization. " \
|
|
487
|
+
"Using '#{matching_paths[0]}'. " \
|
|
488
|
+
"Paths found: #{matching_paths.inspect}"
|
|
489
|
+
)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
matching_paths[0]
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# ------------------------------------------------------------------
|
|
496
|
+
# Record finding
|
|
497
|
+
# ------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
def find_record
|
|
500
|
+
scope = model_class.all
|
|
501
|
+
|
|
502
|
+
org = current_organization
|
|
503
|
+
if org && model_class.column_names.include?("organization_id")
|
|
504
|
+
scope = scope.where(organization_id: org.id)
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
scope.find(params[:id])
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# ------------------------------------------------------------------
|
|
511
|
+
# Include authorization
|
|
512
|
+
# ------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
def authorize_includes
|
|
515
|
+
include_param = params[:include]
|
|
516
|
+
return nil unless include_param.present?
|
|
517
|
+
|
|
518
|
+
allowed = model_class.try(:allowed_includes) || []
|
|
519
|
+
return nil if allowed.empty?
|
|
520
|
+
|
|
521
|
+
requested = include_param.to_s.split(",").map(&:strip)
|
|
522
|
+
|
|
523
|
+
requested.each do |include_path|
|
|
524
|
+
segments = include_path.split(".")
|
|
525
|
+
current_model = model_class
|
|
526
|
+
|
|
527
|
+
segments.each do |segment|
|
|
528
|
+
base = resolve_base_include_segment(segment, allowed)
|
|
529
|
+
next unless base
|
|
530
|
+
|
|
531
|
+
assoc = current_model.reflect_on_association(base.to_sym)
|
|
532
|
+
next unless assoc
|
|
533
|
+
|
|
534
|
+
related_class = assoc.klass
|
|
535
|
+
policy = policy_for(related_class)
|
|
536
|
+
|
|
537
|
+
begin
|
|
538
|
+
unless policy.new(current_user, related_class).index?
|
|
539
|
+
render json: {
|
|
540
|
+
message: "You do not have permission to include #{include_path}."
|
|
541
|
+
}, status: :forbidden
|
|
542
|
+
return true
|
|
543
|
+
end
|
|
544
|
+
rescue StandardError
|
|
545
|
+
# If policy check fails, deny
|
|
546
|
+
render json: {
|
|
547
|
+
message: "You do not have permission to include #{include_path}."
|
|
548
|
+
}, status: :forbidden
|
|
549
|
+
return true
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
current_model = related_class
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
nil
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def resolve_base_include_segment(segment, allowed)
|
|
560
|
+
return segment if allowed.include?(segment)
|
|
561
|
+
|
|
562
|
+
if segment.end_with?("Count")
|
|
563
|
+
base = segment.sub(/Count\z/, "")
|
|
564
|
+
return base if allowed.include?(base)
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
if segment.end_with?("Exists")
|
|
568
|
+
base = segment.sub(/Exists\z/, "")
|
|
569
|
+
return base if allowed.include?(base)
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
nil
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# ------------------------------------------------------------------
|
|
576
|
+
# Serialization
|
|
577
|
+
# ------------------------------------------------------------------
|
|
578
|
+
|
|
579
|
+
def serialize_record(record)
|
|
580
|
+
if record.respond_to?(:as_agentcode_json)
|
|
581
|
+
record.as_agentcode_json
|
|
582
|
+
else
|
|
583
|
+
record.as_json
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def serialize_collection(records)
|
|
588
|
+
records.map { |r| serialize_record(r) }
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# ------------------------------------------------------------------
|
|
592
|
+
# Pagination headers
|
|
593
|
+
# ------------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
def set_pagination_headers(pagination)
|
|
596
|
+
response.headers["X-Current-Page"] = pagination[:current_page].to_s
|
|
597
|
+
response.headers["X-Last-Page"] = pagination[:last_page].to_s
|
|
598
|
+
response.headers["X-Per-Page"] = pagination[:per_page].to_s
|
|
599
|
+
response.headers["X-Total"] = pagination[:total].to_s
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
# ------------------------------------------------------------------
|
|
603
|
+
# Policy resolution
|
|
604
|
+
# ------------------------------------------------------------------
|
|
605
|
+
|
|
606
|
+
def policy_for(record_or_class)
|
|
607
|
+
klass = record_or_class.is_a?(Class) ? record_or_class : record_or_class.class
|
|
608
|
+
|
|
609
|
+
# Try to find a specific policy (e.g., PostPolicy)
|
|
610
|
+
policy_name = "#{klass.name}Policy"
|
|
611
|
+
policy_class = policy_name.safe_constantize
|
|
612
|
+
|
|
613
|
+
# Fall back to AgentCode::ResourcePolicy
|
|
614
|
+
policy_class || AgentCode::ResourcePolicy
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# ------------------------------------------------------------------
|
|
618
|
+
# Nested operations helpers
|
|
619
|
+
# ------------------------------------------------------------------
|
|
620
|
+
|
|
621
|
+
def validate_nested_structure
|
|
622
|
+
data = params.to_unsafe_h
|
|
623
|
+
operations = data["operations"]
|
|
624
|
+
|
|
625
|
+
unless operations.is_a?(Array)
|
|
626
|
+
render json: {
|
|
627
|
+
message: "The operations field is required and must be an array.",
|
|
628
|
+
errors: { operations: ["The operations field is required and must be an array."] }
|
|
629
|
+
}, status: :unprocessable_entity
|
|
630
|
+
return nil
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
operations.each_with_index do |op, index|
|
|
634
|
+
unless op.is_a?(Hash)
|
|
635
|
+
render json: {
|
|
636
|
+
message: "Invalid structure.",
|
|
637
|
+
errors: { "operations.#{index}" => ["Each operation must be an object."] }
|
|
638
|
+
}, status: :unprocessable_entity
|
|
639
|
+
return nil
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
if op["model"].blank?
|
|
643
|
+
render json: {
|
|
644
|
+
message: "Invalid structure.",
|
|
645
|
+
errors: { "operations.#{index}.model" => ["The model field is required."] }
|
|
646
|
+
}, status: :unprocessable_entity
|
|
647
|
+
return nil
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
unless %w[create update].include?(op["action"])
|
|
651
|
+
render json: {
|
|
652
|
+
message: "Invalid structure.",
|
|
653
|
+
errors: { "operations.#{index}.action" => ["The action must be create or update."] }
|
|
654
|
+
}, status: :unprocessable_entity
|
|
655
|
+
return nil
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
unless op["data"].is_a?(Hash)
|
|
659
|
+
render json: {
|
|
660
|
+
message: "Invalid structure.",
|
|
661
|
+
errors: { "operations.#{index}.data" => ["The data field is required and must be an object."] }
|
|
662
|
+
}, status: :unprocessable_entity
|
|
663
|
+
return nil
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
if op["action"] == "update" && !op.key?("id")
|
|
667
|
+
render json: {
|
|
668
|
+
message: "Invalid structure.",
|
|
669
|
+
errors: { "operations.#{index}.id" => ["The id field is required for update operations."] }
|
|
670
|
+
}, status: :unprocessable_entity
|
|
671
|
+
return nil
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
operations
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def validate_nested_operation(operation, index)
|
|
679
|
+
slug = operation["model"]
|
|
680
|
+
op_model_class = begin
|
|
681
|
+
AgentCode.config.resolve_model(slug)
|
|
682
|
+
rescue ActiveRecord::RecordNotFound
|
|
683
|
+
render json: {
|
|
684
|
+
message: "Unknown model.",
|
|
685
|
+
errors: { "operations.#{index}.model" => ["The model \"#{slug}\" does not exist."] }
|
|
686
|
+
}, status: :unprocessable_entity
|
|
687
|
+
return nil
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
action = operation["action"] == "create" ? "create" : "update"
|
|
691
|
+
op_policy = policy_for(op_model_class)
|
|
692
|
+
op_policy_instance = op_policy.new(current_user, op_model_class)
|
|
693
|
+
|
|
694
|
+
permitted_fields = if action == "create" && op_policy_instance.respond_to?(:permitted_attributes_for_create)
|
|
695
|
+
op_policy_instance.permitted_attributes_for_create(current_user)
|
|
696
|
+
elsif action == "update" && op_policy_instance.respond_to?(:permitted_attributes_for_update)
|
|
697
|
+
op_policy_instance.permitted_attributes_for_update(current_user)
|
|
698
|
+
else
|
|
699
|
+
['*']
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# Check for forbidden fields → 403
|
|
703
|
+
forbidden = find_forbidden_fields(operation["data"], permitted_fields)
|
|
704
|
+
if forbidden.any?
|
|
705
|
+
render json: {
|
|
706
|
+
message: "You are not allowed to set the following field(s): #{forbidden.join(', ')}"
|
|
707
|
+
}, status: :forbidden
|
|
708
|
+
return nil
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
model_instance = op_model_class.new
|
|
712
|
+
validation = model_instance.validate_for_action(operation["data"], permitted_fields: permitted_fields)
|
|
713
|
+
|
|
714
|
+
unless validation[:valid]
|
|
715
|
+
errors = {}
|
|
716
|
+
validation[:errors].each do |key, messages|
|
|
717
|
+
errors["operations.#{index}.data.#{key}"] = messages
|
|
718
|
+
end
|
|
719
|
+
render json: { message: "Validation failed.", errors: errors }, status: :unprocessable_entity
|
|
720
|
+
return nil
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
validation[:validated]
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
def authorize_nested_operation(operation, _validated, _index)
|
|
727
|
+
slug = operation["model"]
|
|
728
|
+
op_model_class = AgentCode.config.resolve_model(slug)
|
|
729
|
+
policy = policy_for(op_model_class)
|
|
730
|
+
|
|
731
|
+
if operation["action"] == "create"
|
|
732
|
+
unless policy.new(current_user, op_model_class).create?
|
|
733
|
+
render json: { message: "This action is unauthorized." }, status: :forbidden
|
|
734
|
+
return nil
|
|
735
|
+
end
|
|
736
|
+
nil
|
|
737
|
+
else
|
|
738
|
+
record = op_model_class.find(operation["id"])
|
|
739
|
+
unless policy.new(current_user, record).update?
|
|
740
|
+
render json: { message: "This action is unauthorized." }, status: :forbidden
|
|
741
|
+
return nil
|
|
742
|
+
end
|
|
743
|
+
record
|
|
744
|
+
end
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def execute_nested_operations(operations, validated_per_op, auth_results)
|
|
748
|
+
results = []
|
|
749
|
+
|
|
750
|
+
ActiveRecord::Base.transaction do
|
|
751
|
+
operations.each_with_index do |op, index|
|
|
752
|
+
validated = validated_per_op[index]
|
|
753
|
+
model_or_nil = auth_results[index]
|
|
754
|
+
|
|
755
|
+
if op["action"] == "create"
|
|
756
|
+
op_model_class = AgentCode.config.resolve_model(op["model"])
|
|
757
|
+
data = validated.dup
|
|
758
|
+
add_organization_to_data(data)
|
|
759
|
+
record = op_model_class.create!(data)
|
|
760
|
+
results << {
|
|
761
|
+
model: op["model"],
|
|
762
|
+
action: "create",
|
|
763
|
+
id: record.id,
|
|
764
|
+
data: serialize_record(record)
|
|
765
|
+
}
|
|
766
|
+
else
|
|
767
|
+
model_or_nil.update!(validated)
|
|
768
|
+
model_or_nil.reload
|
|
769
|
+
results << {
|
|
770
|
+
model: op["model"],
|
|
771
|
+
action: "update",
|
|
772
|
+
id: model_or_nil.id,
|
|
773
|
+
data: serialize_record(model_or_nil)
|
|
774
|
+
}
|
|
775
|
+
end
|
|
776
|
+
end
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
results
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# ------------------------------------------------------------------
|
|
783
|
+
# Permitted fields resolution
|
|
784
|
+
# ------------------------------------------------------------------
|
|
785
|
+
|
|
786
|
+
def resolve_permitted_fields(user, action)
|
|
787
|
+
policy = policy_for(model_class)
|
|
788
|
+
policy_instance = policy.new(user, model_class)
|
|
789
|
+
|
|
790
|
+
case action.to_s
|
|
791
|
+
when "create"
|
|
792
|
+
policy_instance.respond_to?(:permitted_attributes_for_create) ?
|
|
793
|
+
policy_instance.permitted_attributes_for_create(user) : ["*"]
|
|
794
|
+
when "update"
|
|
795
|
+
policy_instance.respond_to?(:permitted_attributes_for_update) ?
|
|
796
|
+
policy_instance.permitted_attributes_for_update(user) : ["*"]
|
|
797
|
+
else
|
|
798
|
+
["*"]
|
|
799
|
+
end
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
def find_forbidden_fields(params_data, permitted_fields)
|
|
803
|
+
return [] if permitted_fields == ["*"]
|
|
804
|
+
|
|
805
|
+
permitted = permitted_fields.map(&:to_s)
|
|
806
|
+
params_data.keys.map(&:to_s) - permitted
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
def params_hash
|
|
810
|
+
params.except(:controller, :action, :model_slug, :route_group, :organization, :id, :format).to_unsafe_h
|
|
811
|
+
end
|
|
812
|
+
end
|
|
813
|
+
end
|