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.
@@ -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 PropelAuthentication
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
- @pagy, resources = pagy(apply_scopes(resource_class.all))
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
- @resource = resource_class.new(resource_params)
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
- @resource = resource_class.find(params[:id])
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 { |attribute| ":#{attribute}" }.join(', ') %>
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 (required) and agency (optional)
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, optional: true
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 %>, optional: true
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
- detail_attributes.each do |attribute| -%>, :<%= attribute.name %><% end -%><% attributes.select { |attr| attr.type == :references }.each do |reference| -%>, :<%= reference.name %><% end -%><% unless options[:skip_tenancy] -%>, :organization, :agency<% end -%>]
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 -%>