oso-oso 0.15.1 → 0.21.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -14,17 +14,22 @@ module Oso
14
14
  ffi_lib FFI::LIB_PATH
15
15
 
16
16
  attach_function :new, :polar_new, [], FFI::Polar
17
- attach_function :enable_roles, :polar_enable_roles, [FFI::Polar], :int32
18
- attach_function :validate_roles_config, :polar_validate_roles_config, [FFI::Polar, :string], :int32
19
- attach_function :load, :polar_load, [FFI::Polar, :string, :string], :int32
17
+ attach_function :load, :polar_load, [FFI::Polar, :string], :int32
20
18
  attach_function :clear_rules, :polar_clear_rules, [FFI::Polar], :int32
21
19
  attach_function :next_inline_query, :polar_next_inline_query, [FFI::Polar, :uint32], FFI::Query
22
20
  attach_function :new_id, :polar_get_external_id, [FFI::Polar], :uint64
23
21
  attach_function :new_query_from_str, :polar_new_query, [FFI::Polar, :string, :uint32], FFI::Query
24
22
  attach_function :new_query_from_term, :polar_new_query_from_term, [FFI::Polar, :string, :uint32], FFI::Query
25
23
  attach_function :register_constant, :polar_register_constant, [FFI::Polar, :string, :string], :int32
24
+ attach_function :register_mro, :polar_register_mro, [FFI::Polar, :string, :string], :int32
26
25
  attach_function :next_message, :polar_next_polar_message, [FFI::Polar], FFI::Message
27
26
  attach_function :free, :polar_free, [FFI::Polar], :int32
27
+ attach_function(
28
+ :build_filter_plan,
29
+ :polar_build_filter_plan,
30
+ [FFI::Polar, :string, :string, :string, :string],
31
+ :string
32
+ )
28
33
  end
29
34
  private_constant :Rust
30
35
 
@@ -37,25 +42,20 @@ module Oso
37
42
  polar
38
43
  end
39
44
 
40
- # @raise [FFI::Error] if the FFI call returns an error.
41
- def enable_roles
42
- result = Rust.enable_roles(self)
43
- process_messages
44
- handle_error if result.zero?
45
- end
46
-
47
- # @raise [FFI::Error] if the FFI call returns an error.
48
- def validate_roles_config(config)
49
- result = Rust.validate_roles_config(self, JSON.dump(config))
45
+ def build_filter_plan(types, partials, variable, class_tag)
46
+ types = JSON.dump(types)
47
+ partials = JSON.dump(partials)
48
+ plan = Rust.build_filter_plan(self, types, partials, variable, class_tag)
50
49
  process_messages
51
- handle_error if result.zero?
50
+ handle_error if plan.nil?
51
+ # TODO(gw) more error checking?
52
+ JSON.parse plan
52
53
  end
53
54
 
54
- # @param src [String]
55
- # @param filename [String]
55
+ # @param sources [Array<Source>]
56
56
  # @raise [FFI::Error] if the FFI call returns an error.
57
- def load(src, filename: nil)
58
- loaded = Rust.load(self, src, filename)
57
+ def load(sources)
58
+ loaded = Rust.load(self, JSON.dump(sources))
59
59
  process_messages
60
60
  handle_error if loaded.zero?
61
61
  end
@@ -117,6 +117,14 @@ module Oso
117
117
  handle_error if registered.zero?
118
118
  end
119
119
 
120
+ # @param name [String]
121
+ # @param mro [Array<Integer>]
122
+ # @raise [FFI::Error] if the FFI call returns an error.
123
+ def register_mro(name, mro)
124
+ registered = Rust.register_mro(self, name, JSON.dump(mro))
125
+ handle_error if registered.zero?
126
+ end
127
+
120
128
  def next_message
121
129
  Rust.next_message(self)
122
130
  end
@@ -21,6 +21,7 @@ module Oso
21
21
  attach_function :next_message, :polar_next_query_message, [FFI::Query], FFI::Message
22
22
  attach_function :source, :polar_query_source_info, [FFI::Query], FFI::Source
23
23
  attach_function :free, :query_free, [FFI::Query], :int32
