propel_api 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2412ee53a0d7ea8f145ef0fdc491b35099ca137238c8431512b58e63e75ee715
4
- data.tar.gz: 1e7d0198d37aed70300eeca7972d41faf3d75b86d9e8c704fd63cd7ef250caf5
3
+ metadata.gz: fda016a4637d6921f4cbd7c1739aa12b652453b68f2bf00e627adda7a0384086
4
+ data.tar.gz: bee75517d7fee3d5f04536be44f5dba602bc5525076814a87e7b6f743a467913
5
5
  SHA512:
6
- metadata.gz: 9437dfd83ecea7de889f9455e7097a4f0d4229b0475eb59e8d453695fe2cca302c6ccad2353bf78cb999c3e74bbc358d1b2264b0dcbad18db236c099f877160f
7
- data.tar.gz: bdc7bd6e5a347c93700253634aaa277891d4cd4c88b2606d2c691f4dce285a304934ed4cf3ea2d46a5a536c2a71a7ccedf6294104e23c6339f225345e9e80cb3
6
+ metadata.gz: a19fc6c6f6c4d12773081adc7a856d7f4fcb1977c56cb3686334d8e316bb29ef540829f0f25adcd61ae6285f10a010691f063b0003bad463bc8b8624afd05974
7
+ data.tar.gz: 65349f86faa2e171c64b20d6699f90dc56649ae1b8c888f64ac8c3b76f147bbd301689ac79a0032c71cc134e3d1c3e380f0f5a1fbb7a35c46ea0adaebd63fb44
data/CHANGELOG.md CHANGED
@@ -10,7 +10,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
  ### Planned Features
11
11
  - GraphQL adapter support
12
12
 
