propel_api 0.1.4 → 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 +4 -4
- data/CHANGELOG.md +82 -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
@@ -3,7 +3,7 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
3
3
|
include Pagy::Backend
|
4
4
|
include FacetRenderer
|
5
5
|
include StrongParamsHelper
|
6
|
-
include
|
6
|
+
include PropelAuthenticationConcern
|
7
7
|
|
8
8
|
before_action :authenticate_user
|
9
9
|
before_action :set_resource, only: [:show, :update, :destroy]
|
@@ -15,7 +15,10 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
15
15
|
connect_facet :details, actions: [:show, :update, :create]
|
16
16
|
|
17
17
|
def index
|
18
|
-
|
18
|
+
scoped_resource = for_organization(resource_class)
|
19
|
+
return unless scoped_resource # Early return if organization context missing
|
20
|
+
|
21
|
+
@pagy, resources = pagy(apply_scopes(scoped_resource))
|
19
22
|
render json: {
|
20
23
|
data: resources.map { |resource| resource_json(resource) },
|
21
24
|
pagination: pagy_metadata(@pagy)
|
@@ -27,7 +30,13 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
27
30
|
end
|
28
31
|
|
29
32
|
def create
|
30
|
-
|
33
|
+
scoped_resource = for_organization(resource_class)
|
34
|
+
return unless scoped_resource # Early return if organization context missing
|
35
|
+
|
36
|
+
params_with_context = build_creation_params
|
37
|
+
return if params_with_context.empty? # Early return if validation failed (response already rendered)
|
38
|
+
|
39
|
+
@resource = scoped_resource.build(params_with_context)
|
31
40
|
if @resource.save
|
32
41
|
render json: { data: resource_json(@resource) }, status: :created
|
33
42
|
else
|
@@ -55,7 +64,209 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
55
64
|
end
|
56
65
|
|
57
66
|
def set_resource
|
58
|
-
|
67
|
+
# Ensure resource is within organization scope
|
68
|
+
scoped_resource = for_organization(resource_class)
|
69
|
+
return unless scoped_resource # Early return if organization context missing
|
70
|
+
|
71
|
+
@resource = scoped_resource.find(params[:id])
|
72
|
+
end
|
73
|
+
|
74
|
+
# Organization scoping method with support for self-signup
|
75
|
+
def for_organization(model_class)
|
76
|
+
return render_organization_context_error unless current_organization_id
|
77
|
+
|
78
|
+
# Special case: Organization model should only show user's own organization
|
79
|
+
if model_class == Organization
|
80
|
+
return model_class.where(id: current_organization_id)
|
81
|
+
end
|
82
|
+
|
83
|
+
# Start with organization scoping
|
84
|
+
scoped_query = model_class.for_organization(current_organization_id)
|
85
|
+
|
86
|
+
# Additional agency scoping only if agency tenancy is enabled
|
87
|
+
apply_agency_scoping(scoped_query, model_class)
|
88
|
+
end
|
89
|
+
|
90
|
+
# Build parameters for resource creation with security-first flow
|
91
|
+
def build_creation_params
|
92
|
+
return {} unless validate_organization_context
|
93
|
+
|
94
|
+
params = resource_params
|
95
|
+
|
96
|
+
# FIRST: Security validation of any provided context (fail-fast with 403)
|
97
|
+
params = validate_provided_tenancy_context(params)
|
98
|
+
return {} if params.empty? # Early return on security failure
|
99
|
+
|
100
|
+
# SECOND: Auto-assign missing context (only after security validation passes)
|
101
|
+
params = auto_assign_missing_context(params)
|
102
|
+
|
103
|
+
# THIRD: Final validation (business rules, data format)
|
104
|
+
params = validate_final_tenancy_requirements(params)
|
105
|
+
return {} if params.empty? # Early return on validation failure
|
106
|
+
|
107
|
+
params
|
108
|
+
end
|
109
|
+
|
110
|
+
private
|
111
|
+
|
112
|
+
# Render organization context error
|
113
|
+
def render_organization_context_error
|
114
|
+
render json: {
|
115
|
+
error: 'Organization context required',
|
116
|
+
message: 'Valid organization_id must be provided in JWT token',
|
117
|
+
code: 'MISSING_ORGANIZATION_CONTEXT'
|
118
|
+
}, status: :forbidden
|
119
|
+
nil
|
120
|
+
end
|
121
|
+
|
122
|
+
# Validate organization context is available
|
123
|
+
def validate_organization_context
|
124
|
+
return true if current_organization_id
|
125
|
+
|
126
|
+
render_organization_context_error
|
127
|
+
false
|
128
|
+
end
|
129
|
+
|
130
|
+
# Apply agency scoping if enabled and applicable
|
131
|
+
def apply_agency_scoping(scoped_query, model_class)
|
132
|
+
if agency_tenancy_enabled? &&
|
133
|
+
model_class.column_names.include?('agency_id') &&
|
134
|
+
current_agency_ids&.any?
|
135
|
+
scoped_query.where(agency_id: current_agency_ids)
|
136
|
+
else
|
137
|
+
scoped_query
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
# FIRST: Security validation of any provided tenancy context (fail-fast)
|
142
|
+
def validate_provided_tenancy_context(params)
|
143
|
+
# If organization_id is provided, verify user has access to it
|
144
|
+
if params[:organization_id].present?
|
145
|
+
unless params[:organization_id].to_i == current_organization_id
|
146
|
+
render json: {
|
147
|
+
error: 'Unauthorized organization access',
|
148
|
+
message: 'You do not have access to create resources in this organization',
|
149
|
+
code: 'UNAUTHORIZED_ORGANIZATION_ACCESS'
|
150
|
+
}, status: :forbidden
|
151
|
+
return {}
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
# If user_id is provided, verify it's authorized (for admin delegation scenarios)
|
156
|
+
if params[:user_id].present?
|
157
|
+
# For now, only allow current user (can be extended for admin delegation)
|
158
|
+
unless params[:user_id].to_i == current_user.id
|
159
|
+
render json: {
|
160
|
+
error: 'Unauthorized user assignment',
|
161
|
+
message: 'You can only create resources as yourself (admin delegation not yet implemented)',
|
162
|
+
code: 'UNAUTHORIZED_USER_ASSIGNMENT'
|
163
|
+
}, status: :forbidden
|
164
|
+
return {}
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# If agency_id is provided, verify user has access to it
|
169
|
+
if params[:agency_id].present? && agency_tenancy_enabled?
|
170
|
+
unless current_agency_ids.include?(params[:agency_id].to_i)
|
171
|
+
render json: {
|
172
|
+
error: 'Unauthorized agency access',
|
173
|
+
message: 'You do not have access to create resources in this agency',
|
174
|
+
code: 'UNAUTHORIZED_AGENCY_ACCESS'
|
175
|
+
}, status: :forbidden
|
176
|
+
return {}
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
params
|
181
|
+
end
|
182
|
+
|
183
|
+
# SECOND: Auto-assign missing context based on configuration
|
184
|
+
def auto_assign_missing_context(params)
|
185
|
+
# Auto-assign organization_id if missing and not required to be explicit
|
186
|
+
if params[:organization_id].blank? && !require_organization_id?
|
187
|
+
# Special case: Organization model doesn't get organization_id assigned
|
188
|
+
unless resource_class == Organization
|
189
|
+
params[:organization_id] = current_organization_id
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
# Auto-assign user_id if missing and not required to be explicit
|
194
|
+
if params[:user_id].blank? && !require_user_id?
|
195
|
+
# Auto-assign user_id for models that have user association (except User model itself)
|
196
|
+
if resource_class != User && resource_class.column_names.include?('user_id')
|
197
|
+
params[:user_id] = current_user.id
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
params
|
202
|
+
end
|
203
|
+
|
204
|
+
|
205
|
+
|
206
|
+
# Check if agency tenancy is enabled
|
207
|
+
def agency_tenancy_enabled?
|
208
|
+
# Check PropelAuthentication configuration (owns tenancy models)
|
209
|
+
if defined?(PropelAuthentication) && PropelAuthentication.respond_to?(:configuration)
|
210
|
+
PropelAuthentication.configuration.agency_tenancy
|
211
|
+
else
|
212
|
+
true # Safe default - enables agency tenancy when configuration unavailable
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
# Check if explicit organization_id is required in API requests
|
217
|
+
def require_organization_id?
|
218
|
+
if defined?(PropelAuthentication) && PropelAuthentication.respond_to?(:configuration)
|
219
|
+
PropelAuthentication.configuration.require_organization_id
|
220
|
+
else
|
221
|
+
false # Safe default - allow auto-assignment when configuration unavailable
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
225
|
+
# Check if explicit user_id is required in API requests
|
226
|
+
def require_user_id?
|
227
|
+
if defined?(PropelAuthentication) && PropelAuthentication.respond_to?(:configuration)
|
228
|
+
PropelAuthentication.configuration.require_user_id
|
229
|
+
else
|
230
|
+
false # Safe default - allow auto-assignment when configuration unavailable
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
|
235
|
+
|
236
|
+
# THIRD: Final validation - ensure required fields are present after auto-assignment
|
237
|
+
def validate_final_tenancy_requirements(params)
|
238
|
+
errors = {}
|
239
|
+
|
240
|
+
# Validate organization_id is present (after security check and auto-assignment)
|
241
|
+
if params[:organization_id].blank?
|
242
|
+
if require_organization_id?
|
243
|
+
errors[:organization_id] = ["is required - set require_organization_id = false to enable auto-assignment"]
|
244
|
+
else
|
245
|
+
errors[:organization_id] = ["could not be determined - check authentication context"]
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
# Validate user_id is present for models that need it (after auto-assignment)
|
250
|
+
if resource_class.column_names.include?('user_id') && params[:user_id].blank?
|
251
|
+
if require_user_id?
|
252
|
+
errors[:user_id] = ["is required - set require_user_id = false to enable auto-assignment"]
|
253
|
+
else
|
254
|
+
errors[:user_id] = ["could not be determined - check authentication context"]
|
255
|
+
end
|
256
|
+
end
|
257
|
+
|
258
|
+
# Validate agency_id is present for models that need it (business requirement)
|
259
|
+
if agency_tenancy_enabled? && resource_class.column_names.include?('agency_id') && params[:agency_id].blank?
|
260
|
+
errors[:agency_id] = ["is required when agency tenancy is enabled"]
|
261
|
+
end
|
262
|
+
|
263
|
+
# Return validation errors if any
|
264
|
+
if errors.any?
|
265
|
+
render json: { errors: errors }, status: :unprocessable_entity
|
266
|
+
return {}
|
267
|
+
end
|
268
|
+
|
269
|
+
params
|
59
270
|
end
|
60
271
|
|
61
272
|
<% if options[:with_comments] -%>
|
@@ -1,11 +1,24 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
class <%= controller_class_name_with_namespace %>Controller < <%= api_controller_class_name %>
|
4
|
+
<% if class_name == 'Organization' -%>
|
5
|
+
# Organizations are created through signup flow for security
|
6
|
+
before_action :block_organization_creation, only: [:create]
|
7
|
+
|
8
|
+
<% end -%>
|
4
9
|
<% if options[:with_comments] -%>
|
5
10
|
# Define which parameters are allowed for <%= class_name %> creation/updates
|
6
11
|
# This uses the StrongParamsHelper concern from the base controller
|
7
12
|
<% end -%>
|
8
|
-
permitted_params <%= permitted_param_names.map { |
|
13
|
+
permitted_params <%= permitted_param_names.map { |param|
|
14
|
+
# If param already contains colon (JSON field syntax), use as-is
|
15
|
+
# Otherwise add colon prefix for regular fields
|
16
|
+
if param.to_s.include?(':')
|
17
|
+
param # Already formatted as "field: {}" for JSON fields
|
18
|
+
else
|
19
|
+
":#{param}" # Add colon prefix for regular fields
|
20
|
+
end
|
21
|
+
}.join(', ') %>
|
9
22
|
|
10
23
|
<% if options[:with_comments] -%>
|
11
24
|
# Connect facets to actions - customize as needed for your <%= class_name.downcase %> model
|
@@ -82,4 +95,17 @@ class <%= controller_class_name_with_namespace %>Controller < <%= api_controller
|
|
82
95
|
# end
|
83
96
|
|
84
97
|
<% end -%>
|
98
|
+
<% if class_name == 'Organization' -%>
|
99
|
+
|
100
|
+
private
|
101
|
+
|
102
|
+
def block_organization_creation
|
103
|
+
render json: {
|
104
|
+
error: 'Organizations cannot be created through API',
|
105
|
+
message: 'Use the /signup endpoint to create new organizations',
|
106
|
+
code: 'ORGANIZATION_CREATION_BLOCKED',
|
107
|
+
hint: 'Organizations are created during user signup process for security'
|
108
|
+
}, status: :forbidden
|
109
|
+
end
|
110
|
+
<% end -%>
|
85
111
|
end
|
@@ -46,9 +46,10 @@ class <%= class_name %> < ApplicationRecord
|
|
46
46
|
|
47
47
|
# Associations
|
48
48
|
<% unless options[:skip_tenancy] -%>
|
49
|
-
# Multi-tenancy: Always include organization
|
49
|
+
# Multi-tenancy: Always include organization and agency for data isolation
|
50
|
+
# User/Agent associations are only added when explicitly specified as generator arguments
|
50
51
|
belongs_to :organization
|
51
|
-
belongs_to :agency
|
52
|
+
belongs_to :agency
|
52
53
|
|
53
54
|
<% end -%>
|
54
55
|
<% if defined?(relationship_inferrer) && relationship_inferrer.should_generate_associations? -%>
|
@@ -65,7 +66,7 @@ class <%= class_name %> < ApplicationRecord
|
|
65
66
|
<% if attribute.name == 'organization' -%>
|
66
67
|
belongs_to :organization
|
67
68
|
<% else -%>
|
68
|
-
belongs_to :<%= attribute.name
|
69
|
+
belongs_to :<%= attribute.name %> # Add 'optional: true' if this should be optional
|
69
70
|
<% end -%>
|
70
71
|
<% end -%>
|
71
72
|
<% end -%>
|
@@ -90,9 +91,9 @@ json_facet :short, fields: [:id<%
|
|
90
91
|
short_attributes = attributes.select do |attr|
|
91
92
|
# Include common identifying fields
|
92
93
|
identifying_fields = %w[name title label slug username email status state active published visible enabled]
|
93
|
-
# Include simple types but exclude large content and timestamps
|
94
|
+
# Include simple types but exclude large content, JSON, and timestamps
|
94
95
|
simple_types = [:string, :integer, :boolean, :decimal, :float]
|
95
|
-
# Exclude large content fields, timestamps, and security-sensitive fields
|
96
|
+
# Exclude large content fields, JSON/JSONB, timestamps, and security-sensitive fields
|
96
97
|
excluded_patterns = /\A(description|content|body|notes|comment|bio|about|summary|created_at|updated_at|deleted_at|password|digest|token|secret|key|salt|encrypted|confirmation|unlock|reset|api_key|access_token|refresh_token)\z/i
|
97
98
|
|
98
99
|
# Always exclude if the field contains security-sensitive words
|
@@ -119,7 +120,39 @@ json_facet :details, fields: [:id<%
|
|
119
120
|
security_patterns.match?(attr.name.to_s) ||
|
120
121
|
excluded_types.include?(attr.type)
|
121
122
|
end
|
122
|
-
|
123
|
+
|
124
|
+
# Collect all field names, avoiding duplicates
|
125
|
+
all_fields = Set.new
|
126
|
+
|
127
|
+
# Add non-reference attributes
|
128
|
+
detail_attributes.reject { |attr| attr.type == :references }.each do |attribute|
|
129
|
+
all_fields << attribute.name.to_sym
|
130
|
+
end
|
131
|
+
|
132
|
+
# Add reference attributes (convert association to foreign key format)
|
133
|
+
detail_attributes.select { |attr| attr.type == :references }.each do |reference|
|
134
|
+
all_fields << "#{reference.name}_id".to_sym
|
135
|
+
end
|
136
|
+
|
137
|
+
# Add tenancy references if not skipped
|
138
|
+
unless options[:skip_tenancy]
|
139
|
+
all_fields << :organization_id
|
140
|
+
all_fields << :agency_id
|
141
|
+
end
|
142
|
+
|
143
|
+
# Output the unique fields
|
144
|
+
all_fields.to_a.sort.each do |field| -%>, :<%= field %><% end -%>]<%
|
145
|
+
|
146
|
+
# Generate include for nested objects (reference associations)
|
147
|
+
reference_associations = detail_attributes.select { |attr| attr.type == :references }.map(&:name)
|
148
|
+
unless options[:skip_tenancy]
|
149
|
+
reference_associations += %w[organization agency]
|
150
|
+
end
|
151
|
+
reference_associations.uniq!
|
152
|
+
|
153
|
+
if reference_associations.any?
|
154
|
+
-%>, include: [<% reference_associations.each_with_index do |assoc, index| -%>:<%= assoc %><%= ',' if index < reference_associations.length - 1 %><% end -%>]<%
|
155
|
+
end -%>
|
123
156
|
|
124
157
|
# Example of more complex json_facet configurations:
|
125
158
|
#
|
@@ -107,14 +107,23 @@ if users.count < 6
|
|
107
107
|
next if users.where(organization: org).count >= 2
|
108
108
|
|
109
109
|
(2 - users.where(organization: org).count).times do
|
110
|
-
User.create!(
|
110
|
+
new_user = User.create!(
|
111
111
|
email_address: Faker::Internet.unique.email,
|
112
112
|
username: Faker::Internet.unique.username,
|
113
113
|
first_name: Faker::Name.first_name,
|
114
114
|
last_name: Faker::Name.last_name,
|
115
115
|
password: "password123",
|
116
|
-
organization: org
|
116
|
+
organization: org,
|
117
|
+
confirmed_at: 1.week.ago # Pre-confirm for easier testing
|
117
118
|
)
|
119
|
+
|
120
|
+
# Create agent association for agency access (CRITICAL for tenancy validation)
|
121
|
+
org_agencies = agencies.where(organization: org)
|
122
|
+
if org_agencies.any?
|
123
|
+
# Assign to random agency within organization
|
124
|
+
agency = org_agencies.sample
|
125
|
+
Agent.create!(user: new_user, agency: agency, role: 'member')
|
126
|
+
end
|
118
127
|
end
|
119
128
|
end
|
120
129
|
<% else -%>
|