Module: OpenLoam::BusinessRules::Condition

Defined in:
lib/open_loam/business_rules/condition.rb

Overview

The SAFE condition evaluator — the critical security boundary of the rules engine. A rule is DATA an admin edits, so this must NEVER run arbitrary code: it supports only and/or/not and leaf comparisons { field, op, value }, and a field may name ONLY a real, non-plumbing, non-encrypted column of the record OR a declared custom field. Anything else is refused — no send of arbitrary methods, no SQL, no eval.

Constant Summary collapse

OPS =
%w[eq neq gt gte lt lte in contains present blank].freeze
REFUSED =
:__open_loam_field_refused__

Class Method Summary collapse

Class Method Details

.compare(op, actual, expected) ⇒ Object



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/open_loam/business_rules/condition.rb', line 71

def self.compare(op, actual, expected)
  case op
  when "eq"       then values_equal?(actual, expected)
  when "neq"      then !values_equal?(actual, expected)
  when "in"       then Array(expected).map(&:to_s).include?(actual.to_s)
  when "contains" then actual.to_s.include?(expected.to_s)
  when "present"  then actual.present?
  when "blank"    then actual.blank?
  else
    cmp = numeric_compare(actual, expected)
    return false if cmp.nil?

    { "gt" => cmp.positive?, "gte" => cmp >= 0, "lt" => cmp.negative?, "lte" => cmp <= 0 }[op]
  end
end

.custom_field?(record, field) ⇒ Boolean

Returns:

  • (Boolean)


66
67
68
69
# File 'lib/open_loam/business_rules/condition.rb', line 66

def self.custom_field?(record, field)
  record.class.respond_to?(:custom_field_definitions) &&
    record.class.custom_field_definitions.exists?(name: field)
end

.leaf(node, record) ⇒ Object



28
29
30
31
32
33
34
35
36
# File 'lib/open_loam/business_rules/condition.rb', line 28

def self.leaf(node, record)
  op = node["op"].to_s
  return false unless OPS.include?(op)

  actual = read_field(node["field"].to_s, record)
  return false if actual == REFUSED # an un-whitelisted field never matches

  compare(op, actual, node["value"])
end

.matches?(node, record) ⇒ Boolean

Returns:

  • (Boolean)


13
14
15
16
17
18
19
20
21
22
23
24
25
26
# File 'lib/open_loam/business_rules/condition.rb', line 13

def self.matches?(node, record)
  node = node.is_a?(Hash) ? node : {}
  return true if node.empty? # no condition = always fires

  if node.key?("and")
    Array(node["and"]).all? { |child| matches?(child, record) }
  elsif node.key?("or")
    Array(node["or"]).any? { |child| matches?(child, record) }
  elsif node.key?("not")
    !matches?(node["not"], record)
  else
    leaf(node, record)
  end
end

.numeric_compare(actual, expected) ⇒ Object

Compare as numbers when both coerce; else fall back to the natural comparison; incomparable types yield nil (so the op is simply false).



93
94
95
96
97
98
99
# File 'lib/open_loam/business_rules/condition.rb', line 93

def self.numeric_compare(actual, expected)
  Float(actual) <=> Float(expected)
rescue ArgumentError, TypeError
  actual <=> expected
rescue StandardError
  nil
end

.read_field(field, record) ⇒ Object

A whitelisted read, or REFUSED. tenant_id is refused as an isolation footgun; ENCRYPTED columns are refused because a contains/eq rule over their decrypted value would be an oracle leaking the secret through the rule's behaviour. Everything unknown is refused, never sent.



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/open_loam/business_rules/condition.rb', line 42

def self.read_field(field, record)
  klass = record.class
  return REFUSED if refused_column?(klass, field)

  if klass.column_names.include?(field)
    record.public_send(field)
  elsif custom_field?(record, field)
    begin
      record.custom_field(field)
    rescue OpenLoam::UnknownCustomFieldError
      REFUSED
    end
  else
    REFUSED
  end
end

.refused_column?(klass, field) ⇒ Boolean

Returns:

  • (Boolean)


59
60
61
62
63
64
# File 'lib/open_loam/business_rules/condition.rb', line 59

def self.refused_column?(klass, field)
  return true if field == "tenant_id"

  klass.respond_to?(:open_loam_encrypted_attributes) &&
    klass.open_loam_encrypted_attributes.map(&:to_s).include?(field)
end

.values_equal?(actual, expected) ⇒ Boolean

Returns:

  • (Boolean)


87
88
89
# File 'lib/open_loam/business_rules/condition.rb', line 87

def self.values_equal?(actual, expected)
  actual == expected || actual.to_s == expected.to_s || numeric_compare(actual, expected)&.zero? || false
end