24
+ attach_function :bind, :polar_bind, [FFI::Query, :string, :string], :int32
24
25
  end
25
26
  private_constant :Rust
26
27
 
@@ -49,8 +50,7 @@ module Oso
49
50
  handle_error if res.zero?
50
51
  end
51
52
 
52
- # @param result [Boolean]
53
- # @param call_id [Integer]
53
+ # @param message [String]
54
54
  # @raise [FFI::Error] if the FFI call returns an error.
55
55
  def application_error(message)
56
56
  res = Rust.application_error(self, message)
@@ -67,6 +67,11 @@ module Oso
67
67
  ::Oso::Polar::QueryEvent.new(JSON.parse(event.to_s))
68
68
  end
69
69
 
70
+ def bind(name, value)
71
+ res = Rust.bind(self, name, JSON.dump(value))
72
+ handle_error if res.zero?
73
+ end
74
+
70
75
  def next_message
71
76
  Rust.next_message(self)
72
77
  end
@@ -34,14 +34,31 @@ module Oso
34
34
  end
35
35
  end
36
36
 
37
+ # For holding type metadata: name, fields, etc.
38
+ class UserType
39
+ attr_reader :name, :klass, :id, :fields, :build_query, :combine_query, :exec_query
40
+
41
+ def initialize(name:, klass:, id:, fields:, build_query:, combine_query:, exec_query:) # rubocop:disable Metrics/ParameterLists
42
+ @name = name
43
+ @klass = klass
44
+ @id = id
45
+ # accept symbol keys
46
+ @fields = fields.each_with_object({}) { |kv, o| o[kv[0].to_s] = kv[1] }
47
+ @build_query = build_query
48
+ @combine_query = combine_query
49
+ @exec_query = exec_query
50
+ end
51
+ end
52
+
37
53
  # Translate between Polar and the host language (Ruby).
38
54
  class Host # rubocop:disable Metrics/ClassLength
55
+ # @return [Hash<String, UserType>]
56
+ attr_reader :types
57
+
39
58
  protected
40
59
 
41
60
  # @return [FFI::Polar]
42
61
  attr_reader :ffi_polar
43
- # @return [Hash<String, Class>]
44
- attr_reader :classes
45
62
  # @return [Hash<Integer, Object>]
46
63
  attr_reader :instances
47
64
  # @return [Boolean]
@@ -53,42 +70,60 @@ module Oso
53
70
 
54
71
  def initialize(ffi_polar)
55
72
  @ffi_polar = ffi_polar
56
- @classes = {}
73
+ @types = {}
57
74
  @instances = {}
58
75
  @accept_expression = false
59
76
  end
60
77
 
61
78
  def initialize_copy(other)
62
79
  @ffi_polar = other.ffi_polar
63
- @classes = other.classes.dup
80
+ @types = other.types.dup
64
81
  @instances = other.instances.dup
65
82
  end
66
83
 
67
- # Fetch a Ruby class from the {#classes} cache.
84
+ # Fetch a Ruby class from the {#types} cache.
68
85
  #
69
86
  # @param name [String]
70
87
  # @return [Class]
71
88
  # @raise [UnregisteredClassError] if the class has not been registered.
72
89
  def get_class(name)
73
- raise UnregisteredClassError, name unless classes.key? name
90
+ raise UnregisteredClassError, name unless types.key? name
74
91
 
75
- classes[name].get
92
+ types[name].klass.get
76
93
  end
77
94
 
78
- # Store a Ruby class in the {#classes} cache.
95
+ # Store a Ruby class in the {#types} cache.
79
96
  #
80
97
  # @param cls [Class] the class to cache.
81
98
  # @param name [String] the name to cache the class as.
82
99
  # @return [String] the name the class is cached as.
83
100
  # @raise [DuplicateClassAliasError] if attempting to register a class
84
101
  # under a previously-registered name.
85
- def cache_class(cls, name:)
86
- raise DuplicateClassAliasError.new name: name, old: get_class(name), new: cls if classes.key? name
102
+ def cache_class(cls, name:, fields:, build_query:, combine_query:, exec_query:) # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
103
+ raise DuplicateClassAliasError.new name: name, old: get_class(name), new: cls if types.key? name
87
104
 
