Module: Rhino::ScopesToOrganization

Defined in:
lib/rhino/scopes_to_organization.rb

Overview

Pure organization-scoping logic, extracted from ResourcesController so it can be reused by the custom-query resolver (Rhino.query / Rhino.for_user...).

Every method takes a RELATION and RETURNS a scoped relation — it never mutates a QueryBuilder's @scope. The scoping order MUST match the controller exactly:

1. Organization-is-self (the model IS the Organization)
2. for_organization (scopeForOrganization)
3. organization_id column
4. auto-detected belongs_to relationship path (incl. nested, e.g. post.blog)

Class Method Summary collapse

Class Method Details

._discover_organization_path_recursive(klass, visited, max_depth) ⇒ Object



126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/rhino/scopes_to_organization.rb', line 126

def _discover_organization_path_recursive(klass, visited, max_depth)
  return nil if max_depth <= 0 || visited.include?(klass.name)

  visited = visited + [klass.name]

  begin
    associations = klass.reflect_on_all_associations(:belongs_to)
  rescue StandardError
    return nil
  end

  matching_paths = []

  associations.each do |assoc|
    begin
      related_class = assoc.klass
    rescue StandardError
      next
    end

    # Direct match: related model IS Organization
    if related_class.name == "Organization"
      matching_paths << assoc.name.to_s
      next
    end

    # Related model has organization_id column
    begin
      if related_class.column_names.include?("organization_id")
        matching_paths << assoc.name.to_s
        next
      end
    rescue StandardError
      # Table may not exist yet
    end

    # Related model includes BelongsToOrganization concern
    if defined?(Rhino::BelongsToOrganization) && related_class.include?(Rhino::BelongsToOrganization)
      matching_paths << assoc.name.to_s
      next
    end

    # Recurse into related model's BelongsTo associations
    sub_path = _discover_organization_path_recursive(related_class, visited, max_depth - 1)
    if sub_path.present?
      matching_paths << "#{assoc.name}.#{sub_path}"
    end
  end

  return nil if matching_paths.empty?

  if matching_paths.length > 1
    Rails.logger&.debug(
      "Rhino: Model #{klass.name} has multiple BelongsTo paths to Organization. " \
      "Using '#{matching_paths[0]}'. " \
      "Paths found: #{matching_paths.inspect}"
    )
  end

  matching_paths[0]
end

.discover_organization_path(klass, visited = [], max_depth = 3) ⇒ Object

Recursively discover the relationship path from a model to Organization by introspecting BelongsTo associations. Returns dot-notation path or nil. Results are cached per model class to avoid repeated reflection.



112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/rhino/scopes_to_organization.rb', line 112

def discover_organization_path(klass, visited = [], max_depth = 3)
  if @organization_path_cache.key?(klass.name)
    return @organization_path_cache[klass.name]
  end

  result = _discover_organization_path_recursive(klass, visited, max_depth)
  # Only cache a positive result. Caching a transient nil (associations/tables
  # not yet resolvable under Zeitwerk lazy-loading) would permanently
  # misclassify a genuinely org-scoped model as global — a fail-open leak.
  # Mirrors HasAutoScope's non-nil caching.
  @organization_path_cache[klass.name] = result if result
  result
end

.organization_scoped?(model_class) ⇒ Boolean

Whether model_class has any organization-scoping mechanism at all. Used by the resolver to decide whether missing org context must fail closed. On an unexpected classification error we fail CLOSED (treat as scopable) so the resolver raises rather than silently returning unscoped rows.

Returns:

  • (Boolean)


63
64
65
66
67
68
69
70
# File 'lib/rhino/scopes_to_organization.rb', line 63

def organization_scoped?(model_class)
  return true if model_class.respond_to?(:for_organization)
  return true if model_class.column_names.include?("organization_id")

  discover_organization_path(model_class).present?
rescue StandardError
  true
end

.scope_through_relationship(relation, model_class, organization, relationship_path, strict: false) ⇒ Object



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/rhino/scopes_to_organization.rb', line 72

def scope_through_relationship(relation, model_class, organization, relationship_path, strict: false)
  if relationship_path.include?(".")
    # Nested path: 'post.blog' -> joins(post: { blog: :organization })
    #                             .where(organizations: { id: org.id })
    # The inject already folds every path segment (outermost first) around
    # the trailing :organization association, so the chain is passed to
    # joins as-is — re-wrapping it in parts.first would double-nest the
    # first segment and raise ActiveRecord::ConfigurationError.
    parts = relationship_path.split(".")
    join_chain = parts.reverse.inject(:organization) { |inner, outer| { outer.to_sym => inner } }

    relation.joins(join_chain)
            .where(organizations: { id: organization.id })
  else
    # Single relationship
    assoc = model_class.reflect_on_association(relationship_path.to_sym)
    if assoc.nil?
      # Classified scopable but the association vanished — fail closed for the
      # resolver; stay lenient for the controller's legacy path.
      raise Rhino::MissingTenantContext, model_class.name if strict

      return relation
    end

    if assoc.klass.column_names.include?("organization_id")
      relation.joins(relationship_path.to_sym)
              .where(assoc.klass.table_name => { organization_id: organization.id })
    elsif strict
      # Path leads somewhere without an organization_id column, so no filter
      # can be applied — fail closed rather than return every tenant's rows.
      raise Rhino::MissingTenantContext, model_class.name
    else
      relation
    end
  end
end

.scope_to_organization(relation, model_class, organization, strict: false) ⇒ Object

Apply organization scoping to relation for model_class, returning the scoped relation. Behavior-equivalent to the controller's apply_organization_scope, but returning instead of mutating a builder.



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/rhino/scopes_to_organization.rb', line 23

def scope_to_organization(relation, model_class, organization, strict: false)
  return relation unless organization

  # When the resource IS the Organization model
  if organization.class == model_class
    return relation.where(
      model_class.primary_key => organization.send(model_class.primary_key)
    )
  end

  # Check for scopeForOrganization. Merge into the incoming relation instead
  # of replacing it so upstream conditions (e.g. `.discarded` for
  # restore/force-delete lookups) are preserved alongside the org filter.
  if model_class.respond_to?(:for_organization)
    return relation.merge(model_class.for_organization(organization))
  end

  # Check for organization_id column
  if model_class.column_names.include?("organization_id")
    return relation.where(organization_id: organization.id)
  end

  # Auto-detect from belongs_to relationships
  detected_path = discover_organization_path(model_class)
  if detected_path.present?
    return scope_through_relationship(relation, model_class, organization, detected_path, strict: strict)
  end

  # No mechanism could be applied. In strict mode (the resolver) a model that
  # reached here after being classified organization_scoped? must NOT return
  # unscoped — fail closed instead of leaking across tenants.
  raise Rhino::MissingTenantContext, model_class.name if strict && organization_scoped?(model_class)

  relation
end