13
- ## [0.1.2] - 2025-01-11
13
+ ## [0.2.0] - 2025-09-02
14
+
15
+ ### BREAKING CHANGES
16
+ - **Security-first API architecture**: Complete redesign of tenancy validation flow
17
+ - Invalid tenancy context (organization_id, agency_id) now returns 403 Forbidden instead of 422 Unprocessable Entity
18
+ - Security validation occurs before business validation (prevents information disclosure)
19
+ - Error response structure changed from `{"errors": {...}}` to `{"error": "...", "message": "...", "code": "..."}`
20
+ - **Controller generation format**: Generated controllers now use foreign key format in permitted_params
21
+ - `permitted_params :organization` → `permitted_params :organization_id`
22
+ - Ensures proper strong parameter filtering for security validation
23
+ - **Configuration dependency**: `agency_tenancy` configuration moved to PropelAuthentication
24
+ - Remove `PropelApi.configuration.agency_tenancy` from config files
25
+ - Agency tenancy now controlled entirely by PropelAuthentication
26
+
27
+ ### Added
28
+ - **Organization-level multi-tenancy security** - Complete data isolation between organizations
29
+ - All API queries automatically scoped to user's organization
30
+ - JWT tokens include `organization_id` for secure context extraction
31
+ - `for_organization(org_id)` scope added to ApplicationRecord base class
32
+ - Cross-organization data access completely blocked (show, update, delete return 404)
33
+ - New records automatically assigned to authenticated user's organization
34
+ - Comprehensive security test suite covering all attack vectors
35
+ - Zero impact on existing single-tenant applications
36
+ - **Configurable auto-assignment**: Integration with PropelAuthentication tenancy configuration
37
+ - Respects `require_organization_id` and `require_user_id` settings from PropelAuthentication
38
+ - Helper methods: `require_organization_id?`, `require_user_id?` for configuration access
39
+ - **Enhanced security validation**: Comprehensive unauthorized access protection
40
+ - Organization access validation with detailed error codes
41
+ - User assignment validation for admin delegation scenarios
42
+ - Agency access validation with proper user permission checking
43
+ - **Conditional test generation**: Tests now adapt behavior based on PropelAuthentication configuration
44
+ - Auto-assignment mode: Tests expect 201 Created with proper context assignment
45
+ - Strict mode: Tests expect 422 Unprocessable Entity when required fields missing
46
+ - Security tests: Tests expect 403 Forbidden for unauthorized access attempts
47
+
48
+ ### Fixed
49
+ - **Authentication namespace conflict resolved** - Renamed authentication concern to prevent module name collision
50
+ - `PropelAuthentication` concern renamed to `PropelAuthenticationConcern`
51
+ - Eliminates conflict between authentication controller concern and PropelAuthentication configuration module
52
+ - Updated all controller templates to use `include PropelAuthenticationConcern`
53
+ - Updated PropelFacets and Graphiti API controller templates
54
+ - Improved method visibility: `authenticate_user`, `current_user`, and `extract_jwt_token` are now public methods
55
+ - Enhanced flexibility for custom authentication scenarios (email notifications, audit logging, token refresh)
56
+ - **Attribute introspection**: Fixed foreign key detection for User and other models with associations
57
+ - Database column introspection now properly generates foreign key format for permitted_params
58
+ - Association detection preserved for model relationship generation
59
+ - JSON field handling improved with proper `field: {}` syntax for nested objects
60
+ - **Test data generation**: Enhanced User model test data generation
61
+ - Unique email and username generation to prevent fixture conflicts
62
+ - Proper field names for User model tests (email_address, username, password vs generic title)
63
+ - Model-specific test data patterns for comprehensive validation coverage
64
+
65
+ ### Security
66
+ - **Multi-tenant data isolation** - Zero-trust organization scoping prevents data leaks
67
+ - Index queries: Users only see their organization's records
68
+ - Individual access: 404 responses for other organizations' records
69
+ - CRUD operations: All create/update/delete operations respect organization boundaries
70
+ - JWT security: Organization context properly extracted and validated
71
+ - Database-level enforcement via ActiveRecord scopes
72
+
73
+ ### Improved
74
+ - **Multi-step security validation**: Three-phase validation for robust security
75
+ 1. Security validation (403 for unauthorized access)
76
+ 2. Auto-assignment (based on configuration)
77
+ 3. Final validation (422 for missing required fields)
78
+ - **Authentication concern API design** - Better method organization and access patterns
79
+ - `authenticate_user` - Public method for `before_action` callbacks
80
+ - `current_user` - Public method for accessing authenticated user
81
+ - `current_organization_id` - Public method for accessing organization context
82
+ - `extract_jwt_token` - Public method for custom authentication scenarios
83
+ - Clean separation between public API and internal implementation
84
+ - **Template reliability**: Attribute detection using generator attributes instead of database queries during generation
85
+
86
+ ## [0.1.4] - 2025-08-15
87
+
88
+ ### Improved
89
+ - Minor bug fixes and stability improvements
90
+
91
+ ## [0.1.3] - 2025-07-22
92
+
93
+ ### Improved
94
+ - Performance optimizations and code refinements
95
+
96
+ ## [0.1.2] - 2025-07-15
14
97
 
15
98
  ### Added
16
99
  - **Automatic inverse relationship generation** - When creating resources with references, parent models are automatically updated
@@ -42,7 +125,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
42
125
  - **Better error handling** - Graceful recovery when parent model modifications fail
43
126
  - **Console output** - Color-coded feedback for relationship additions and warnings
44
127
 
45
- ## [0.1.1] - 2025-01-10
128
+ ## [0.1.1] - 2025-07-11
46
129
 
47
130
  ### Added
48
131
  - **Optional usage comments in controllers** - `--with-comments` flag for generators
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'configuration_methods'
4
4
  require_relative 'path_generation_methods'
5
+ require_relative 'relationship_inferrer'
5
6
 
6
7
  ##
7
8
  # Named base class for PropelApi generators that work with named resources
@@ -44,6 +45,10 @@ module PropelApi
44
45
  file_name.pluralize
45
46
  end
46
47
 