88
- classes[name] = PolarClass.new(cls)
105
+ types[name] = types[cls] = UserType.new(
106
+ name: name,
107
+ klass: PolarClass.new(cls),
108
+ id: cache_instance(cls),
109
+ fields: fields || {},
110
+ combine_query: combine_query,
111
+ exec_query: exec_query,
112
+ build_query: build_query
113
+ )
89
114
  name
90
115
  end
91
116
 
117
+ def register_mros # rubocop:disable Metrics/AbcSize
118
+ types.values.uniq.each do |typ|
119
+ mro = []
120
+ typ.klass.get.ancestors.each do |a|
121
+ mro.push(types[a].id) if types.key?(a)
122
+ end
123
+ ffi_polar.register_mro(typ.name, mro)
124
+ end
125
+ end
126
+
92
127
  # Check if an instance exists in the {#instances} cache.
93
128
  #
94
129
  # @param id [Integer]
@@ -160,7 +195,7 @@ module Oso
160
195
 
161
196
  # Compare two values
162
197
  #
163
- # @param op [String] operation to perform.
198
+ # @param operation [String] operation to perform.
164
199
  # @param args [Array<Object>] left and right args to operation.
165
200
  # @raise [PolarRuntimeError] if operation fails or is unsupported.
166
201
  # @return [Boolean]
@@ -190,6 +225,10 @@ module Oso
190
225
  left_index && right_index && left_index < right_index
191
226
  end
192
227
 
228
+ def subclass?(left_tag:, right_tag:)
229
+ get_class(left_tag) <= get_class(right_tag)
230
+ end
231
+
193
232
  # Check if instance is an instance of class.
194
233
  #
195
234
  # @param instance [Hash<String, Object>]
@@ -201,6 +240,32 @@ module Oso
201
240
  instance.is_a? cls
202
241
  end
203
242
 
243
+ def serialize_types # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
244
+ polar_types = {}
245
+ types.values.uniq.each do |typ|
246
+ tag = typ.name
247
+ fields = typ.fields
248
+ field_types = {}
249
+ fields.each do |k, v|
250
+ field_types[k] =
251
+ if v.is_a? ::Oso::Polar::DataFiltering::Relation
252
+ {
253
+ 'Relation' => {
254
+ 'kind' => v.kind,
255
+ 'other_class_tag' => v.other_type,
256
+ 'my_field' => v.my_field,
257
+ 'other_field' => v.other_field
258
+ }
259
+ }
260
+ else
261
+ { 'Base' => { 'class_tag' => types[v].name } }
262
+ end
263
+ end
264
+ polar_types[tag] = field_types
265
+ end
266
+ polar_types
267
+ end
268
+
204
269
  # Turn a Ruby value into a Polar term that's ready to be sent across the
205
270
  # FFI boundary.
206
271
  #
@@ -242,7 +307,9 @@ module Oso
242
307
  { 'Pattern' => { 'Instance' => { 'tag' => value.tag, 'fields' => dict['Dictionary'] } } }
243
308
  end
244
309
  else
245
- { 'ExternalInstance' => { 'instance_id' => cache_instance(value), 'repr' => nil } }
310
+ instance_id = nil
311
+ instance_id = types[value].id if value.is_a?(Class) && types.key?(value)
312
+ { 'ExternalInstance' => { 'instance_id' => cache_instance(value, id: instance_id), 'repr' => nil } }
246
313
  end
247
314
  { 'value' => value }
248
315
  end
@@ -27,6 +27,35 @@ def print_error(error)
27
27
  warn error.message
28
28
  end
29
29
 
