propel_api 0.1.4 → 0.2.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 +53 -6
- data/lib/generators/propel_api/core/named_base.rb +88 -27
- data/lib/generators/propel_api/core/relationship_inferrer.rb +42 -8
- data/lib/generators/propel_api/templates/config/propel_api.rb.tt +3 -2
- data/lib/generators/propel_api/templates/controllers/api_controller_graphiti.rb +49 -6
- data/lib/generators/propel_api/templates/controllers/api_controller_propel_facets.rb +215 -4
- data/lib/generators/propel_api/templates/scaffold/facet_controller_template.rb.tt +27 -1
- data/lib/generators/propel_api/templates/scaffold/facet_model_template.rb.tt +39 -6
- data/lib/generators/propel_api/templates/seeds/seeds_template.rb.tt +11 -2
- data/lib/generators/propel_api/templates/tests/controller_test_template.rb.tt +279 -36
- data/lib/generators/propel_api/templates/tests/fixtures_template.yml.tt +30 -0
- data/lib/generators/propel_api/templates/tests/integration_test_template.rb.tt +459 -56
- data/lib/generators/propel_api/templates/tests/model_test_template.rb.tt +25 -7
- data/lib/generators/propel_api/unpack/unpack_generator.rb +52 -30
- data/lib/propel_api.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8fb51b38b12561c709712a339a39cf3639cd1e8a16149f72c00f1566ee37bdcf
|
4
|
+
data.tar.gz: a90352463962e6385b895cf339195b6860f0d5e98ccf1c4e6e3237957f6895cf
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a2d01e1bcc8f7a749e8de4101d6f7be9dc5c4a7b2b0f2b7d5dc78e630dbba0102cedee82ff957cd210bde487a66c0b936a546b50c75f38e45db84dabcc74e6d0
|
7
|
+
data.tar.gz: 94dad0a33d4fd5af9327d42b21faa0a69bcdd7a018d914293db3643e68496b4b4f979d99cb14f8683b0df3040f6f15b55b6c4ff47c72015da9eaf6d336d5fb83
|
data/CHANGELOG.md
CHANGED
@@ -10,14 +10,61 @@ 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
|
13
|
+
## [0.2.1] - 2025-01-14
|
14
14
|
|
15
15
|
### Fixed
|
16
|
-
- **
|
17
|
-
-
|
18
|
-
-
|
16
|
+
- **Dependency update**: Improved compatibility with PropelFacets 0.2.1
|
17
|
+
- API controllers now work correctly with fixed `for_organization` scope installation
|
18
|
+
- Resolves `NoMethodError` when using generated controllers in fresh Rails installations
|
19
19
|
|
20
|
-
## [0.
|
20
|
+
## [0.2.0] - 2025-09-02
|
21
|
+
|
22
|
+
### Added
|
23
|
+
- **Organization-level multi-tenancy security** - Complete data isolation between organizations
|
24
|
+
- All API queries automatically scoped to user's organization
|
25
|
+
- JWT tokens include `organization_id` for secure context extraction
|
26
|
+
- `for_organization(org_id)` scope added to ApplicationRecord base class
|
27
|
+
- Cross-organization data access completely blocked (show, update, delete return 404)
|
28
|
+
- New records automatically assigned to authenticated user's organization
|
29
|
+
- Comprehensive security test suite with 12 tests covering all attack vectors
|
30
|
+
- Zero impact on existing single-tenant applications
|
31
|
+
|
32
|
+
### Fixed
|
33
|
+
- **Authentication namespace conflict resolved** - Renamed authentication concern to prevent module name collision
|
34
|
+
- `PropelAuthentication` concern renamed to `PropelAuthenticationConcern`
|
35
|
+
- Eliminates conflict between authentication controller concern and PropelAuthentication configuration module
|
36
|
+
- Updated all controller templates to use `include PropelAuthenticationConcern`
|
37
|
+
- Updated PropelFacets and Graphiti API controller templates
|
38
|
+
- Improved method visibility: `authenticate_user`, `current_user`, and `extract_jwt_token` are now public methods
|
39
|
+
- Enhanced flexibility for custom authentication scenarios (email notifications, audit logging, token refresh)
|
40
|
+
|
41
|
+
### Security
|
42
|
+
- **Multi-tenant data isolation** - Zero-trust organization scoping prevents data leaks
|
43
|
+
- Index queries: Users only see their organization's records
|
44
|
+
- Individual access: 404 responses for other organizations' records
|
45
|
+
- CRUD operations: All create/update/delete operations respect organization boundaries
|
46
|
+
- JWT security: Organization context properly extracted and validated
|
47
|
+
- Database-level enforcement via ActiveRecord scopes
|
48
|
+
|
49
|
+
### Improved
|
50
|
+
- **Authentication concern API design** - Better method organization and access patterns
|
51
|
+
- `authenticate_user` - Public method for `before_action` callbacks
|
52
|
+
- `current_user` - Public method for accessing authenticated user
|
53
|
+
- `current_organization_id` - Public method for accessing organization context
|
54
|
+
- `extract_jwt_token` - Public method for custom authentication scenarios
|
55
|
+
- Clean separation between public API and internal implementation
|
56
|
+
|
57
|
+
## [0.1.4] - 2025-08-15
|
58
|
+
|
59
|
+
### Improved
|
60
|
+
- Minor bug fixes and stability improvements
|
61
|
+
|
62
|
+
## [0.1.3] - 2025-07-22
|
63
|
+
|
64
|
+
### Improved
|
65
|
+
- Performance optimizations and code refinements
|
66
|
+
|
67
|
+
## [0.1.2] - 2025-07-15
|
21
68
|
|
22
69
|
### Added
|
23
70
|
- **Automatic inverse relationship generation** - When creating resources with references, parent models are automatically updated
|
@@ -49,7 +96,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
49
96
|
- **Better error handling** - Graceful recovery when parent model modifications fail
|
50
97
|
- **Console output** - Color-coded feedback for relationship additions and warnings
|
51
98
|
|
52
|
-
## [0.1.1] - 2025-
|
99
|
+
## [0.1.1] - 2025-07-11
|
53
100
|
|
54
101
|
### Added
|
55
102
|
- **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
|
-
#
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
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
|
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 #
|
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
|
107
|
-
|
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
|
-
|
110
|
-
|
111
|
-
|
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
|
170
|
+
# Convert attributes to permitted param names with proper Rails strong parameter syntax
|
130
171
|
params = attributes.map do |attr|
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
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
|
-
#
|
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
|
68
|
-
if
|
69
|
-
|
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
|
88
|
-
|
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
|
-
/(
|
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
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|