48
+ def singular_route_name
49
+ file_name
50
+ end
51
+
47
52
  def api_route_path
48
53
  path_parts = []
49
54
  path_parts << @api_namespace if @api_namespace.present?
@@ -60,6 +65,14 @@ module PropelApi
60
65
  path_parts.join("_")
61
66
  end
62
67
 
68
+ def api_singular_route_helper
69
+ path_parts = []
70
+ path_parts << @api_namespace if @api_namespace.present?
71
+ path_parts << @api_version if @api_version.present?
72
+ path_parts << singular_route_name
73
+ path_parts.join("_")
74
+ end
75
+
63
76
  # Model introspection methods for existing models
64
77
  def introspect_model_attributes
65
78
  return [] unless model_exists?
@@ -70,30 +83,46 @@ module PropelApi
70
83
  # Extract basic attribute information from model file
71
84
  attributes = []
72
85
 
73
- # Look for belongs_to associations
74
- model_content.scan(/belongs_to\s+:(\w+)(?:,\s*(.*))?/) do |association, options|
75
- # Convert belongs_to to reference attribute
76
- attr_name = "#{association}_id"
77
- unless should_exclude_from_permitted_params?(attr_name, :integer)
78
- attributes << {
79
- name: attr_name,
80
- type: :integer,
81
- reference: association
82
- }
86
+ # Use RelationshipInferrer to introspect existing associations
87
+ association_data = RelationshipInferrer.introspect_existing_associations(model_content)
88
+ association_names = association_data[:association_names]
89
+
90
+ # Add association attributes (with filtering)
91
+ association_data[:attributes].each do |attr_data|
92
+ unless should_exclude_from_permitted_params?(attr_data[:name], attr_data[:type])
93
+ attributes << attr_data
83
94
  end
84
95
  end
85
96
 
86
97
  # Look for basic validations that might indicate attributes
98
+ # Exclude association names (they're already handled above as foreign keys)
99
+ # NOTE: Only add validated attributes if they weren't already detected by schema introspection
100
+ # This prevents overriding correct types (e.g., :decimal) with default :string
87
101
  model_content.scan(/validates\s+:(\w+)/) do |attr|
88
102
  attr_name = attr.first
89
- unless attributes.any? { |a| a[:name] == attr_name } || should_exclude_from_permitted_params?(attr_name, :string)
103
+ unless association_names.include?(attr_name) ||
104
+ attributes.any? { |a| a[:name] == attr_name } || # Proper deduplication - don't override existing attributes
105
+ should_exclude_from_permitted_params?(attr_name, :string)
90
106
  attributes << {
91
107
  name: attr_name,
92
- type: :string # Default to string for validated attributes
108
+ type: :string # Only use :string as fallback for truly unknown attributes
93
109
  }
94
110
  end
95
111
  end
96
112
 
113
+ # Detect common virtual attributes patterns
114
+ # Handle has_secure_password virtual attributes (password, password_confirmation)
115
+ if model_content.match?(/has_secure_password|include\s+Authenticatable/)
116
+ unless should_exclude_from_permitted_params?('password', :string) ||
117
+ attributes.any? { |a| a[:name] == 'password' }
118
+ attributes << { name: 'password', type: :string, required: true }
119
+ end
120
+ unless should_exclude_from_permitted_params?('password_confirmation', :string) ||
121
+ attributes.any? { |a| a[:name] == 'password_confirmation' }
122
+ attributes << { name: 'password_confirmation', type: :string, required: false }
123
+ end
124
+ end
125
+
97
126
  # Try to introspect from schema if available
98
127
  if defined?(ActiveRecord::Base) && model_class_defined?
99
128
  begin
@@ -103,13 +132,25 @@ module PropelApi
103
132
  attr_name = column.name
104
133
  attr_type = schema_type_to_generator_type(column.type)
105
134
 