30
+ # Polar source string with optional filename.
31
+ class Source
32
+ # @return [String]
33
+ attr_reader :src, :filename
34
+
35
+ # @param src [String]
36
+ # @param filename [String]
37
+ def initialize(src, filename: nil)
38
+ @src = src
39
+ @filename = filename
40
+ end
41
+
42
+ def to_json(*_args)
43
+ { src: src, filename: filename }.to_json
44
+ end
45
+ end
46
+
47
+ def filename_to_source(filename)
48
+ raise Oso::Polar::PolarFileExtensionError, filename unless File.extname(filename) == '.polar'
49
+
50
+ src = File.open(filename, &:read)
51
+
52
+ raise Oso::Polar::NullByteInPolarFileError if src.chomp("\0").include?("\0")
53
+
54
+ Source.new(src, filename: filename)
55
+ rescue Errno::ENOENT
56
+ raise Oso::Polar::PolarFileNotFoundError, filename
57
+ end
58
+
30
59
  module Oso
31
60
  module Polar
32
61
  # Create and manage an instance of the Polar runtime.
@@ -34,11 +63,10 @@ module Oso
34
63
  # @return [Host]
35
64
  attr_reader :host
36
65
 
37
- def initialize # rubocop:disable Metrics/MethodLength
66
+ def initialize
38
67
  @ffi_polar = FFI::Polar.create
39
68
  @host = Host.new(ffi_polar)
40
69
  @ffi_polar.enrich_message = @host.method(:enrich_message)
41
- @polar_roles_enabled = false
42
70
 
43
71
  # Register global constants.
44
72
  register_constant nil, name: 'nil'
@@ -52,40 +80,24 @@ module Oso
52
80
  register_class String
53
81
  end
54
82
 
55
- def enable_roles # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
56
- return if polar_roles_enabled
57
-
58
- roles_helper = Class.new do
59
- def self.join(separator, left, right)
60
- [left, right].join(separator)
61
- end
62
- end
63
- register_constant(roles_helper, name: '__oso_internal_roles_helpers__')
64
- ffi_polar.enable_roles
65
- self.polar_roles_enabled = true
66
-
67
- # validate config
68
- validation_query_results = []
69
- loop do
70
- query = ffi_polar.next_inline_query
71
- break if query.nil?
72
-
73
- new_host = host.dup
74
- new_host.accept_expression = true
75
- results = Query.new(query, host: new_host).to_a
76
- raise InlineQueryFailedError, query.source if results.empty?
83
+ def ffi
84
+ @ffi_polar
85
+ end
77
86
 
78
- validation_query_results.push results
79
- end
87
+ # get the (maybe user-supplied) name of a class.
88
+ # kind of a hack because of class autoreloading.
89
+ def get_class_name(klass) # rubocop:disable Metrics/AbcSize
90
+ if host.types.key? klass
91
+ host.types[klass].name
92
+ elsif host.types.key? klass.name
93
+ host.types[klass.name].name
94
+ else
95
+ rec = host.types.values.find { |v| v.klass.get == klass }
96
+ raise "Unknown class `#{klass}`" if rec.nil?
80
97
 
81
- # turn bindings back into polar
82
- validation_query_results = validation_query_results.map do |results|
83
- results.map do |result|
84
- { 'bindings' => result.transform_values { |v| host.to_polar(v) } }
85
- end
98
+ host.types[klass] = rec
99
+ rec.name
86
100
  end
87
-
88
- ffi_polar.validate_roles_config(validation_query_results)
89
101
  end
90
102
 
91
103
  # Clear all rules and rule sources from the current Polar instance
@@ -93,23 +105,47 @@ module Oso
93
105
  # @return [self] for chaining.
94
106
  def clear_rules
95
107
  ffi_polar.clear_rules
96
- ffi_polar.enable_roles if polar_roles_enabled
97
108
  self
98
109
  end
99
110
 
100
- # Load a Polar policy file.
111
+ # Load Polar policy files.
101
112
  #
102
- # @param name [String]
103
- # @raise [PolarFileExtensionError] if provided filename has invalid extension.
104
- # @raise [PolarFileNotFoundError] if provided filename does not exist.
113
+ # @param filenames [Array<String>]
114
+ # @raise [PolarFileExtensionError] if any filename has an invalid extension.
115
+ # @raise [PolarFileNotFoundError] if any filename does not exist.
116
+ # @raise [NullByteInPolarFileError] if any file contains a non-terminating null byte.
117
+ # @raise [Error] if any of the FFI calls raise one.
118
+ # @raise [InlineQueryFailedError] on the first failed inline query.
105
119
  # @return [self] for chaining.
