propel_api 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +97 -0
- data/README.md +239 -4
- data/lib/generators/propel_api/controller/controller_generator.rb +26 -0
- data/lib/generators/propel_api/core/named_base.rb +97 -1
- data/lib/generators/propel_api/core/relationship_inferrer.rb +4 -11
- data/lib/generators/propel_api/install/install_generator.rb +39 -2
- data/lib/generators/propel_api/resource/resource_generator.rb +205 -63
- data/lib/generators/propel_api/templates/config/propel_api.rb.tt +26 -1
- data/lib/generators/propel_api/templates/controllers/api_base_controller.rb +75 -0
- data/lib/generators/propel_api/templates/controllers/api_controller_graphiti.rb +3 -3
- data/lib/generators/propel_api/templates/controllers/api_controller_propel_facets.rb +22 -12
- data/lib/generators/propel_api/templates/controllers/example_controller.rb.tt +2 -2
- data/lib/generators/propel_api/templates/errors/propel_api_csrf_error.rb +19 -0
- data/lib/generators/propel_api/templates/scaffold/facet_controller_template.rb.tt +41 -8
- data/lib/generators/propel_api/templates/scaffold/facet_model_template.rb.tt +43 -10
- data/lib/generators/propel_api/templates/scaffold/graphiti_controller_template.rb.tt +1 -1
- data/lib/generators/propel_api/templates/scaffold/graphiti_resource_template.rb.tt +2 -2
- data/lib/generators/propel_api/templates/seeds/seeds_template.rb.tt +65 -17
- data/lib/generators/propel_api/templates/tests/controller_test_template.rb.tt +40 -6
- data/lib/generators/propel_api/templates/tests/fixtures_template.yml.tt +61 -18
- data/lib/generators/propel_api/templates/tests/integration_test_template.rb.tt +154 -42
- data/lib/generators/propel_api/templates/tests/model_test_template.rb.tt +20 -0
- data/lib/propel_api.rb +1 -1
- metadata +24 -2
@@ -4,45 +4,77 @@ require_relative '../core/named_base'
|
|
4
4
|
module PropelApi
|
5
5
|
class ResourceGenerator < PropelApi::NamedBase
|
6
6
|
source_root File.expand_path("../templates", __dir__)
|
7
|
+
|
7
8
|
|
8
9
|
desc <<~DESC
|
9
10
|
Generate a complete API resource with model, migration, controller, and routes
|
10
11
|
|
11
|
-
|
12
|
+
BASIC USAGE:
|
13
|
+
rails generate propel_api:resource Product name:string price:decimal
|
14
|
+
→ Creates model, migration, controller, routes, tests, and fixtures
|
15
|
+
→ Requires confirmation if multi-tenancy attributes are missing
|
12
16
|
|
13
|
-
|
14
|
-
rails generate propel_api
|
15
|
-
→
|
16
|
-
→
|
17
|
+
MULTI-TENANT RESOURCES (RECOMMENDED):
|
18
|
+
rails generate propel_api:resource Product name:string organization:references agency:references
|
19
|
+
→ Includes multi-tenancy associations for proper data isolation
|
20
|
+
→ organization:references (required) - isolates data by organization
|
21
|
+
→ agency:references (optional) - sub-organization grouping
|
17
22
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
23
|
+
POLYMORPHIC ASSOCIATIONS:
|
24
|
+
rails generate propel_api:resource Comment content:text commentable:polymorphic
|
25
|
+
→ Comment belongs_to :commentable, polymorphic: true
|
26
|
+
→ Auto-discovers available parent models for fixtures/seeds
|
27
|
+
→ No quotes needed! (also supports commentable:references:polymorphic)
|
28
|
+
|
29
|
+
rails generate propel_api:resource Comment content:text commentable:polymorphic --parents commentable:Post,Photo,Video
|
30
|
+
→ Generates fixtures and seeds with specified polymorphic parent types
|
31
|
+
→ Ensures realistic test data and development seeds
|
32
|
+
|
33
|
+
LEGACY SYNTAX (still supported):
|
34
|
+
rails generate propel_api:resource Comment content:text "commentable:references{polymorphic}"
|
24
35
|
|
25
|
-
|
36
|
+
SKIP CONFIRMATIONS:
|
37
|
+
rails generate propel_api:resource Tag name:string --skip-tenancy
|
38
|
+
→ No confirmation prompts about missing multi-tenancy attributes
|
26
39
|
|
27
|
-
|
28
|
-
rails generate propel_api Article title:string user:references --skip-associations
|
40
|
+
SKIP ASSOCIATIONS:
|
41
|
+
rails generate propel_api:resource Article title:string user:references --skip-associations
|
29
42
|
→ No associations generated (manual setup required)
|
30
43
|
DESC
|
31
44
|
|
32
45
|
argument :attributes, type: :array, default: [], banner: "field:type field:type"
|
33
46
|
|
47
|
+
def initialize(args = [], local_options = {}, config = {})
|
48
|
+
# Process attributes to handle our custom polymorphic syntax
|
49
|
+
processed_args = args.dup
|
50
|
+
if processed_args.length > 1
|
51
|
+
# Convert user-friendly polymorphic syntax to Rails format
|
52
|
+
processed_args[1..-1] = processed_args[1..-1].map do |attr|
|
53
|
+
convert_polymorphic_syntax(attr)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
super(processed_args, local_options, config)
|
58
|
+
end
|
59
|
+
|
34
60
|
class_option :skip_associations,
|
35
61
|
type: :boolean,
|
36
62
|
desc: "Skip automatic association generation"
|
37
63
|
|
38
64
|
class_option :skip_tenancy,
|
39
65
|
type: :boolean,
|
40
|
-
|
66
|
+
default: false,
|
67
|
+
desc: "Skip warnings about missing multi-tenancy attributes (organization/agency)"
|
41
68
|
|
42
69
|
class_option :with_comments,
|
43
70
|
type: :boolean,
|
44
71
|
default: false,
|
45
72
|
desc: "Include usage comments in generated controller"
|
73
|
+
|
74
|
+
class_option :parents,
|
75
|
+
type: :hash,
|
76
|
+
default: {},
|
77
|
+
desc: "Specify parent types for polymorphic associations (e.g., --parents commentable:Post,Photo,Video)"
|
46
78
|
|
47
79
|
def create_migration
|
48
80
|
initialize_propel_api_settings
|
@@ -90,7 +122,20 @@ module PropelApi
|
|
90
122
|
say "No migration file found for #{table_name}", :yellow
|
91
123
|
end
|
92
124
|
else
|
93
|
-
|
125
|
+
# Check for tenancy and warn if missing (unless warnings are disabled)
|
126
|
+
check_tenancy_attributes_and_warn unless options[:skip_tenancy]
|
127
|
+
|
128
|
+
# Build migration arguments from processed attributes
|
129
|
+
# This ensures our custom polymorphic syntax is converted to Rails format
|
130
|
+
migration_args = attributes.map do |attr|
|
131
|
+
if attr.type == :references && attr.respond_to?(:polymorphic?) && attr.polymorphic?
|
132
|
+
"#{attr.name}:references{polymorphic}"
|
133
|
+
else
|
134
|
+
"#{attr.name}:#{attr.type}"
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
generate :migration, "create_#{table_name}", *migration_args
|
94
139
|
# Post-process migration to make non-organization references nullable
|
95
140
|
update_migration_constraints
|
96
141
|
end
|
@@ -256,6 +301,98 @@ module PropelApi
|
|
256
301
|
@relationship_inferrer
|
257
302
|
end
|
258
303
|
|
304
|
+
def check_tenancy_attributes_and_warn
|
305
|
+
# Check configuration first - this enforces organizational standards
|
306
|
+
return unless should_check_tenancy_by_config?
|
307
|
+
|
308
|
+
# Analyze what's missing based on configuration
|
309
|
+
missing_attributes = analyze_missing_tenancy_attributes
|
310
|
+
|
311
|
+
# Only proceed if there are missing attributes
|
312
|
+
return if missing_attributes.empty?
|
313
|
+
|
314
|
+
# Show warning about missing tenancy attributes
|
315
|
+
say "\n" + "="*80, :yellow
|
316
|
+
say "⚠️ MULTI-TENANCY CONFIRMATION REQUIRED", :yellow
|
317
|
+
say "="*80, :yellow
|
318
|
+
|
319
|
+
# Show what's missing based on configuration
|
320
|
+
say "\n📋 This resource is missing required tenancy attributes:", :red
|
321
|
+
missing_attributes.each do |attr|
|
322
|
+
say " • #{attr}:references (required by system policy)", :red
|
323
|
+
end
|
324
|
+
|
325
|
+
# Show recommended command with missing attributes
|
326
|
+
missing_attrs = missing_attributes.map { |attr| "#{attr}:references" }.join(' ')
|
327
|
+
say "\n💡 Recommended command:", :green
|
328
|
+
say " rails g propel_api:resource #{class_name} #{formatted_attributes} #{missing_attrs}", :green
|
329
|
+
|
330
|
+
say "\n" + "="*80, :yellow
|
331
|
+
|
332
|
+
# Require confirmation to proceed without tenancy
|
333
|
+
unless yes?("\n🚨 Do you want to continue WITHOUT required tenancy attributes? (y/N)")
|
334
|
+
say "\n✋ Generation cancelled. Please add the required tenancy attributes.", :red
|
335
|
+
say "💡 To skip this confirmation in the future: --skip-tenancy", :blue
|
336
|
+
exit 1
|
337
|
+
end
|
338
|
+
|
339
|
+
say "\n🚨 Proceeding without required tenancy! This may violate system policies.", :yellow
|
340
|
+
|
341
|
+
say ""
|
342
|
+
end
|
343
|
+
|
344
|
+
def should_check_tenancy_by_config?
|
345
|
+
# First check: Is tenancy enforcement enabled in configuration?
|
346
|
+
return false unless PropelApi.configuration.enforce_tenancy
|
347
|
+
|
348
|
+
# Second check: Should this specific model be checked?
|
349
|
+
return should_have_tenancy_by_heuristics?
|
350
|
+
end
|
351
|
+
|
352
|
+
def should_have_tenancy_by_heuristics?
|
353
|
+
# Skip tenancy warnings for certain model types that typically don't need it
|
354
|
+
# This provides a fallback when configuration doesn't specify exceptions
|
355
|
+
skip_tenancy_models = %w[
|
356
|
+
Organization Agency User
|
357
|
+
]
|
358
|
+
|
359
|
+
# Skip if this is one of the models that doesn't typically need tenancy
|
360
|
+
return false if skip_tenancy_models.include?(class_name)
|
361
|
+
|
362
|
+
# Skip if this looks like a join table or through table
|
363
|
+
return false if table_name.include?('_') && table_name.split('_').length == 2
|
364
|
+
|
365
|
+
# Skip if this is clearly a polymorphic join (like comments, attachments, etc.)
|
366
|
+
polymorphic_attributes = attributes.select { |attr| attr.respond_to?(:polymorphic?) && attr.polymorphic? }
|
367
|
+
return false if polymorphic_attributes.any? && attributes.length <= 3
|
368
|
+
|
369
|
+
# Otherwise, assume it needs tenancy
|
370
|
+
true
|
371
|
+
end
|
372
|
+
|
373
|
+
def analyze_missing_tenancy_attributes
|
374
|
+
# Get configured required tenancy attributes
|
375
|
+
required_attrs = PropelApi.configuration.required_tenancy_attributes || []
|
376
|
+
|
377
|
+
# Check which attributes are present in the generator arguments
|
378
|
+
present_attrs = attributes.select { |attr| attr.type == :references }.map { |attr| attr.name.to_sym }
|
379
|
+
|
380
|
+
# Find missing required attributes
|
381
|
+
missing_attrs = required_attrs - present_attrs
|
382
|
+
|
383
|
+
missing_attrs
|
384
|
+
end
|
385
|
+
|
386
|
+
def formatted_attributes
|
387
|
+
attributes.map { |attr|
|
388
|
+
if attr.type == :references && attr.respond_to?(:polymorphic?) && attr.polymorphic?
|
389
|
+
"\"#{attr.name}:references{polymorphic}\""
|
390
|
+
else
|
391
|
+
"#{attr.name}:#{attr.type}"
|
392
|
+
end
|
393
|
+
}.join(' ')
|
394
|
+
end
|
395
|
+
|
259
396
|
def apply_inverse_relationships
|
260
397
|
return unless @relationship_inferrer.should_generate_associations?
|
261
398
|
return if behavior == :revoke
|
@@ -358,55 +495,17 @@ module PropelApi
|
|
358
495
|
file_name.pluralize
|
359
496
|
end
|
360
497
|
|
361
|
-
def migration_attributes
|
362
|
-
# Always include organization (required) and agency (optional) for multi-tenancy
|
363
|
-
# unless --skip-tenancy flag is used
|
364
|
-
if options[:skip_tenancy]
|
365
|
-
# Only include what the developer explicitly specified
|
366
|
-
attributes.map { |attr| "#{attr.name}:#{attr.type}" }
|
367
|
-
else
|
368
|
-
# Include multi-tenancy foundation by default
|
369
|
-
standard_attributes = ["organization:references", "agency:references"]
|
370
|
-
|
371
|
-
# Add user-specified attributes, but skip organization and agency if they're already specified
|
372
|
-
user_attributes = attributes.map { |attr| "#{attr.name}:#{attr.type}" }
|
373
|
-
user_attributes.reject! { |attr| attr.start_with?("organization:") || attr.start_with?("agency:") }
|
374
|
-
|
375
|
-
# Combine standard and user attributes
|
376
|
-
standard_attributes + user_attributes
|
377
|
-
end
|
378
|
-
end
|
379
498
|
|
380
499
|
def permitted_param_names
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
attr.name
|
390
|
-
end
|
391
|
-
end
|
392
|
-
else
|
393
|
-
# Always include organization_id (required) and agency_id (optional) for multi-tenancy
|
394
|
-
standard_params = ["organization_id", "agency_id"]
|
395
|
-
|
396
|
-
# Add user-specified attributes, but skip organization and agency if they're already specified
|
397
|
-
user_params = attributes.map do |attr|
|
398
|
-
# Convert references to foreign key names for permitted params
|
399
|
-
# e.g., organization:references becomes organization_id
|
400
|
-
if attr.type == :references
|
401
|
-
"#{attr.name}_id"
|
402
|
-
else
|
403
|
-
attr.name
|
404
|
-
end
|
500
|
+
# Include exactly what the developer specified
|
501
|
+
attributes.map do |attr|
|
502
|
+
# Convert references to foreign key names for permitted params
|
503
|
+
# e.g., organization:references becomes organization_id
|
504
|
+
if attr.type == :references
|
505
|
+
"#{attr.name}_id"
|
506
|
+
else
|
507
|
+
attr.name
|
405
508
|
end
|
406
|
-
user_params.reject! { |param| param == "organization_id" || param == "agency_id" }
|
407
|
-
|
408
|
-
# Combine standard and user params
|
409
|
-
standard_params + user_params
|
410
509
|
end
|
411
510
|
end
|
412
511
|
|
@@ -488,8 +587,12 @@ module PropelApi
|
|
488
587
|
content = File.read(migration_file)
|
489
588
|
|
490
589
|
# Update references to be nullable except for organization
|
590
|
+
# BUT preserve polymorphic references as-is (they should have polymorphic: true, not foreign_key: true)
|
491
591
|
# organization:references should remain: null: false, foreign_key: true
|
492
|
-
# All other references should become: null: true, foreign_key: true
|
592
|
+
# All other non-polymorphic references should become: null: true, foreign_key: true
|
593
|
+
# Polymorphic references should remain: null: true/false, polymorphic: true
|
594
|
+
|
595
|
+
# First pass: Handle regular references (with foreign_key: true)
|
493
596
|
updated_content = content.gsub(/t\.references :(\w+), null: false, foreign_key: true/) do |match|
|
494
597
|
reference_name = $1
|
495
598
|
if reference_name == 'organization'
|
@@ -499,6 +602,18 @@ module PropelApi
|
|
499
602
|
end
|
500
603
|
end
|
501
604
|
|
605
|
+
# Second pass: Ensure polymorphic references stay polymorphic (don't change polymorphic: true lines)
|
606
|
+
# This is a safety check - Rails should generate polymorphic: true correctly,
|
607
|
+
# but if somehow foreign_key: true got generated for polymorphic fields, fix it
|
608
|
+
polymorphic_attrs = attributes.select { |attr| attr.type == :references && attr.respond_to?(:polymorphic?) && attr.polymorphic? }
|
609
|
+
polymorphic_attrs.each do |attr|
|
610
|
+
# If we find a polymorphic attribute that somehow got foreign_key: true, fix it
|
611
|
+
polymorphic_with_fk_pattern = /t\.references :#{attr.name}, null: (true|false), foreign_key: true/
|
612
|
+
if updated_content.match?(polymorphic_with_fk_pattern)
|
613
|
+
updated_content = updated_content.gsub(polymorphic_with_fk_pattern, "t.references :#{attr.name}, null: true, polymorphic: true")
|
614
|
+
end
|
615
|
+
end
|
616
|
+
|
502
617
|
# Write the updated migration back to file
|
503
618
|
File.write(migration_file, updated_content)
|
504
619
|
end
|
@@ -530,5 +645,32 @@ module PropelApi
|
|
530
645
|
# 2. Newer migrations exist but none have been executed
|
531
646
|
true
|
532
647
|
end
|
648
|
+
|
649
|
+
private
|
650
|
+
|
651
|
+
# Convert user-friendly polymorphic syntax to Rails' expected format
|
652
|
+
def convert_polymorphic_syntax(attr_string)
|
653
|
+
# Handle different polymorphic syntaxes:
|
654
|
+
# commentable:polymorphic -> commentable:references{polymorphic}
|
655
|
+
# commentable:references:polymorphic -> commentable:references{polymorphic}
|
656
|
+
# commentable:references{polymorphic} -> commentable:references{polymorphic} (unchanged)
|
657
|
+
|
658
|
+
return attr_string unless attr_string.include?(':')
|
659
|
+
|
660
|
+
parts = attr_string.split(':')
|
661
|
+
field_name = parts[0]
|
662
|
+
|
663
|
+
# Check for our new polymorphic syntaxes
|
664
|
+
if parts.length == 2 && parts[1] == 'polymorphic'
|
665
|
+
# commentable:polymorphic -> commentable:references{polymorphic}
|
666
|
+
return "#{field_name}:references{polymorphic}"
|
667
|
+
elsif parts.length == 3 && parts[1] == 'references' && parts[2] == 'polymorphic'
|
668
|
+
# commentable:references:polymorphic -> commentable:references{polymorphic}
|
669
|
+
return "#{field_name}:references{polymorphic}"
|
670
|
+
else
|
671
|
+
# Leave unchanged (regular attributes or legacy {polymorphic} syntax)
|
672
|
+
return attr_string
|
673
|
+
end
|
674
|
+
end
|
533
675
|
end
|
534
676
|
end
|
@@ -6,7 +6,7 @@
|
|
6
6
|
|
7
7
|
module PropelApi
|
8
8
|
class Configuration
|
9
|
-
attr_accessor :adapter, :namespace, :version
|
9
|
+
attr_accessor :adapter, :namespace, :version, :enforce_tenancy, :required_tenancy_attributes
|
10
10
|
attr_reader :attribute_filter
|
11
11
|
|
12
12
|
def initialize
|
@@ -15,6 +15,10 @@ module PropelApi
|
|
15
15
|
@namespace = 'api' # Default API namespace
|
16
16
|
@version = 'v1' # Default API version
|
17
17
|
|
18
|
+
# Multi-tenancy configuration - simple boolean
|
19
|
+
@enforce_tenancy = true # true = enforce tenancy (default), false = no checking
|
20
|
+
@required_tenancy_attributes = [:organization, :agency] # Required when enforce_tenancy is true
|
21
|
+
|
18
22
|
# Initialize the configurable attribute filter
|
19
23
|
@attribute_filter = PropelApi::AttributeFilter.new
|
20
24
|
end
|
@@ -132,6 +136,27 @@ PropelApi.configure do |config|
|
|
132
136
|
# API version: 'v1', 'v2', etc. or nil for no versioning
|
133
137
|
config.version = <%= @api_version ? "'#{@api_version}'" : 'nil' %>
|
134
138
|
|
139
|
+
# Multi-tenancy enforcement configuration
|
140
|
+
# Simple boolean: enforce tenancy requirements or not
|
141
|
+
config.enforce_tenancy = true # Default: enforce tenancy (require confirmation if missing)
|
142
|
+
|
143
|
+
# Define which tenancy attributes are required for your system
|
144
|
+
config.required_tenancy_attributes = [:organization, :agency] # Both required by default
|
145
|
+
|
146
|
+
# Examples of different tenancy configurations:
|
147
|
+
#
|
148
|
+
# Organization-only tenancy:
|
149
|
+
# config.required_tenancy_attributes = [:organization]
|
150
|
+
#
|
151
|
+
# Agency-only tenancy:
|
152
|
+
# config.required_tenancy_attributes = [:agency]
|
153
|
+
#
|
154
|
+
# Extended enterprise multi-tenancy:
|
155
|
+
# config.required_tenancy_attributes = [:organization, :agency, :department]
|
156
|
+
#
|
157
|
+
# Single-tenant application (no tenancy checking):
|
158
|
+
# config.enforce_tenancy = false
|
159
|
+
|
135
160
|
# Customize attribute filtering for your project's naming conventions
|
136
161
|
# Examples:
|
137
162
|
#
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
##
|
4
|
+
# Base controller for API endpoints ONLY
|
5
|
+
#
|
6
|
+
# ⚠️ SECURITY WARNING: This controller disables CSRF protection!
|
7
|
+
# ⚠️ ONLY use this for stateless JSON API endpoints with JWT authentication
|
8
|
+
# ⚠️ DO NOT use this for web controllers that serve HTML or handle sessions
|
9
|
+
#
|
10
|
+
# For web controllers, inherit from ApplicationController instead.
|
11
|
+
#
|
12
|
+
class Api::BaseController < ActionController::Base
|
13
|
+
# Set up and then skip CSRF protection for API endpoints (they use JWT tokens instead)
|
14
|
+
protect_from_forgery with: :null_session
|
15
|
+
skip_before_action :verify_authenticity_token
|
16
|
+
|
17
|
+
# Disable session handling for API requests (stateless)
|
18
|
+
before_action :disable_session
|
19
|
+
before_action :ensure_json_request, if: -> { Rails.env.production? }
|
20
|
+
|
21
|
+
# Handle common API errors
|
22
|
+
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
|
23
|
+
rescue_from ActionController::ParameterMissing, with: :parameter_missing
|
24
|
+
rescue_from ActiveRecord::RecordInvalid, with: :record_invalid
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def disable_session
|
29
|
+
request.session_options[:skip] = true
|
30
|
+
end
|
31
|
+
|
32
|
+
def ensure_json_request
|
33
|
+
unless request.format.json? || request.content_type&.include?('application/json')
|
34
|
+
Rails.logger.warn "Non-JSON request to API controller: #{controller_name}##{action_name}"
|
35
|
+
render json: {
|
36
|
+
error: 'Invalid request format',
|
37
|
+
message: 'This endpoint only accepts JSON requests'
|
38
|
+
}, status: :not_acceptable
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def record_not_found(exception)
|
43
|
+
Rails.logger.error "API Record not found: #{exception.message}"
|
44
|
+
render json: {
|
45
|
+
error: 'Record not found',
|
46
|
+
message: 'The requested resource could not be found'
|
47
|
+
}, status: :not_found
|
48
|
+
end
|
49
|
+
|
50
|
+
def parameter_missing(exception)
|
51
|
+
Rails.logger.error "API Parameter missing: #{exception.message}"
|
52
|
+
render json: {
|
53
|
+
error: 'Missing required parameters',
|
54
|
+
message: 'Required parameters are missing from the request'
|
55
|
+
}, status: :bad_request
|
56
|
+
end
|
57
|
+
|
58
|
+
def record_invalid(exception)
|
59
|
+
Rails.logger.error "API Record invalid: #{exception.record.errors.full_messages}"
|
60
|
+
|
61
|
+
# In development, show full validation errors for debugging
|
62
|
+
# In production, show generic message to prevent information disclosure
|
63
|
+
errors = if Rails.env.development?
|
64
|
+
exception.record.errors.full_messages
|
65
|
+
else
|
66
|
+
['Validation failed - please check your input']
|
67
|
+
end
|
68
|
+
|
69
|
+
render json: {
|
70
|
+
error: 'Validation failed',
|
71
|
+
errors: errors
|
72
|
+
}, status: :unprocessable_content
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
@@ -1,4 +1,4 @@
|
|
1
|
-
class <%= api_controller_class_name %> <
|
1
|
+
class <%= api_controller_class_name %> < Api::BaseController
|
2
2
|
include Graphiti::Rails
|
3
3
|
include Graphiti::Responders
|
4
4
|
include PropelAuthenticationConcern
|
@@ -41,7 +41,7 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
41
41
|
if resource_instance.save
|
42
42
|
respond_with(resource_instance, status: :created)
|
43
43
|
else
|
44
|
-
respond_with(resource_instance.errors, status: :
|
44
|
+
respond_with(resource_instance.errors, status: :unprocessable_content)
|
45
45
|
end
|
46
46
|
end
|
47
47
|
|
@@ -54,7 +54,7 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
54
54
|
if resource_instance.update_attributes
|
55
55
|
respond_with(resource_instance)
|
56
56
|
else
|
57
|
-
respond_with(resource_instance.errors, status: :
|
57
|
+
respond_with(resource_instance.errors, status: :unprocessable_content)
|
58
58
|
end
|
59
59
|
end
|
60
60
|
|
@@ -1,4 +1,4 @@
|
|
1
|
-
class <%= api_controller_class_name %> <
|
1
|
+
class <%= api_controller_class_name %> < Api::BaseController
|
2
2
|
include HasScope
|
3
3
|
include Pagy::Backend
|
4
4
|
include FacetRenderer
|
@@ -40,7 +40,7 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
40
40
|
if @resource.save
|
41
41
|
render json: { data: resource_json(@resource) }, status: :created
|
42
42
|
else
|
43
|
-
render json: { errors: @resource.errors }, status: :
|
43
|
+
render json: { errors: @resource.errors }, status: :unprocessable_content
|
44
44
|
end
|
45
45
|
end
|
46
46
|
|
@@ -48,7 +48,7 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
48
48
|
if @resource.update(resource_params)
|
49
49
|
render json: { data: resource_json(@resource) }
|
50
50
|
else
|
51
|
-
render json: { errors: @resource.errors }, status: :
|
51
|
+
render json: { errors: @resource.errors }, status: :unprocessable_content
|
52
52
|
end
|
53
53
|
end
|
54
54
|
|
@@ -73,6 +73,9 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
73
73
|
|
74
74
|
# Organization scoping method with support for self-signup
|
75
75
|
def for_organization(model_class)
|
76
|
+
# Skip tenancy if controller explicitly opts out
|
77
|
+
return model_class if respond_to?(:skip_tenancy?) && skip_tenancy?
|
78
|
+
|
76
79
|
return render_organization_context_error unless current_organization_id
|
77
80
|
|
78
81
|
# Special case: Organization model should only show user's own organization
|
@@ -80,6 +83,9 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
80
83
|
return model_class.where(id: current_organization_id)
|
81
84
|
end
|
82
85
|
|
86
|
+
# Skip if model doesn't have organization_id column
|
87
|
+
return model_class unless model_class.column_names.include?('organization_id')
|
88
|
+
|
83
89
|
# Start with organization scoping
|
84
90
|
scoped_query = model_class.for_organization(current_organization_id)
|
85
91
|
|
@@ -89,7 +95,10 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
89
95
|
|
90
96
|
# Build parameters for resource creation with security-first flow
|
91
97
|
def build_creation_params
|
92
|
-
|
98
|
+
# Skip organization validation if controller opts out or model doesn't have organization_id
|
99
|
+
unless (respond_to?(:skip_tenancy?) && skip_tenancy?) || !resource_class.column_names.include?('organization_id')
|
100
|
+
return {} unless validate_organization_context
|
101
|
+
end
|
93
102
|
|
94
103
|
params = resource_params
|
95
104
|
|
@@ -141,7 +150,8 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
141
150
|
# FIRST: Security validation of any provided tenancy context (fail-fast)
|
142
151
|
def validate_provided_tenancy_context(params)
|
143
152
|
# If organization_id is provided, verify user has access to it
|
144
|
-
if
|
153
|
+
# Only validate if model actually has organization_id column
|
154
|
+
if params[:organization_id].present? && resource_class.column_names.include?('organization_id')
|
145
155
|
unless params[:organization_id].to_i == current_organization_id
|
146
156
|
render json: {
|
147
157
|
error: 'Unauthorized organization access',
|
@@ -185,7 +195,8 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
185
195
|
# Auto-assign organization_id if missing and not required to be explicit
|
186
196
|
if params[:organization_id].blank? && !require_organization_id?
|
187
197
|
# Special case: Organization model doesn't get organization_id assigned
|
188
|
-
|
198
|
+
# Only assign if model has organization_id column
|
199
|
+
if resource_class != Organization && resource_class.column_names.include?('organization_id')
|
189
200
|
params[:organization_id] = current_organization_id
|
190
201
|
end
|
191
202
|
end
|
@@ -201,13 +212,11 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
201
212
|
params
|
202
213
|
end
|
203
214
|
|
204
|
-
|
205
|
-
|
206
215
|
# Check if agency tenancy is enabled
|
207
216
|
def agency_tenancy_enabled?
|
208
217
|
# Check PropelAuthentication configuration (owns tenancy models)
|
209
218
|
if defined?(PropelAuthentication) && PropelAuthentication.respond_to?(:configuration)
|
210
|
-
PropelAuthentication.configuration.
|
219
|
+
PropelAuthentication.configuration.agency_required?
|
211
220
|
else
|
212
221
|
true # Safe default - enables agency tenancy when configuration unavailable
|
213
222
|
end
|
@@ -238,7 +247,8 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
238
247
|
errors = {}
|
239
248
|
|
240
249
|
# Validate organization_id is present (after security check and auto-assignment)
|
241
|
-
if
|
250
|
+
# Only validate if model actually has organization_id column
|
251
|
+
if resource_class.column_names.include?('organization_id') && params[:organization_id].blank?
|
242
252
|
if require_organization_id?
|
243
253
|
errors[:organization_id] = ["is required - set require_organization_id = false to enable auto-assignment"]
|
244
254
|
else
|
@@ -262,7 +272,7 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
262
272
|
|
263
273
|
# Return validation errors if any
|
264
274
|
if errors.any?
|
265
|
-
render json: { errors: errors }, status: :
|
275
|
+
render json: { errors: errors }, status: :unprocessable_content
|
266
276
|
return {}
|
267
277
|
end
|
268
278
|
|
@@ -281,7 +291,7 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
281
291
|
def pagy_metadata(pagy)
|
282
292
|
{
|
283
293
|
current_page: pagy.page,
|
284
|
-
per_page: pagy.
|
294
|
+
per_page: pagy.limit,
|
285
295
|
total_pages: pagy.pages,
|
286
296
|
total_count: pagy.count,
|
287
297
|
next_page: pagy.next,
|
@@ -42,7 +42,7 @@ module Api
|
|
42
42
|
# if @resource.save
|
43
43
|
# render json: { data: resource_json(@resource) }, status: :created
|
44
44
|
# else
|
45
|
-
# render json: { errors: @resource.errors }, status: :
|
45
|
+
# render json: { errors: @resource.errors }, status: :unprocessable_content
|
46
46
|
# end
|
47
47
|
# end
|
48
48
|
end
|
@@ -82,7 +82,7 @@ module Api
|
|
82
82
|
# if resource_instance.save
|
83
83
|
# respond_with(resource_instance, status: :created)
|
84
84
|
# else
|
85
|
-
# respond_with(resource_instance.errors, status: :
|
85
|
+
# respond_with(resource_instance.errors, status: :unprocessable_content)
|
86
86
|
# end
|
87
87
|
# end
|
88
88
|
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
##
|
4
|
+
# PropelAPI CSRF Error Handler
|
5
|
+
# Provides helpful guidance when developers send JSON requests to web controllers
|
6
|
+
#
|
7
|
+
class PropelApiCsrfError < StandardError
|
8
|
+
def self.handle_csrf_error_for_json(controller)
|
9
|
+
# Log security event for monitoring
|
10
|
+
Rails.logger.warn "CSRF bypass attempted on #{controller.controller_name}##{controller.action_name} from #{controller.request.remote_ip}"
|
11
|
+
|
12
|
+
controller.render json: {
|
13
|
+
error: "CSRF token verification failed",
|
14
|
+
message: "This endpoint requires CSRF protection and is intended for web browser requests.",
|
15
|
+
hint: "For API requests, use the Propel-generated API controllers instead.",
|
16
|
+
note: "API controllers are CSRF-free and use JWT authentication."
|
17
|
+
}, status: :unprocessable_content
|
18
|
+
end
|
19
|
+
end
|