106
- # Skip if we already have this attribute or if it should be excluded
107
- next if attributes.any? { |a| a[:name] == attr_name } || should_exclude_from_permitted_params?(attr_name, attr_type)
135
+ # Skip if it should be excluded or if foreign key is covered by association
136
+ # Also skip foreign key columns if we already have the corresponding association
137
+ foreign_key_association = attr_name.end_with?('_id') ? attr_name.gsub(/_id$/, '') : nil
138
+ already_has_association = foreign_key_association && attributes.any? { |a| a[:name] == foreign_key_association && a[:type] == :references }
108
139
 
109
- attributes << {
110
- name: attr_name,
111
- type: attr_type
112
- }
140
+ next if already_has_association || should_exclude_from_permitted_params?(attr_name, attr_type)
141
+
142
+ # Schema introspection is authoritative - override existing attributes with correct database types
143
+ # EXCEPT for associations, which should remain as :references
144
+ existing_index = attributes.find_index { |a| a[:name] == attr_name }
145
+ if existing_index
146
+ existing_attr = attributes[existing_index]
147
+ # Don't override associations detected by RelationshipInferrer
148
+ unless existing_attr[:type] == :references
149
+ attributes[existing_index] = { name: attr_name, type: attr_type } # Override with correct schema type
150
+ end
151
+ else
152
+ attributes << { name: attr_name, type: attr_type } # Add new attribute
153
+ end
113
154
  end
114
155
  end
115
156
  rescue => e
@@ -126,19 +167,37 @@ module PropelApi
126
167
 
127
168
  attributes = introspect_model_attributes
128
169
 
129
- # Convert attributes to permitted param names (filtering already applied in introspect_model_attributes)
170
+ # Convert attributes to permitted param names with proper Rails strong parameter syntax
130
171
  params = attributes.map do |attr|
131
- attr[:name]
132
- end
133
-
134
- # Always include common multi-tenancy params if not already included
135
- %w[organization_id agency_id].each do |param|
136
- params << param unless params.include?(param)
172
+ # For foreign key columns (organization_id, agency_id, user_id), use column name directly
173
+ if attr[:name].end_with?('_id') && model_has_association_for_foreign_key?(attr[:name])
174
+ attr[:name] # Keep foreign key format: organization_id, agency_id, user_id
175
+ elsif attr[:type] == :references
176
+ # Convert association references to foreign key format for strong parameters
177
+ "#{attr[:name]}_id" # Convert :organization to :organization_id for permitted_params
178
+ elsif attr[:type] == :json
179
+ # JSON/JSONB fields need hash syntax for Rails strong parameters to allow nested objects
180
+ "#{attr[:name]}: {}"
181
+ else
182
+ attr[:name] # Regular attributes: title, description, price, etc.
183
+ end
137
184
  end
138
185
 
139
186
  params
140
187
  end
141
188
 
189
+ def model_has_association_for_foreign_key?(foreign_key)
190
+ return false unless model_class_defined?
191
+
192
+ begin
193
+ association_name = foreign_key.gsub('_id', '')
194
+ model_class = class_name.constantize
195
+ model_class.reflect_on_association(association_name.to_sym).present?
196
+ rescue
197
+ false
198
+ end
199
+ end
200
+
142
201
  def validate_attributes_exist
143
202
  return if options[:all_attributes] || !model_exists?
144
203
 
@@ -265,8 +324,8 @@ module PropelApi
265
324
  # Fallback: Use the same patterns as templates for consistency
266
325
  attr_str = attribute_name.to_s
267
326
 
268
- # Exclude timestamps and internal fields (same as templates)
269
- excluded_patterns = /\A(created_at|updated_at|deleted_at|password_digest|reset_password_token|confirmation_token|unlock_token)\z/i
327
+ # Fallback: Basic exclusion patterns (PropelApi.attribute_filter should handle most cases)
328
+ excluded_patterns = /\A(id|created_at|updated_at|deleted_at|password_digest|reset_password_token|confirmation_token|unlock_token)\z/i
270
329
 
271
330
  # Always exclude security-sensitive fields (same as templates)