106
- def load_file(name)
107
- raise PolarFileExtensionError, name unless File.extname(name) == '.polar'
120
+ def load_files(filenames = [])
121
+ return if filenames.empty?
108
122
 
109
- file_data = File.open(name, &:read)
110
- load_str(file_data, filename: name)
111
- rescue Errno::ENOENT
112
- raise PolarFileNotFoundError, name
123
+ sources = filenames.map { |f| filename_to_source f }
124
+ load_sources(sources)
125
+ self
126
+ end
127
+
128
+ # Load a Polar policy file.
129
+ #
130
+ # @param filename [String]
131
+ # @raise [PolarFileExtensionError] if filename has an invalid extension.
132
+ # @raise [PolarFileNotFoundError] if filename does not exist.
133
+ # @raise [NullByteInPolarFileError] if file contains a non-terminating null byte.
134
+ # @raise [Error] if any of the FFI calls raise one.
135
+ # @raise [InlineQueryFailedError] on the first failed inline query.
136
+ # @return [self] for chaining.
137
+ #
138
+ # @deprecated {#load_file} has been deprecated in favor of {#load_files}
139
+ # as of the 0.20 release. Please see changelog for migration
140
+ # instructions:
141
+ # https://docs.osohq.com/project/changelogs/2021-09-15.html
142
+ def load_file(filename)
143
+ warn <<~WARNING
144
+ `Oso#load_file` has been deprecated in favor of `Oso#load_files` as of the 0.20 release.
145
+
146
+ Please see changelog for migration instructions: https://docs.osohq.com/project/changelogs/2021-09-15.html
147
+ WARNING
148
+ load_files([filename])
113
149
  end
114
150
 
115
151
  # Load a Polar string into the KB.
@@ -117,26 +153,13 @@ module Oso
117
153
  # @param str [String] Polar string to load.
118
154
  # @param filename [String] Name of Polar source file.
119
155
  # @raise [NullByteInPolarFileError] if str includes a non-terminating null byte.
120
- # @raise [InlineQueryFailedError] on the first failed inline query.
121
156
  # @raise [Error] if any of the FFI calls raise one.
157
+ # @raise [InlineQueryFailedError] on the first failed inline query.
122
158
  # @return [self] for chaining.
