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.
Files changed (25) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/README.md +239 -4
  4. data/lib/generators/propel_api/controller/controller_generator.rb +26 -0
  5. data/lib/generators/propel_api/core/named_base.rb +97 -1
  6. data/lib/generators/propel_api/core/relationship_inferrer.rb +4 -11
  7. data/lib/generators/propel_api/install/install_generator.rb +39 -2
  8. data/lib/generators/propel_api/resource/resource_generator.rb +205 -63
  9. data/lib/generators/propel_api/templates/config/propel_api.rb.tt +26 -1
  10. data/lib/generators/propel_api/templates/controllers/api_base_controller.rb +75 -0
  11. data/lib/generators/propel_api/templates/controllers/api_controller_graphiti.rb +3 -3
  12. data/lib/generators/propel_api/templates/controllers/api_controller_propel_facets.rb +22 -12
  13. data/lib/generators/propel_api/templates/controllers/example_controller.rb.tt +2 -2
  14. data/lib/generators/propel_api/templates/errors/propel_api_csrf_error.rb +19 -0
  15. data/lib/generators/propel_api/templates/scaffold/facet_controller_template.rb.tt +41 -8
  16. data/lib/generators/propel_api/templates/scaffold/facet_model_template.rb.tt +43 -10
  17. data/lib/generators/propel_api/templates/scaffold/graphiti_controller_template.rb.tt +1 -1
  18. data/lib/generators/propel_api/templates/scaffold/graphiti_resource_template.rb.tt +2 -2
  19. data/lib/generators/propel_api/templates/seeds/seeds_template.rb.tt +65 -17
  20. data/lib/generators/propel_api/templates/tests/controller_test_template.rb.tt +40 -6
  21. data/lib/generators/propel_api/templates/tests/fixtures_template.yml.tt +61 -18
  22. data/lib/generators/propel_api/templates/tests/integration_test_template.rb.tt +154 -42
  23. data/lib/generators/propel_api/templates/tests/model_test_template.rb.tt +20 -0
  24. data/lib/propel_api.rb +1 -1
  25. 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
- ASSOCIATION EXAMPLES:
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
- Standard belongs_to/has_many (automatic):
14
- rails generate propel_api Article title:string user:references category:references
15
- Article belongs_to :user, :category
16
- User/Category has_many :articles, dependent: :destroy
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
- Polymorphic associations:
19
- rails generate propel_api Comment content:text commentable:references
20
- → Comment belongs_to :commentable, polymorphic: true
21
-
22
- rails generate propel_api Attachment name:string resource_parent:references
23
- → Attachment belongs_to :resource, polymorphic: true
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
- Skip associations entirely:
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
- desc: "Skip multi-tenancy foundation (organization and agency)"
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
- generate :migration, "create_#{table_name}", *migration_attributes
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
- if options[:skip_tenancy]
382
- # Only include what the developer explicitly specified
383
- attributes.map do |attr|
384
- # Convert references to foreign key names for permitted params
385
- # e.g., organization:references becomes organization_id
386
- if attr.type == :references
387
- "#{attr.name}_id"
388
- else
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 %> < ApplicationController
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: :unprocessable_entity)
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: :unprocessable_entity)
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 %> < ApplicationController
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: :unprocessable_entity
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: :unprocessable_entity
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
- return {} unless validate_organization_context
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 params[:organization_id].present?
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
- unless resource_class == Organization
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.agency_tenancy
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 params[:organization_id].blank?
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: :unprocessable_entity
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.items,
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: :unprocessable_entity
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: :unprocessable_entity)
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