272
331
  security_patterns = /(password|digest|token|secret|key|salt|encrypted|confirmation|unlock|reset|api_key|access_token|refresh_token)/i
@@ -305,6 +364,8 @@ module PropelApi
305
364
  :date
306
365
  when :time
307
366
  :time
367
+ when :json, :jsonb
368
+ :json
308
369
  else
309
370
  :string
310
371
  end
@@ -19,6 +19,42 @@ class RelationshipInferrer
19
19
  @inverse_relationships = {}
20
20
  end
21
21
 
22
+ # Class method to introspect existing associations from model content
23
+ # Returns: { association_names: [], attributes: [] }
24
+ def self.introspect_existing_associations(model_content)
25
+ association_names = []
26
+ attributes = []
27
+
28
+ # Look for belongs_to associations
29
+ model_content.scan(/belongs_to\s+:(\w+)(?:,\s*(.*))?/) do |association, options|
30
+ association_names << association
31
+
32
+ # Convert belongs_to to reference attribute for controller generation
33
+ attributes << {
34
+ name: association, # Use association name for template (organization, not organization_id)
35
+ type: :references,
36
+ reference: association
37
+ }
38
+ end
39
+
40
+ # Look for has_many associations (for completeness)
41
+ model_content.scan(/has_many\s+:(\w+)(?:,\s*(.*))?/) do |association, options|
42
+ # We don't create controller attributes for has_many, but track for validation exclusion
43
+ association_names << association
44
+ end
45
+
46
+ # Look for has_one associations (for completeness)
47
+ model_content.scan(/has_one\s+:(\w+)(?:,\s*(.*))?/) do |association, options|
48
+ # We don't create controller attributes for has_one, but track for validation exclusion
49
+ association_names << association
50
+ end
51
+
52
+ {
53
+ association_names: association_names.uniq,
54
+ attributes: attributes
55
+ }
56
+ end
57
+
22
58
  # Generate relationships for this model
23
59
  def belongs_to_relationships
24
60
  relationships = []
@@ -64,12 +100,9 @@ class RelationshipInferrer
64
100
  # Add inverse relationship for non-polymorphic references only
65
101
  add_inverse_relationship(class_name, standard_has_many)
66
102
 
67
- # All references except organization are optional by default (PropelAPI convention)
68
- if reference_name == 'organization'
69
- "belongs_to :#{reference_name}"
70
- else
71
- "belongs_to :#{reference_name}, optional: true"
72
- end
103
+ # All references are required by default (Rails convention)
104
+ # Add 'optional: true' manually if optional behavior is needed
105
+ "belongs_to :#{reference_name}"
73
106
  end
74
107
 
75
108
  def polymorphic_belongs_to(attribute)
@@ -84,8 +117,9 @@ class RelationshipInferrer
84
117
  base_name = attribute.name.to_s
85
118
  end
86
119
 
87
- # Polymorphic associations are optional by default (PropelAPI convention)
88
- "belongs_to :#{base_name}, polymorphic: true, optional: true"
120
+ # Polymorphic associations default to required (Rails convention)
121
+ # Add 'optional: true' manually if optional behavior is needed
122
+ "belongs_to :#{base_name}, polymorphic: true"
89
123
  end
90
124
 
91
125
 
@@ -26,9 +26,10 @@ module PropelApi
26
26
  attr_accessor :sensitive_patterns, :excluded_patterns, :large_content_patterns
27
27
 
28
28
  def initialize
29
- # Security-sensitive field patterns
29
+ # Security-sensitive field patterns (exclude from permitted params)
30
+ # Note: Excludes stored/hashed fields but allows input fields like password, password_confirmation
30
31
  @sensitive_patterns = [
31
- /(password|digest|token|secret|key|salt|encrypted|confirmation|unlock|reset|api_key|access_token|refresh_token)/i,
32
+ /(password_digest|reset_password_token|confirmation_token|unlock_token|remember_token|digest|secret|key|salt|encrypted|api_key|access_token|refresh_token)/i,
32
33
  /\A(ssn|social_security|credit_card|cvv|pin|tax_id)\z/i
33
34
  ]
