agentcode 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.
- checksums.yaml +7 -0
- data/README.md +59 -0
- data/lib/agentcode/blueprint/blueprint_parser.rb +198 -0
- data/lib/agentcode/blueprint/blueprint_validator.rb +209 -0
- data/lib/agentcode/blueprint/generators/factory_generator.rb +74 -0
- data/lib/agentcode/blueprint/generators/policy_generator.rb +154 -0
- data/lib/agentcode/blueprint/generators/seeder_generator.rb +160 -0
- data/lib/agentcode/blueprint/generators/test_generator.rb +291 -0
- data/lib/agentcode/blueprint/manifest_manager.rb +81 -0
- data/lib/agentcode/commands/base_command.rb +57 -0
- data/lib/agentcode/commands/blueprint_command.rb +549 -0
- data/lib/agentcode/commands/export_postman_command.rb +328 -0
- data/lib/agentcode/commands/generate_command.rb +563 -0
- data/lib/agentcode/commands/install_command.rb +441 -0
- data/lib/agentcode/commands/invitation_link_command.rb +107 -0
- data/lib/agentcode/concerns/belongs_to_organization.rb +49 -0
- data/lib/agentcode/concerns/has_agentcode.rb +93 -0
- data/lib/agentcode/concerns/has_audit_trail.rb +125 -0
- data/lib/agentcode/concerns/has_auto_scope.rb +91 -0
- data/lib/agentcode/concerns/has_permissions.rb +117 -0
- data/lib/agentcode/concerns/has_uuid.rb +26 -0
- data/lib/agentcode/concerns/has_validation.rb +250 -0
- data/lib/agentcode/concerns/hidable_columns.rb +180 -0
- data/lib/agentcode/configuration.rb +98 -0
- data/lib/agentcode/controllers/auth_controller.rb +242 -0
- data/lib/agentcode/controllers/invitations_controller.rb +231 -0
- data/lib/agentcode/controllers/resources_controller.rb +813 -0
- data/lib/agentcode/engine.rb +65 -0
- data/lib/agentcode/mailers/invitation_mailer.rb +22 -0
- data/lib/agentcode/middleware/resolve_organization_from_route.rb +72 -0
- data/lib/agentcode/models/agentcode_model.rb +387 -0
- data/lib/agentcode/models/audit_log.rb +17 -0
- data/lib/agentcode/models/organization_invitation.rb +57 -0
- data/lib/agentcode/policies/invitation_policy.rb +54 -0
- data/lib/agentcode/policies/resource_policy.rb +197 -0
- data/lib/agentcode/query_builder.rb +278 -0
- data/lib/agentcode/railtie.rb +11 -0
- data/lib/agentcode/resource_scope.rb +59 -0
- data/lib/agentcode/routes.rb +124 -0
- data/lib/agentcode/tasks/agentcode.rake +39 -0
- data/lib/agentcode/templates/agentcode.rb +71 -0
- data/lib/agentcode/templates/agentcode_model.rb +104 -0
- data/lib/agentcode/templates/audit_trail/create_audit_logs.rb.erb +26 -0
- data/lib/agentcode/templates/generate/factory.rb.erb +43 -0
- data/lib/agentcode/templates/generate/migration.rb.erb +26 -0
- data/lib/agentcode/templates/generate/model.rb.erb +55 -0
- data/lib/agentcode/templates/generate/policy.rb.erb +52 -0
- data/lib/agentcode/templates/generate/scope.rb.erb +31 -0
- data/lib/agentcode/templates/multi_tenant/factories/organizations.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/factories/roles.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/factories/user_roles.rb.erb +10 -0
- data/lib/agentcode/templates/multi_tenant/factories/users.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_organizations.rb.erb +15 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_roles.rb.erb +15 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_user_roles.rb.erb +16 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_users.rb.erb +15 -0
- data/lib/agentcode/templates/multi_tenant/models/organization.rb.erb +18 -0
- data/lib/agentcode/templates/multi_tenant/models/role.rb.erb +11 -0
- data/lib/agentcode/templates/multi_tenant/models/user.rb.erb +14 -0
- data/lib/agentcode/templates/multi_tenant/models/user_role.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/policies/organization_policy.rb.erb +6 -0
- data/lib/agentcode/templates/multi_tenant/policies/role_policy.rb.erb +6 -0
- data/lib/agentcode/templates/multi_tenant/seeders/organization_seeder.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/seeders/role_seeder.rb.erb +19 -0
- data/lib/agentcode/templates/routes.rb +13 -0
- data/lib/agentcode/version.rb +5 -0
- data/lib/agentcode/views/lumina/invitation_mailer/invite.html.erb +29 -0
- data/lib/agentcode-rails.rb +3 -0
- data/lib/agentcode.rb +26 -0
- metadata +281 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentCode
|
|
4
|
+
# Automatic change logging concern.
|
|
5
|
+
# Mirrors the Laravel HasAuditTrail trait.
|
|
6
|
+
#
|
|
7
|
+
# Tracks: created, updated, deleted, force_deleted, restored
|
|
8
|
+
# Records: old/new values, user_id, organization_id, ip_address, user_agent
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# class Post < ApplicationRecord
|
|
12
|
+
# include AgentCode::HasAuditTrail
|
|
13
|
+
#
|
|
14
|
+
# # Optional: exclude sensitive fields from audit logging
|
|
15
|
+
# agentcode_audit_exclude :password, :remember_token
|
|
16
|
+
# end
|
|
17
|
+
module HasAuditTrail
|
|
18
|
+
extend ActiveSupport::Concern
|
|
19
|
+
|
|
20
|
+
included do
|
|
21
|
+
class_attribute :audit_exclude_fields, default: %w[password remember_token]
|
|
22
|
+
|
|
23
|
+
has_many :audit_logs, -> { order(created_at: :desc) },
|
|
24
|
+
as: :auditable,
|
|
25
|
+
class_name: "AgentCode::AuditLog",
|
|
26
|
+
dependent: :destroy
|
|
27
|
+
|
|
28
|
+
after_create :log_audit_created
|
|
29
|
+
after_update :log_audit_updated
|
|
30
|
+
after_destroy :log_audit_deleted
|
|
31
|
+
|
|
32
|
+
# For soft deletes restoration
|
|
33
|
+
if respond_to?(:after_undiscard)
|
|
34
|
+
after_undiscard :log_audit_restored
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class_methods do
|
|
39
|
+
def agentcode_audit_exclude(*fields)
|
|
40
|
+
self.audit_exclude_fields = fields.map(&:to_s)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def log_audit_created
|
|
47
|
+
log_audit("created", nil, auditable_attributes)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def log_audit_updated
|
|
51
|
+
changes = saved_changes.except("updated_at")
|
|
52
|
+
return if changes.blank?
|
|
53
|
+
|
|
54
|
+
old_values = {}
|
|
55
|
+
new_values = {}
|
|
56
|
+
|
|
57
|
+
changes.each do |field, (old_val, new_val)|
|
|
58
|
+
next if audit_exclude_fields.include?(field)
|
|
59
|
+
old_values[field] = old_val
|
|
60
|
+
new_values[field] = new_val
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
return if new_values.blank?
|
|
64
|
+
|
|
65
|
+
log_audit("updated", old_values, new_values)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def log_audit_deleted
|
|
69
|
+
action = respond_to?(:discarded?) && discarded? ? "deleted" : "force_deleted"
|
|
70
|
+
log_audit(action, auditable_attributes, nil)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def log_audit_restored
|
|
74
|
+
log_audit("restored", nil, auditable_attributes)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def log_audit(action, old_values, new_values)
|
|
78
|
+
return unless audit_log_table_exists?
|
|
79
|
+
|
|
80
|
+
attributes = {
|
|
81
|
+
auditable: self,
|
|
82
|
+
action: action,
|
|
83
|
+
old_values: old_values,
|
|
84
|
+
new_values: new_values,
|
|
85
|
+
user_id: current_audit_user_id,
|
|
86
|
+
ip_address: current_audit_ip_address,
|
|
87
|
+
user_agent: current_audit_user_agent
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Add organization_id if available
|
|
91
|
+
org = current_audit_organization
|
|
92
|
+
attributes[:organization_id] = org.id if org
|
|
93
|
+
|
|
94
|
+
AgentCode::AuditLog.create!(attributes)
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
Rails.logger.warn("AgentCode::HasAuditTrail: Failed to log audit: #{e.message}")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def auditable_attributes
|
|
100
|
+
attributes.except(*audit_exclude_fields)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def current_audit_user_id
|
|
104
|
+
RequestStore.store[:agentcode_current_user]&.id if defined?(RequestStore)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def current_audit_ip_address
|
|
108
|
+
RequestStore.store[:agentcode_ip_address] if defined?(RequestStore)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def current_audit_user_agent
|
|
112
|
+
RequestStore.store[:agentcode_user_agent] if defined?(RequestStore)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def current_audit_organization
|
|
116
|
+
RequestStore.store[:agentcode_organization] if defined?(RequestStore)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def audit_log_table_exists?
|
|
120
|
+
@_audit_log_table_exists ||= ActiveRecord::Base.connection.table_exists?("audit_logs")
|
|
121
|
+
rescue StandardError
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentCode
|
|
4
|
+
# Auto-detect and apply global scopes by convention.
|
|
5
|
+
# Mirrors the Laravel HasAutoScope trait.
|
|
6
|
+
#
|
|
7
|
+
# Looks for a scope class at `Scopes::{ModelName}Scope`
|
|
8
|
+
# (e.g., `Scopes::PostScope` for `Post` model).
|
|
9
|
+
#
|
|
10
|
+
# The scope class can either:
|
|
11
|
+
#
|
|
12
|
+
# 1. Extend +AgentCode::ResourceScope+ (recommended) — provides access to
|
|
13
|
+
# +user+, +organization+, and +role+ inside the +apply+ instance method:
|
|
14
|
+
#
|
|
15
|
+
# module Scopes
|
|
16
|
+
# class PostScope < AgentCode::ResourceScope
|
|
17
|
+
# def apply(relation)
|
|
18
|
+
# if role == "viewer"
|
|
19
|
+
# relation.where(published: true)
|
|
20
|
+
# else
|
|
21
|
+
# relation
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# 2. Implement +self.apply(relation)+ as a class method (legacy/simple):
|
|
28
|
+
#
|
|
29
|
+
# module Scopes
|
|
30
|
+
# class PostScope
|
|
31
|
+
# def self.apply(relation)
|
|
32
|
+
# relation.where(active: true)
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
module HasAutoScope
|
|
38
|
+
extend ActiveSupport::Concern
|
|
39
|
+
|
|
40
|
+
included do
|
|
41
|
+
default_scope lambda {
|
|
42
|
+
model = is_a?(ActiveRecord::Relation) ? self.klass : self
|
|
43
|
+
if model.respond_to?(:agentcode_auto_scope_class)
|
|
44
|
+
scope_class = model.agentcode_auto_scope_class
|
|
45
|
+
if scope_class
|
|
46
|
+
model.apply_agentcode_scope(scope_class, where(nil))
|
|
47
|
+
else
|
|
48
|
+
where(nil)
|
|
49
|
+
end
|
|
50
|
+
else
|
|
51
|
+
where(nil)
|
|
52
|
+
end
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class_methods do
|
|
57
|
+
def agentcode_auto_scope_class
|
|
58
|
+
return @agentcode_auto_scope_class if instance_variable_defined?(:@agentcode_auto_scope_class)
|
|
59
|
+
|
|
60
|
+
result = find_auto_scope_class
|
|
61
|
+
# Only cache non-nil results to avoid permanently caching nil
|
|
62
|
+
# when the scope class hasn't been autoloaded yet (Zeitwerk)
|
|
63
|
+
@agentcode_auto_scope_class = result if result
|
|
64
|
+
result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Apply the scope class to a relation.
|
|
68
|
+
# Supports both ResourceScope subclasses (instance method) and
|
|
69
|
+
# plain classes with self.apply (class method).
|
|
70
|
+
def apply_agentcode_scope(scope_class, relation)
|
|
71
|
+
if scope_class < AgentCode::ResourceScope
|
|
72
|
+
scope_class.new.apply(relation)
|
|
73
|
+
elsif scope_class.respond_to?(:apply)
|
|
74
|
+
scope_class.apply(relation)
|
|
75
|
+
else
|
|
76
|
+
relation
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def find_auto_scope_class
|
|
83
|
+
return nil if name.nil?
|
|
84
|
+
|
|
85
|
+
model_name = name.demodulize
|
|
86
|
+
"Scopes::#{model_name}Scope".safe_constantize ||
|
|
87
|
+
"ModelScopes::#{model_name}Scope".safe_constantize
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentCode
|
|
4
|
+
# Permission checking concern for the User model.
|
|
5
|
+
# Mirrors the Laravel HasPermissions trait.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# class User < ApplicationRecord
|
|
9
|
+
# include AgentCode::HasPermissions
|
|
10
|
+
#
|
|
11
|
+
# has_many :user_roles
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Permission format: '{slug}.{action}' (e.g., 'posts.index', 'blogs.store')
|
|
15
|
+
# Wildcard support:
|
|
16
|
+
# - '*' grants access to everything
|
|
17
|
+
# - 'posts.*' grants access to all actions on posts
|
|
18
|
+
#
|
|
19
|
+
# Two permission sources:
|
|
20
|
+
# - users.permissions: used for non-tenant route groups (no organization context).
|
|
21
|
+
# Stored as a JSON array directly on the user model.
|
|
22
|
+
# - role.permissions (via user_roles): used for tenant route groups (organization context present).
|
|
23
|
+
# Resolved per-organization via the user_roles → role association.
|
|
24
|
+
#
|
|
25
|
+
# Resolution:
|
|
26
|
+
# 1. When an organization is provided (tenant route group) → checks role.permissions
|
|
27
|
+
# for that specific organization via user_roles.
|
|
28
|
+
# 2. When no organization is provided (non-tenant route group) → checks users.permissions
|
|
29
|
+
# directly on the user model.
|
|
30
|
+
module HasPermissions
|
|
31
|
+
extend ActiveSupport::Concern
|
|
32
|
+
|
|
33
|
+
# Check if the user has a specific permission.
|
|
34
|
+
#
|
|
35
|
+
# @param permission [String] Permission string like 'posts.index'
|
|
36
|
+
# @param organization [Object, nil] Organization to check permissions for
|
|
37
|
+
# @return [Boolean]
|
|
38
|
+
def has_permission?(permission, organization = nil)
|
|
39
|
+
return false if permission.blank?
|
|
40
|
+
|
|
41
|
+
if organization
|
|
42
|
+
# Tenant route group: check permissions for this organization
|
|
43
|
+
user_role = find_user_role(organization)
|
|
44
|
+
|
|
45
|
+
if user_role
|
|
46
|
+
# Check user_role.permissions first (per-user-org permissions on the pivot)
|
|
47
|
+
ur_permissions = parse_permissions(user_role.respond_to?(:permissions) ? user_role.permissions : nil)
|
|
48
|
+
if ur_permissions.present?
|
|
49
|
+
return matches_permission?(permission, ur_permissions)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Fallback to role.permissions (shared role-level permissions)
|
|
53
|
+
role = user_role.respond_to?(:role) ? user_role.role : nil
|
|
54
|
+
if role
|
|
55
|
+
role_permissions = parse_permissions(role.respond_to?(:permissions) ? role.permissions : nil)
|
|
56
|
+
return matches_permission?(permission, role_permissions) if role_permissions.present?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
return false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Non-tenant route group: check users.permissions directly
|
|
64
|
+
user_perms = parse_permissions(respond_to?(:permissions) ? self.permissions : nil)
|
|
65
|
+
matches_permission?(permission, user_perms)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Get the role slug for validation purposes.
|
|
69
|
+
#
|
|
70
|
+
# @param organization [Object, nil] Organization context
|
|
71
|
+
# @return [String, nil] Role slug or nil
|
|
72
|
+
def role_slug_for_validation(organization = nil)
|
|
73
|
+
user_role = find_user_role(organization)
|
|
74
|
+
return nil unless user_role
|
|
75
|
+
|
|
76
|
+
role = user_role.respond_to?(:role) ? user_role.role : nil
|
|
77
|
+
return nil unless role
|
|
78
|
+
|
|
79
|
+
role.respond_to?(:slug) ? role.slug : nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def matches_permission?(permission, granted_permissions)
|
|
85
|
+
return true if granted_permissions.include?(permission)
|
|
86
|
+
return true if granted_permissions.include?("*")
|
|
87
|
+
|
|
88
|
+
resource = permission.split(".").first
|
|
89
|
+
return true if granted_permissions.include?("#{resource}.*")
|
|
90
|
+
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def parse_permissions(perms)
|
|
95
|
+
return [] if perms.blank?
|
|
96
|
+
|
|
97
|
+
if perms.is_a?(String)
|
|
98
|
+
begin
|
|
99
|
+
JSON.parse(perms)
|
|
100
|
+
rescue JSON::ParserError
|
|
101
|
+
[]
|
|
102
|
+
end
|
|
103
|
+
elsif perms.is_a?(Array)
|
|
104
|
+
perms
|
|
105
|
+
else
|
|
106
|
+
[]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def find_user_role(organization)
|
|
111
|
+
return nil unless respond_to?(:user_roles)
|
|
112
|
+
return nil unless organization
|
|
113
|
+
|
|
114
|
+
user_roles.find_by(organization_id: organization.id)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentCode
|
|
4
|
+
# Auto-generate UUID on model creation.
|
|
5
|
+
# Mirrors the Laravel HasUuid trait.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# class Post < ApplicationRecord
|
|
9
|
+
# include AgentCode::HasUuid
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# Requires a `uuid` column in the migration.
|
|
13
|
+
module HasUuid
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
included do
|
|
17
|
+
before_create :generate_uuid
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def generate_uuid
|
|
23
|
+
self.uuid ||= SecureRandom.uuid if respond_to?(:uuid=)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentCode
|
|
4
|
+
# Format validation concern for models.
|
|
5
|
+
#
|
|
6
|
+
# This concern runs ActiveModel validations on request data before
|
|
7
|
+
# it reaches the database. Field permissions (which fields each role
|
|
8
|
+
# can write) are controlled by the policy, not the model.
|
|
9
|
+
#
|
|
10
|
+
# Also provides cross-tenant FK validation: any belongs_to FK in the
|
|
11
|
+
# submitted data is checked to ensure the referenced record belongs
|
|
12
|
+
# to the current organization (directly or via FK chain).
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# class Post < ApplicationRecord
|
|
16
|
+
# include AgentCode::HasValidation
|
|
17
|
+
#
|
|
18
|
+
# # Standard Rails validations for type/format (use allow_nil: true)
|
|
19
|
+
# validates :title, length: { maximum: 255 }, allow_nil: true
|
|
20
|
+
# validates :status, inclusion: { in: %w[draft published] }, allow_nil: true
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# Field permissions are defined on the policy:
|
|
24
|
+
# class PostPolicy < AgentCode::ResourcePolicy
|
|
25
|
+
# def permitted_attributes_for_create(user)
|
|
26
|
+
# has_role?(user, 'admin') ? ['*'] : ['title', 'content']
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
module HasValidation
|
|
30
|
+
extend ActiveSupport::Concern
|
|
31
|
+
|
|
32
|
+
# Validate data for a given action.
|
|
33
|
+
# Filters to only permitted fields, then runs ActiveModel validations
|
|
34
|
+
# and cross-tenant FK validation.
|
|
35
|
+
#
|
|
36
|
+
# @param params [Hash] The request data
|
|
37
|
+
# @param permitted_fields [Array<String>] Fields the user is allowed to set (['*'] for all)
|
|
38
|
+
# @param organization [Object, nil] Current organization for FK scoping (optional)
|
|
39
|
+
# @return [Hash] { valid: Boolean, errors: Hash, validated: Hash }
|
|
40
|
+
def validate_for_action(params, permitted_fields:, organization: nil)
|
|
41
|
+
# Filter to only permitted fields
|
|
42
|
+
if permitted_fields == ['*']
|
|
43
|
+
filtered = params.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
44
|
+
else
|
|
45
|
+
permitted = permitted_fields.map(&:to_s)
|
|
46
|
+
filtered = params.each_with_object({}) do |(k, v), h|
|
|
47
|
+
h[k.to_s] = v if permitted.include?(k.to_s)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Remove organization_id from validated data — managed by framework
|
|
52
|
+
filtered.delete("organization_id") if organization
|
|
53
|
+
|
|
54
|
+
# Run ActiveModel validations on a temp instance
|
|
55
|
+
temp = self.class.new
|
|
56
|
+
safe_attrs = filtered.select { |k, _| temp.respond_to?("#{k}=") }
|
|
57
|
+
temp.assign_attributes(safe_attrs)
|
|
58
|
+
|
|
59
|
+
errors = {}
|
|
60
|
+
unless temp.valid?
|
|
61
|
+
temp.errors.each do |error|
|
|
62
|
+
field_name = error.attribute.to_s
|
|
63
|
+
if filtered.key?(field_name)
|
|
64
|
+
errors[field_name] ||= []
|
|
65
|
+
errors[field_name] << error.message
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Cross-tenant FK validation
|
|
71
|
+
if organization
|
|
72
|
+
fk_errors = validate_foreign_keys_for_organization(filtered, organization)
|
|
73
|
+
errors.merge!(fk_errors)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if errors.any?
|
|
77
|
+
{ valid: false, errors: errors, validated: {} }
|
|
78
|
+
else
|
|
79
|
+
{ valid: true, errors: {}, validated: filtered }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Cache for FK chain lookups (class-level)
|
|
86
|
+
@@fk_chain_cache = {}
|
|
87
|
+
@@org_column_cache = {}
|
|
88
|
+
|
|
89
|
+
# Validate that all FK references in the data belong to the current organization.
|
|
90
|
+
# Walks belongs_to associations on the model, checks if the referenced table
|
|
91
|
+
# is org-scoped (directly or via FK chain), and verifies the record exists
|
|
92
|
+
# within the org's scope.
|
|
93
|
+
def validate_foreign_keys_for_organization(data, organization)
|
|
94
|
+
errors = {}
|
|
95
|
+
org_id = organization.id
|
|
96
|
+
|
|
97
|
+
self.class.reflect_on_all_associations(:belongs_to).each do |assoc|
|
|
98
|
+
fk_column = assoc.foreign_key.to_s
|
|
99
|
+
next unless data.key?(fk_column)
|
|
100
|
+
next if data[fk_column].nil?
|
|
101
|
+
|
|
102
|
+
# Skip organization_id itself
|
|
103
|
+
next if fk_column == "organization_id"
|
|
104
|
+
|
|
105
|
+
begin
|
|
106
|
+
related_class = assoc.klass
|
|
107
|
+
related_table = related_class.table_name
|
|
108
|
+
rescue StandardError
|
|
109
|
+
next
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Direct: related table has organization_id
|
|
113
|
+
if table_has_organization_id?(related_table)
|
|
114
|
+
unless related_class.where(
|
|
115
|
+
related_class.primary_key => data[fk_column],
|
|
116
|
+
organization_id: org_id
|
|
117
|
+
).exists?
|
|
118
|
+
errors[fk_column] = ["does not belong to your organization"]
|
|
119
|
+
end
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Indirect: walk FK chain to find org-scoped ancestor
|
|
124
|
+
chain = find_organization_fk_chain(related_table)
|
|
125
|
+
next unless chain
|
|
126
|
+
|
|
127
|
+
unless record_belongs_to_organization?(related_table, related_class.primary_key, data[fk_column], org_id, chain)
|
|
128
|
+
errors[fk_column] = ["does not belong to your organization"]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
errors
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Check if a table has an organization_id column.
|
|
136
|
+
def table_has_organization_id?(table)
|
|
137
|
+
unless @@org_column_cache.key?(table)
|
|
138
|
+
@@org_column_cache[table] = ActiveRecord::Base.connection.column_exists?(table, :organization_id)
|
|
139
|
+
end
|
|
140
|
+
@@org_column_cache[table]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Find the FK chain from a table to an org-scoped ancestor.
|
|
144
|
+
# Returns array of steps or nil.
|
|
145
|
+
# Each step: { local_column:, foreign_table:, foreign_column: }
|
|
146
|
+
def find_organization_fk_chain(table)
|
|
147
|
+
return @@fk_chain_cache[table] if @@fk_chain_cache.key?(table)
|
|
148
|
+
|
|
149
|
+
chain = walk_fk_chain(table, 5, [])
|
|
150
|
+
@@fk_chain_cache[table] = chain
|
|
151
|
+
chain
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def walk_fk_chain(table, max_depth, visited)
|
|
155
|
+
return nil if max_depth <= 0 || visited.include?(table)
|
|
156
|
+
|
|
157
|
+
visited = visited + [table]
|
|
158
|
+
|
|
159
|
+
begin
|
|
160
|
+
foreign_keys = ActiveRecord::Base.connection.foreign_keys(table)
|
|
161
|
+
rescue StandardError
|
|
162
|
+
return nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
foreign_keys.each do |fk|
|
|
166
|
+
local_column = fk.column
|
|
167
|
+
foreign_table = fk.to_table
|
|
168
|
+
foreign_column = fk.primary_key || "id"
|
|
169
|
+
|
|
170
|
+
if table_has_organization_id?(foreign_table)
|
|
171
|
+
return [{ local_column: local_column, foreign_table: foreign_table, foreign_column: foreign_column }]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
deeper = walk_fk_chain(foreign_table, max_depth - 1, visited)
|
|
175
|
+
if deeper
|
|
176
|
+
deeper.unshift({ local_column: local_column, foreign_table: foreign_table, foreign_column: foreign_column })
|
|
177
|
+
return deeper
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Check if a specific record belongs to the organization via FK chain.
|
|
185
|
+
# Builds a SQL EXISTS query with nested subqueries.
|
|
186
|
+
def record_belongs_to_organization?(table, pk_column, pk_value, org_id, chain)
|
|
187
|
+
# Build from innermost (org-scoped table) outward
|
|
188
|
+
# The chain goes: table → chain[0].foreign_table → chain[1].foreign_table → ... → org-scoped table
|
|
189
|
+
# We need to verify: record in `table` with pk_value → chain walks → org_id matches
|
|
190
|
+
|
|
191
|
+
query = build_chain_exists_query(table, pk_column, pk_value, org_id, chain, 0)
|
|
192
|
+
ActiveRecord::Base.connection.select_value(query).present?
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def build_chain_exists_query(table, pk_column, pk_value, org_id, chain, index)
|
|
196
|
+
step = chain[index]
|
|
197
|
+
|
|
198
|
+
if index == chain.length - 1
|
|
199
|
+
# Last step: the foreign table has organization_id
|
|
200
|
+
sanitize_sql([
|
|
201
|
+
"SELECT 1 FROM #{quote_table(table)} " \
|
|
202
|
+
"WHERE #{quote_column(pk_column)} = ? " \
|
|
203
|
+
"AND #{quote_column(step[:local_column])} IN (" \
|
|
204
|
+
"SELECT #{quote_column(step[:foreign_column])} FROM #{quote_table(step[:foreign_table])} " \
|
|
205
|
+
"WHERE organization_id = ?)",
|
|
206
|
+
pk_value, org_id
|
|
207
|
+
])
|
|
208
|
+
else
|
|
209
|
+
# Intermediate step: recurse deeper
|
|
210
|
+
inner = build_inner_chain_query(step[:foreign_table], step[:foreign_column], org_id, chain, index + 1)
|
|
211
|
+
sanitize_sql([
|
|
212
|
+
"SELECT 1 FROM #{quote_table(table)} " \
|
|
213
|
+
"WHERE #{quote_column(pk_column)} = ? " \
|
|
214
|
+
"AND #{quote_column(step[:local_column])} IN (#{inner})",
|
|
215
|
+
pk_value
|
|
216
|
+
])
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def build_inner_chain_query(table, pk_column, org_id, chain, index)
|
|
221
|
+
step = chain[index]
|
|
222
|
+
|
|
223
|
+
if index == chain.length - 1
|
|
224
|
+
sanitize_sql([
|
|
225
|
+
"SELECT #{quote_column(pk_column)} FROM #{quote_table(table)} " \
|
|
226
|
+
"WHERE #{quote_column(step[:local_column])} IN (" \
|
|
227
|
+
"SELECT #{quote_column(step[:foreign_column])} FROM #{quote_table(step[:foreign_table])} " \
|
|
228
|
+
"WHERE organization_id = ?)",
|
|
229
|
+
org_id
|
|
230
|
+
])
|
|
231
|
+
else
|
|
232
|
+
inner = build_inner_chain_query(step[:foreign_table], step[:foreign_column], org_id, chain, index + 1)
|
|
233
|
+
"SELECT #{quote_column(pk_column)} FROM #{quote_table(table)} " \
|
|
234
|
+
"WHERE #{quote_column(step[:local_column])} IN (#{inner})"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def sanitize_sql(args)
|
|
239
|
+
ActiveRecord::Base.send(:sanitize_sql_array, args)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def quote_table(name)
|
|
243
|
+
ActiveRecord::Base.connection.quote_table_name(name)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def quote_column(name)
|
|
247
|
+
ActiveRecord::Base.connection.quote_column_name(name)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|