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



119
120
121
122
123
124
125
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
# File 'lib/rhino/scopes_to_organization.rb', line 119

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.



105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/rhino/scopes_to_organization.rb', line 105

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)


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

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



70
71
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
# File 'lib/rhino/scopes_to_organization.rb', line 70

def scope_through_relationship(relation, model_class, organization, relationship_path, strict: false)
  if relationship_path.include?(".")
    # Nested path: 'post.blog' -> joins(post: :blog).where(organizations: { id: org.id })
    parts = relationship_path.split(".")
    join_chain = parts.reverse.inject(:organization) { |inner, outer| { outer.to_sym => inner } }

    relation.joins(join_chain.is_a?(Symbol) ? join_chain : parts.first.to_sym => 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
# 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
  if model_class.respond_to?(:for_organization)
    return 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