34
35
 
@@ -1,7 +1,7 @@
1
1
  class <%= api_controller_class_name %> < ApplicationController
2
2
  include Graphiti::Rails
3
3
  include Graphiti::Responders
4
- include PropelAuthentication
4
+ include PropelAuthenticationConcern
5
5
 
6
6
  before_action :authenticate_user
7
7
 
@@ -17,17 +17,26 @@ class <%= api_controller_class_name %> < ApplicationController
17
17
 
18
18
  <% end -%>
19
19
  def index
20
- resources = resource.all(params)
20
+ scoped_params = params_with_organization_scope
21
+ return unless scoped_params # Early return if organization context missing
22
+
23
+ resources = resource.all(scoped_params)
21
24
  respond_with(resources)
22
25
  end
23
26
 
24
27
  def show
25
- resource_instance = resource.find(params)
28
+ scoped_params = params_with_organization_scope
29
+ return unless scoped_params # Early return if organization context missing
30
+
31
+ resource_instance = resource.find(scoped_params)
26
32
  respond_with(resource_instance)
27
33
  end
28
34
 
29
35
  def create
30
- resource_instance = resource.build(params)
36
+ scoped_params = params_with_organization_context
37
+ return unless scoped_params # Early return if organization context missing
38
+
39
+ resource_instance = resource.build(scoped_params)
31
40
 
32
41
  if resource_instance.save
33
42
  respond_with(resource_instance, status: :created)
@@ -37,7 +46,10 @@ class <%= api_controller_class_name %> < ApplicationController
37
46
  end
38
47
 
39
48
  def update
40
- resource_instance = resource.find(params)
49
+ scoped_params = params_with_organization_scope
50
+ return unless scoped_params # Early return if organization context missing
51
+
52
+ resource_instance = resource.find(scoped_params)
41
53
 
42
54
  if resource_instance.update_attributes
43
55
  respond_with(resource_instance)
@@ -47,7 +59,10 @@ class <%= api_controller_class_name %> < ApplicationController
47
59
  end
48
60
 
49
61
  def destroy
50
- resource_instance = resource.find(params)
62
+ scoped_params = params_with_organization_scope
63
+ return unless scoped_params # Early return if organization context missing
64
+
65
+ resource_instance = resource.find(scoped_params)
51
66
  resource_instance.destroy
52
67
 
53
68
  respond_with(resource_instance)
@@ -77,6 +92,34 @@ class <%= api_controller_class_name %> < ApplicationController
77
92
  raise "#{resource_class_name} not found. Please create it using: rails generate graphiti:resource #{controller_name.classify}"
78
93
  end
79
94
 
95
+ # Add organization scope to params for query operations
96
+ def params_with_organization_scope
97
+ unless current_organization_id
98
+ render json: {
99
+ error: 'Organization context required',
100
+ message: 'Valid organization_id must be provided in JWT token',
101
+ code: 'MISSING_ORGANIZATION_CONTEXT'
102
+ }, status: :forbidden
103
+ return nil
104
+ end
105
+
106
+ params.merge(organization_scope: current_organization_id)
107
+ end
108
+
109
+ # Add organization context to params for create operations
110
+ def params_with_organization_context
111
+ unless current_organization_id
112
+ render json: {
113
+ error: 'Organization context required',
114
+ message: 'Valid organization_id must be provided in JWT token',
115
+ code: 'MISSING_ORGANIZATION_CONTEXT'
116
+ }, status: :forbidden
117
+ return nil
118
+ end
119
+
120
+ params.merge(organization_id: current_organization_id)
121
+ end
122
+
80
123
  <% if options[:with_comments] -%>
81
124
  # Graphiti handles parameter filtering through resources automatically
82
125
  # No need for manual strong parameters - resources define allowed attributes