123
- def load_str(str, filename: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
159
+ def load_str(str, filename: nil)
124
160
  raise NullByteInPolarFileError if str.chomp("\0").include?("\0")
125
161
 
126
- ffi_polar.load(str, filename: filename)
127
- loop do
128
- next_query = ffi_polar.next_inline_query
129
- break if next_query.nil?
130
-
131
- raise InlineQueryFailedError, next_query.source if Query.new(next_query, host: host).first.nil?
132
- end
133
-
134
- # If roles are enabled, re-validate config when new rules are loaded.
135
- if polar_roles_enabled
136
- self.polar_roles_enabled = false
137
- enable_roles
138
- end
139
-
162
+ load_sources([Source.new(str, filename: filename)])
140
163
  self
141
164
  end
142
165
 
@@ -150,17 +173,16 @@ module Oso
150
173
  # @param query [Predicate]
151
174
  # @return [Enumerator] of resulting bindings
152
175
  # @raise [Error] if the FFI call raises one.
153
- def query(query)
154
- new_host = host.dup
176
+ def query(query, host: self.host.dup, bindings: {})
155
177
  case query
156
178
  when String
157
179
  ffi_query = ffi_polar.new_query_from_str(query)
158
180
  when Predicate
159
- ffi_query = ffi_polar.new_query_from_term(new_host.to_polar(query))
181
+ ffi_query = ffi_polar.new_query_from_term(host.to_polar(query))
160
182
  else
161
183
  raise InvalidQueryTypeError
162
184
  end
163
- Query.new(ffi_query, host: new_host)
185
+ Query.new(ffi_query, host: host, bindings: bindings)
164
186
  end
165
187
 
166
188
  # Query for a rule.
@@ -169,20 +191,43 @@ module Oso
169
191
  # @param args [Array<Object>]
170
192
  # @return [Enumerator] of resulting bindings
171
193
  # @raise [Error] if the FFI call raises one.
172
- def query_rule(name, *args)
173
- query(Predicate.new(name, args: args))
194
+ def query_rule(name, *args, accept_expression: false, bindings: {})
195
+ host = self.host.dup
196
+ host.accept_expression = accept_expression
197
+ query(Predicate.new(name, args: args), host: host, bindings: bindings)
198
+ end
199
+
200
+ # Query for a rule, returning true if it has any results.
201
+ #
202
+ # @param name [String]
203
+ # @param args [Array<Object>]
204
+ # @return [Boolean] indicating whether the query found at least one result.
205
+ # @raise [Error] if the FFI call raises one.
206
+ def query_rule_once(name, *args)
207
+ query_rule(name, *args).any?
174
208
  end
175
209
 
176
210
  # Register a Ruby class with Polar.
177
211
  #
178
212
  # @param cls [Class] the class to register.
179
213
  # @param name [String] the name to register the class as. Defaults to the name of the class.
214
+ # @param fields [Hash] a map from field names on instances of +cls+ to types, or Relation objects.
215
+ # @param build_query [Proc] a method to produce a query for +cls+ objects, given a list of Filters.
216
+ # @param exec_query [Proc] a method to execute a query produced by +build_query+
217
+ # @param combine_query [Proc] a method to merge two queries produced by +build_query+
180
218
  # @raise [DuplicateClassAliasError] if attempting to register a class
181
219
  # under a previously-registered name.
182
220
  # @raise [FFI::Error] if the FFI call returns an error.
183
221
  # @return [self] for chaining.
184
- def register_class(cls, name: nil)
185
- name = host.cache_class(cls, name: name || cls.name)
222
+ def register_class(cls, name: nil, fields: nil, combine_query: nil, build_query: nil, exec_query: nil) # rubocop:disable Metrics/ParameterLists
223
+ name = host.cache_class(
224
+ cls,
225
+ name: name || cls.name,
226
+ fields: fields,
227
+ build_query: build_query || maybe_mtd(cls, :build_query),
228
+ combine_query: combine_query || maybe_mtd(cls, :combine_query),
229
+ exec_query: exec_query || maybe_mtd(cls, :exec_query)
230
+ )
186
231
  register_constant(cls, name: name)
187
232
  end
188
233
 
@@ -202,7 +247,7 @@ module Oso
202
247
  # @param files [Array<String>]
203
248
  # @raise [Error] if the FFI call raises one.
204
249
  def repl(files = [])
205
- files.map { |f| load_file(f) }
250
+ load_files(files)
206
251
  prompt = "#{FG_BLUE}query>#{RESET} "
207
252
  # Try loading the readline module from the Ruby stdlib. If we get a
208
253
  # LoadError, fall back to the standard REPL with no readline support.
@@ -214,9 +259,36 @@ module Oso
214
259
 
215
260
  private
216
261
 
262
+ def type_constraint(var, cls)
263
+ Expression.new(
264
+ 'And',
265
+ [Expression.new('Isa', [var, Pattern.new(get_class_name(cls), {})])]
266
+ )
267
+ end
268
+
269
+ def maybe_mtd(cls, mtd)
270
+ cls.respond_to?(mtd) && cls.method(mtd) || nil
271
+ end
272
+
217
273
  # @return [FFI::Polar]
218
274
  attr_reader :ffi_polar
219
- attr_accessor :polar_roles_enabled
275
+
276
+ # Register MROs, load Polar code, and check inline queries.
277
+ # @param sources [Array<Source>] Polar sources to load.
278
+ def load_sources(sources)
279
+ host.register_mros
280
+ ffi_polar.load(sources)
281
+ check_inline_queries
282
+ end
283
+
284
+ def check_inline_queries
285
+ loop do
286
+ next_query = ffi_polar.next_inline_query
287
+ break if next_query.nil?
288
+
289
+ raise InlineQueryFailedError, next_query.source if Query.new(next_query, host: host).none?
290
+ end
291
+ end
220
292
 
221
293
  # The R and L in REPL for systems where readline is available.
222
294
  def repl_readline(prompt)