rhino-rails 0.9.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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +59 -0
  3. data/lib/rhino/blueprint/blueprint_parser.rb +198 -0
  4. data/lib/rhino/blueprint/blueprint_validator.rb +209 -0
  5. data/lib/rhino/blueprint/generators/factory_generator.rb +74 -0
  6. data/lib/rhino/blueprint/generators/policy_generator.rb +154 -0
  7. data/lib/rhino/blueprint/generators/seeder_generator.rb +160 -0
  8. data/lib/rhino/blueprint/generators/test_generator.rb +291 -0
  9. data/lib/rhino/blueprint/manifest_manager.rb +81 -0
  10. data/lib/rhino/commands/base_command.rb +57 -0
  11. data/lib/rhino/commands/blueprint_command.rb +529 -0
  12. data/lib/rhino/commands/export_postman_command.rb +328 -0
  13. data/lib/rhino/commands/export_types_command.rb +202 -0
  14. data/lib/rhino/commands/generate_command.rb +535 -0
  15. data/lib/rhino/commands/install_command.rb +408 -0
  16. data/lib/rhino/commands/invitation_link_command.rb +107 -0
  17. data/lib/rhino/concerns/belongs_to_organization.rb +49 -0
  18. data/lib/rhino/concerns/has_audit_trail.rb +125 -0
  19. data/lib/rhino/concerns/has_auto_scope.rb +91 -0
  20. data/lib/rhino/concerns/has_permissions.rb +117 -0
  21. data/lib/rhino/concerns/has_rhino.rb +93 -0
  22. data/lib/rhino/concerns/has_uuid.rb +26 -0
  23. data/lib/rhino/concerns/has_validation.rb +250 -0
  24. data/lib/rhino/concerns/hidable_columns.rb +180 -0
  25. data/lib/rhino/configuration.rb +101 -0
  26. data/lib/rhino/controllers/auth_controller.rb +242 -0
  27. data/lib/rhino/controllers/invitations_controller.rb +231 -0
  28. data/lib/rhino/controllers/resources_controller.rb +813 -0
  29. data/lib/rhino/engine.rb +64 -0
  30. data/lib/rhino/mailers/invitation_mailer.rb +22 -0
  31. data/lib/rhino/middleware/resolve_organization_from_route.rb +72 -0
  32. data/lib/rhino/models/audit_log.rb +17 -0
  33. data/lib/rhino/models/organization_invitation.rb +57 -0
  34. data/lib/rhino/models/rhino_model.rb +387 -0
  35. data/lib/rhino/policies/invitation_policy.rb +54 -0
  36. data/lib/rhino/policies/resource_policy.rb +197 -0
  37. data/lib/rhino/query_builder.rb +278 -0
  38. data/lib/rhino/railtie.rb +11 -0
  39. data/lib/rhino/resource_scope.rb +59 -0
  40. data/lib/rhino/routes.rb +124 -0
  41. data/lib/rhino/tasks/rhino.rake +47 -0
  42. data/lib/rhino/templates/audit_trail/create_audit_logs.rb.erb +26 -0
  43. data/lib/rhino/templates/generate/factory.rb.erb +43 -0
  44. data/lib/rhino/templates/generate/migration.rb.erb +26 -0
  45. data/lib/rhino/templates/generate/model.rb.erb +55 -0
  46. data/lib/rhino/templates/generate/policy.rb.erb +52 -0
  47. data/lib/rhino/templates/generate/scope.rb.erb +31 -0
  48. data/lib/rhino/templates/multi_tenant/factories/organizations.rb.erb +9 -0
  49. data/lib/rhino/templates/multi_tenant/factories/roles.rb.erb +9 -0
  50. data/lib/rhino/templates/multi_tenant/factories/user_roles.rb.erb +10 -0
  51. data/lib/rhino/templates/multi_tenant/factories/users.rb.erb +9 -0
  52. data/lib/rhino/templates/multi_tenant/migrations/create_organizations.rb.erb +15 -0
  53. data/lib/rhino/templates/multi_tenant/migrations/create_roles.rb.erb +15 -0
  54. data/lib/rhino/templates/multi_tenant/migrations/create_user_roles.rb.erb +16 -0
  55. data/lib/rhino/templates/multi_tenant/migrations/create_users.rb.erb +15 -0
  56. data/lib/rhino/templates/multi_tenant/models/organization.rb.erb +18 -0
  57. data/lib/rhino/templates/multi_tenant/models/role.rb.erb +11 -0
  58. data/lib/rhino/templates/multi_tenant/models/user.rb.erb +14 -0
  59. data/lib/rhino/templates/multi_tenant/models/user_role.rb.erb +9 -0
  60. data/lib/rhino/templates/multi_tenant/policies/organization_policy.rb.erb +6 -0
  61. data/lib/rhino/templates/multi_tenant/policies/role_policy.rb.erb +6 -0
  62. data/lib/rhino/templates/multi_tenant/seeders/organization_seeder.rb.erb +9 -0
  63. data/lib/rhino/templates/multi_tenant/seeders/role_seeder.rb.erb +19 -0
  64. data/lib/rhino/templates/rhino.rb +71 -0
  65. data/lib/rhino/templates/rhino_model.rb +104 -0
  66. data/lib/rhino/templates/routes.rb +13 -0
  67. data/lib/rhino/version.rb +5 -0
  68. data/lib/rhino/views/lumina/invitation_mailer/invite.html.erb +29 -0
  69. data/lib/rhino-rails.rb +3 -0
  70. data/lib/rhino.rb +26 -0
  71. metadata +282 -0
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ # Column-level visibility control concern.
5
+ # Mirrors the Laravel HidableColumns trait.
6
+ #
7
+ # Base hidden columns: password, remember_token, created_at, updated_at,
8
+ # deleted_at, discarded_at, email_verified_at
9
+ #
10
+ # Usage:
11
+ # class User < ApplicationRecord
12
+ # include Rhino::HidableColumns
13
+ #
14
+ # rhino_additional_hidden :secret_field, :internal_notes
15
+ # end
16
+ #
17
+ # Adding computed attributes to JSON responses:
18
+ # class Comment < Rhino::RhinoModel
19
+ # def author_name
20
+ # user&.name || 'Anonymous'
21
+ # end
22
+ #
23
+ # def rhino_computed_attributes
24
+ # {
25
+ # 'author_name' => author_name
26
+ # }
27
+ # end
28
+ # end
29
+ #
30
+ # Policy-based hiding:
31
+ # class UserPolicy < Rhino::ResourcePolicy
32
+ # def hidden_attributes_for_show(user)
33
+ # has_role?(user, 'admin') ? [] : ['email', 'phone']
34
+ # end
35
+ #
36
+ # def permitted_attributes_for_show(user)
37
+ # has_role?(user, 'admin') ? ['*'] : ['id', 'name', 'avatar']
38
+ # end
39
+ # end
40
+ module HidableColumns
41
+ extend ActiveSupport::Concern
42
+
43
+ BASE_HIDDEN_COLUMNS = %w[
44
+ password
45
+ password_digest
46
+ remember_token
47
+ created_at
48
+ updated_at
49
+ deleted_at
50
+ discarded_at
51
+ email_verified_at
52
+ ].freeze
53
+
54
+ included do
55
+ class_attribute :additional_hidden_columns, default: []
56
+ end
57
+
58
+ class_methods do
59
+ def rhino_additional_hidden(*columns)
60
+ self.additional_hidden_columns = columns.map(&:to_s)
61
+ end
62
+ end
63
+
64
+ # Get the list of columns to hide for the current user.
65
+ # Merges base + static + policy-defined hidden columns.
66
+ # Resolves the user from RequestStore automatically.
67
+ #
68
+ # @return [Array<String>] Column names to hide
69
+ def hidden_columns_for(user = nil)
70
+ user ||= rhino_current_user
71
+ columns = BASE_HIDDEN_COLUMNS.dup
72
+ columns.concat(additional_hidden_columns)
73
+ columns.concat(policy_hidden_columns(user))
74
+ columns.uniq
75
+ end
76
+
77
+ # Serialize to JSON excluding hidden columns and respecting policy whitelist.
78
+ #
79
+ # The current user is resolved automatically from RequestStore. Policy
80
+ # filtering (blacklist + whitelist) is applied AFTER computed attributes
81
+ # are merged, so computed attributes are always subject to policy control.
82
+ #
83
+ # Do NOT override this method. Override +rhino_computed_attributes+ instead
84
+ # to add computed/virtual attributes to the JSON response.
85
+ #
86
+ # @return [Hash]
87
+ def as_rhino_json
88
+ user = rhino_current_user
89
+ hidden = hidden_columns_for(user)
90
+ result = as_json(except: hidden)
91
+
92
+ # Merge computed attributes from model BEFORE applying policy filtering
93
+ computed = rhino_computed_attributes
94
+ result.merge!(computed) if computed.is_a?(Hash) && computed.any?
95
+
96
+ # Apply blacklist to the final hash (covers DB columns from as_json
97
+ # overrides AND computed attributes from rhino_computed_attributes)
98
+ hidden_set = Set.new(hidden)
99
+ result.reject! { |key, _| hidden_set.include?(key) }
100
+
101
+ # Apply whitelist to the final hash (covers computed attributes too)
102
+ permitted = policy_permitted_attributes(user)
103
+ if permitted && permitted != ['*']
104
+ permitted_set = Set.new(permitted.map(&:to_s))
105
+ permitted_set.add('id') # id is always allowed
106
+ result.select! { |key, _| permitted_set.include?(key) }
107
+ end
108
+
109
+ result
110
+ end
111
+
112
+ # Override this method in your model to add computed/virtual attributes
113
+ # to the JSON response. These attributes are subject to policy-level
114
+ # blacklist (+hidden_attributes_for_show+) and whitelist
115
+ # (+permitted_attributes_for_show+) just like database columns.
116
+ #
117
+ # @example
118
+ # def rhino_computed_attributes
119
+ # {
120
+ # 'full_name' => "#{first_name} #{last_name}",
121
+ # 'is_overdue' => due_date&.past?,
122
+ # 'days_until_expiry' => expiry_date ? (expiry_date - Date.current).to_i : nil
123
+ # }
124
+ # end
125
+ #
126
+ # @return [Hash] key-value pairs to merge into the JSON response
127
+ def rhino_computed_attributes
128
+ {}
129
+ end
130
+
131
+ private
132
+
133
+ # Resolves the current user from RequestStore.
134
+ # @return [Object, nil]
135
+ def rhino_current_user
136
+ RequestStore.store[:rhino_current_user] if defined?(RequestStore)
137
+ end
138
+
139
+ # Returns the permitted attributes list from the policy, or nil if no policy.
140
+ def policy_permitted_attributes(user)
141
+ policy_class = Pundit::PolicyFinder.new(self).policy
142
+ return nil unless policy_class
143
+
144
+ policy = policy_class.new(user, self)
145
+ if policy.respond_to?(:permitted_attributes_for_show)
146
+ policy.permitted_attributes_for_show(user)
147
+ end
148
+ rescue StandardError
149
+ nil
150
+ end
151
+
152
+ def policy_hidden_columns(user)
153
+ policy_class = Pundit::PolicyFinder.new(self).policy
154
+ return [] unless policy_class
155
+
156
+ policy = policy_class.new(user, self)
157
+ hidden = []
158
+
159
+ # Blacklist: hidden_attributes_for_show
160
+ if policy.respond_to?(:hidden_attributes_for_show)
161
+ hidden.concat(policy.hidden_attributes_for_show(user))
162
+ end
163
+
164
+ # Whitelist: permitted_attributes_for_show
165
+ # Hide DB columns not in permitted list (computed attributes handled in as_rhino_json)
166
+ if policy.respond_to?(:permitted_attributes_for_show)
167
+ permitted = policy.permitted_attributes_for_show(user)
168
+ if permitted != ['*']
169
+ all_columns = self.class.column_names
170
+ not_permitted = all_columns - permitted.map(&:to_s)
171
+ hidden.concat(not_permitted)
172
+ end
173
+ end
174
+
175
+ hidden
176
+ rescue StandardError
177
+ []
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ class Configuration
5
+ attr_accessor :models, :route_groups, :multi_tenant, :invitations, :nested, :test_framework,
6
+ :client_path, :mobile_path
7
+
8
+ def initialize
9
+ @models = {}
10
+ @route_groups = {}
11
+ @multi_tenant = {
12
+ organization_identifier_column: "id"
13
+ }
14
+ @invitations = {
15
+ expires_days: 7,
16
+ allowed_roles: nil
17
+ }
18
+ @nested = {
19
+ path: "nested",
20
+ max_operations: 50,
21
+ allowed_models: nil
22
+ }
23
+ @test_framework = "rspec"
24
+ @client_path = nil
25
+ @mobile_path = nil
26
+ end
27
+
28
+ # Register a model with its slug
29
+ # Usage: config.model :posts, 'Post'
30
+ def model(slug, klass_name)
31
+ @models[slug.to_sym] = klass_name.to_s
32
+ end
33
+
34
+ # Register a route group with its configuration
35
+ # Usage: config.route_group :tenant, prefix: ':organization', middleware: [Rhino::Middleware::ResolveOrganizationFromRoute], models: :all
36
+ def route_group(name, prefix: "", middleware: [], models: :all)
37
+ @route_groups[name.to_sym] = {
38
+ prefix: prefix.to_s,
39
+ middleware: Array(middleware),
40
+ models: models
41
+ }
42
+ end
43
+
44
+ # Resolve a model class from its slug
45
+ def resolve_model(slug)
46
+ klass_name = @models[slug.to_sym]
47
+ raise ActiveRecord::RecordNotFound, "The #{slug} model does not exist" unless klass_name
48
+
49
+ klass = klass_name.constantize
50
+ raise ActiveRecord::RecordNotFound, "The #{slug} model does not exist" unless klass
51
+
52
+ klass
53
+ rescue NameError
54
+ raise ActiveRecord::RecordNotFound, "The #{slug} model does not exist"
55
+ end
56
+
57
+ # Find the slug for a given model class
58
+ def slug_for(model_class)
59
+ class_name = model_class.is_a?(Class) ? model_class.name : model_class.class.name
60
+ @models.each do |slug, klass_name|
61
+ return slug if klass_name == class_name
62
+ end
63
+ nil
64
+ end
65
+
66
+ # Whether a 'tenant' route group is configured
67
+ def has_tenant_group?
68
+ @route_groups.key?(:tenant)
69
+ end
70
+
71
+ # Whether a 'public' route group is configured
72
+ def has_public_group?
73
+ @route_groups.key?(:public)
74
+ end
75
+
76
+ # Resolve the model slugs for a given route group
77
+ def models_for_group(group_name)
78
+ group = @route_groups[group_name.to_sym]
79
+ return [] unless group
80
+
81
+ group_models = group[:models]
82
+ if group_models == :all || group_models == "*"
83
+ @models.keys
84
+ else
85
+ Array(group_models).map(&:to_sym) & @models.keys
86
+ end
87
+ end
88
+
89
+ # Check if a model belongs to the 'public' route group
90
+ def public_model?(slug)
91
+ return false unless has_public_group?
92
+
93
+ models_for_group(:public).include?(slug.to_sym)
94
+ end
95
+
96
+ # Check if a specific slug belongs to a specific group
97
+ def model_in_group?(slug, group_name)
98
+ models_for_group(group_name).include?(slug.to_sym)
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ # Authentication controller — mirrors Laravel AuthController exactly.
5
+ #
6
+ # Endpoints:
7
+ # POST /api/auth/login
8
+ # POST /api/auth/logout
9
+ # POST /api/auth/password/recover
10
+ # POST /api/auth/password/reset
11
+ # POST /api/auth/register
12
+ class AuthController < ActionController::API
13
+ before_action :authenticate_user!, only: [:logout]
14
+
15
+ # POST /api/auth/login
16
+ def login
17
+ email = params[:email].to_s.strip
18
+ password = params[:password].to_s
19
+
20
+ if email.blank? || password.blank?
21
+ return render json: { message: "Invalid credentials" }, status: :unauthorized
22
+ end
23
+
24
+ user_class = "User".safe_constantize
25
+ return render json: { message: "Invalid credentials" }, status: :unauthorized unless user_class
26
+
27
+ user = user_class.find_by(email: email)
28
+
29
+ unless user&.authenticate(password)
30
+ return render json: { message: "Invalid credentials" }, status: :unauthorized
31
+ end
32
+
33
+ token = generate_api_token(user)
34
+
35
+ # Get the first organization the user belongs to
36
+ organization_slug = nil
37
+ if user.respond_to?(:organizations)
38
+ first_org = user.organizations.first
39
+ organization_slug = first_org&.slug
40
+ end
41
+
42
+ render json: {
43
+ token: token,
44
+ organization_slug: organization_slug
45
+ }, status: :ok
46
+ end
47
+
48
+ # POST /api/auth/logout
49
+ def logout
50
+ user = current_user
51
+
52
+ if user.respond_to?(:regenerate_api_token)
53
+ user.regenerate_api_token
54
+ elsif user.respond_to?(:update_column) && user.class.column_names.include?("api_token")
55
+ user.update_column(:api_token, SecureRandom.hex(32))
56
+ end
57
+
58
+ render json: { message: "Logged out successfully" }, status: :ok
59
+ end
60
+
61
+ # POST /api/auth/password/recover
62
+ def recover_password
63
+ email = params[:email].to_s.strip
64
+
65
+ if email.blank?
66
+ return render json: { errors: { email: ["The email field is required."] } }, status: :unprocessable_entity
67
+ end
68
+
69
+ user_class = "User".safe_constantize
70
+ user = user_class&.find_by(email: email)
71
+
72
+ if user
73
+ token = SecureRandom.hex(32)
74
+
75
+ # Store reset token
76
+ if user.respond_to?(:update_columns)
77
+ user.update_columns(
78
+ reset_password_token: token,
79
+ reset_password_sent_at: Time.current
80
+ )
81
+ end
82
+
83
+ # Send email via mailer if available
84
+ mailer_class = "Rhino::PasswordRecoveryMailer".safe_constantize
85
+ mailer_class&.recover(user, token)&.deliver_later
86
+ end
87
+
88
+ # Always return success to prevent email enumeration
89
+ render json: { message: "Password recovery email sent." }, status: :ok
90
+ end
91
+
92
+ # POST /api/auth/password/reset
93
+ def reset
94
+ errors = {}
95
+ errors[:token] = ["The token field is required."] if params[:token].blank?
96
+ errors[:email] = ["The email field is required."] if params[:email].blank?
97
+ errors[:password] = ["The password field is required."] if params[:password].blank?
98
+
99
+ if params[:password].present? && params[:password].length < 8
100
+ errors[:password] = ["The password must be at least 8 characters."]
101
+ end
102
+
103
+ if params[:password].present? && params[:password] != params[:password_confirmation]
104
+ errors[:password_confirmation] = ["The password confirmation does not match."]
105
+ end
106
+
107
+ unless errors.empty?
108
+ return render json: { errors: errors }, status: :unprocessable_entity
109
+ end
110
+
111
+ user_class = "User".safe_constantize
112
+ user = user_class&.find_by(email: params[:email])
113
+
114
+ unless user
115
+ return render json: { message: "Token is invalid or expired." }, status: :bad_request
116
+ end
117
+
118
+ # Verify token
119
+ valid_token = user.respond_to?(:reset_password_token) &&
120
+ user.reset_password_token == params[:token] &&
121
+ user.respond_to?(:reset_password_sent_at) &&
122
+ user.reset_password_sent_at.present? &&
123
+ user.reset_password_sent_at > 1.hour.ago
124
+
125
+ unless valid_token
126
+ return render json: { message: "Token is invalid or expired." }, status: :bad_request
127
+ end
128
+
129
+ # Update password
130
+ user.password = params[:password]
131
+ user.reset_password_token = nil
132
+ user.reset_password_sent_at = nil
133
+ user.save!
134
+
135
+ render json: { message: "Password has been reset." }, status: :ok
136
+ end
137
+
138
+ # POST /api/auth/register
139
+ def register_with_invitation
140
+ errors = {}
141
+ errors[:token] = ["The token field is required."] if params[:token].blank?
142
+ errors[:name] = ["The name field is required."] if params[:name].blank?
143
+ errors[:email] = ["The email field is required."] if params[:email].blank?
144
+ errors[:password] = ["The password field is required."] if params[:password].blank?
145
+
146
+ if params[:password].present? && params[:password].length < 8
147
+ errors[:password] = ["The password must be at least 8 characters."]
148
+ end
149
+
150
+ if params[:password].present? && params[:password] != params[:password_confirmation]
151
+ errors[:password_confirmation] = ["The password confirmation does not match."]
152
+ end
153
+
154
+ user_class = "User".safe_constantize
155
+ if user_class && params[:email].present? && user_class.exists?(email: params[:email])
156
+ errors[:email] = ["The email has already been taken."]
157
+ end
158
+
159
+ unless errors.empty?
160
+ return render json: { errors: errors }, status: :unprocessable_entity
161
+ end
162
+
163
+ # Find invitation
164
+ invitation = OrganizationInvitation.find_by(token: params[:token], status: "pending")
165
+
166
+ unless invitation
167
+ return render json: { message: "Invalid or expired invitation token" }, status: :not_found
168
+ end
169
+
170
+ if invitation.expired?
171
+ invitation.update!(status: "expired")
172
+ return render json: { message: "This invitation has expired" }, status: :unprocessable_entity
173
+ end
174
+
175
+ # Validate email matches invitation
176
+ unless invitation.email == params[:email]
177
+ return render json: { message: "Email does not match the invitation" }, status: :unprocessable_entity
178
+ end
179
+
180
+ # Create user
181
+ user = user_class.create!(
182
+ name: params[:name],
183
+ email: params[:email],
184
+ password: params[:password]
185
+ )
186
+
187
+ # Accept invitation (adds user to organization)
188
+ invitation.accept!(user)
189
+
190
+ # Generate token
191
+ token = generate_api_token(user)
192
+
193
+ # Get organization slug for redirect
194
+ organization = invitation.organization
195
+ organization_slug = organization&.slug
196
+
197
+ render json: {
198
+ message: "Registration successful",
199
+ token: token,
200
+ user: user.as_json(except: %w[password_digest api_token reset_password_token]),
201
+ organization_slug: organization_slug
202
+ }, status: :created
203
+ end
204
+
205
+ private
206
+
207
+ def authenticate_user!
208
+ unless current_user
209
+ render json: { message: "Unauthenticated." }, status: :unauthorized
210
+ end
211
+ end
212
+
213
+ def current_user
214
+ @current_user ||= begin
215
+ token = request.headers["Authorization"]&.sub(/\ABearer /, "")
216
+ return nil unless token
217
+
218
+ user_class = "User".safe_constantize
219
+ return nil unless user_class
220
+
221
+ if user_class.respond_to?(:find_by_api_token)
222
+ user_class.find_by_api_token(token)
223
+ elsif user_class.column_names.include?("api_token")
224
+ user_class.find_by(api_token: token)
225
+ end
226
+ end
227
+ end
228
+
229
+ def generate_api_token(user)
230
+ if user.respond_to?(:regenerate_api_token)
231
+ user.regenerate_api_token
232
+ user.api_token
233
+ elsif user.class.column_names.include?("api_token")
234
+ token = SecureRandom.hex(32)
235
+ user.update_column(:api_token, token)
236
+ token
237
+ else
238
+ SecureRandom.hex(32)
239
+ end
240
+ end
241
+ end
242
+ end