oso-oso 0.14.2 → 0.20.1.pre.beta

Sign up to get free protection for your applications and to get access to all the features.
@@ -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.0 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.0 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,8 +191,20 @@ 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.
@@ -181,8 +215,15 @@ module Oso
181
215
  # under a previously-registered name.
182
216
  # @raise [FFI::Error] if the FFI call returns an error.
183
217
  # @return [self] for chaining.
184
- def register_class(cls, name: nil)
185
- name = host.cache_class(cls, name: name || cls.name)
218
+ def register_class(cls, name: nil, fields: nil, combine_query: nil, build_query: nil, exec_query: nil) # rubocop:disable Metrics/ParameterLists
219
+ name = host.cache_class(
220
+ cls,
221
+ name: name || cls.name,
222
+ fields: fields,
223
+ build_query: build_query || maybe_mtd(cls, :build_query),
224
+ combine_query: combine_query || maybe_mtd(cls, :combine_query),
225
+ exec_query: exec_query || maybe_mtd(cls, :exec_query)
226
+ )
186
227
  register_constant(cls, name: name)
187
228
  end
188
229
 
@@ -202,7 +243,7 @@ module Oso
202
243
  # @param files [Array<String>]
203
244
  # @raise [Error] if the FFI call raises one.
204
245
  def repl(files = [])
205
- files.map { |f| load_file(f) }
246
+ load_files(files)
206
247
  prompt = "#{FG_BLUE}query>#{RESET} "
207
248
  # Try loading the readline module from the Ruby stdlib. If we get a
208
249
  # LoadError, fall back to the standard REPL with no readline support.
@@ -214,9 +255,36 @@ module Oso
214
255
 
215
256
  private
216
257
 
258
+ def type_constraint(var, cls)
259
+ Expression.new(
260
+ 'And',
261
+ [Expression.new('Isa', [var, Pattern.new(get_class_name(cls), {})])]
262
+ )
263
+ end
264
+
265
+ def maybe_mtd(cls, mtd)
266
+ cls.respond_to?(mtd) && cls.method(mtd) || nil
267
+ end
268
+
217
269
  # @return [FFI::Polar]
218
270
  attr_reader :ffi_polar
219
- attr_accessor :polar_roles_enabled
271
+
272
+ # Register MROs, load Polar code, and check inline queries.
273
+ # @param sources [Array<Source>] Polar sources to load.
274
+ def load_sources(sources)
275
+ host.register_mros
276
+ ffi_polar.load(sources)
277
+ check_inline_queries
278
+ end
279
+
280
+ def check_inline_queries
281
+ loop do
282
+ next_query = ffi_polar.next_inline_query
283
+ break if next_query.nil?
284
+
285
+ raise InlineQueryFailedError, next_query.source if Query.new(next_query, host: host).none?
286
+ end
287
+ end
220
288
 
221
289
  # The R and L in REPL for systems where readline is available.
222
290
  def repl_readline(prompt)
@@ -10,11 +10,22 @@ module Oso
10
10
 
11
11
  # @param ffi_query [FFI::Query]
12
12
  # @param host [Oso::Polar::Host]
13
- def initialize(ffi_query, host:)
13
+ def initialize(ffi_query, host:, bindings: {})
14
14
  @calls = {}
15
15
  @ffi_query = ffi_query
16
16
  ffi_query.enrich_message = host.method(:enrich_message)
17
17
  @host = host
18
+ bindings.each { |k, v| ffi_query.bind k, host.to_polar(v) }
19
+ end
20
+
21
+ # Create an enumerator that can be polled to advance the query loop. Yields
22
+ # results one by one.
23
+ #
24
+ # @yieldparam [Hash<String, Object>]
25
+ # @return [Enumerator]
26
+ # @raise [Error] if any of the FFI calls raise one.
27
+ def each(&block)
28
+ run(&block)
18
29
  end
19
30
 
20
31
  private
@@ -64,13 +75,17 @@ module Oso
64
75
  # Fetch the next result from calling a Ruby method and prepare it for
65
76
  # transmission across the FFI boundary.
66
77
  #
67
- # @param method [#to_sym]
68
- # @param args [Array<Hash>]
78
+ # @param attribute [#to_sym]
69
79
  # @param call_id [Integer]
70
80
  # @param instance [Hash<String, Object>]
81
+ # @param args [Array<Hash>]
82
+ # @param kwargs [Hash<String, Object>]
71
83
  # @raise [Error] if the FFI call raises one.
72
84
  def handle_call(attribute, call_id:, instance:, args:, kwargs:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
73
85
  instance = host.to_ruby(instance)
86
+ rel = get_relationship(instance.class, attribute)
87
+ return handle_relationship(call_id, instance, rel) unless rel.nil?
88
+
74
89
  args = args.map { |a| host.to_ruby(a) }
75
90
  kwargs = Hash[kwargs.map { |k, v| [k.to_sym, host.to_ruby(v)] }]
76
91
  # The kwargs.empty? check is for Ruby < 2.7.
@@ -86,6 +101,40 @@ module Oso
86
101
  call_result(nil, call_id: call_id)
87
102
  end
88
103
 
104
+ # Get the type information for a field on a class.
105
+ #
106
+ # @param cls [UserType]
107
+ # @param tag [String]
108
+ # @return [UserType]
109
+ # @raise [Error] if no information is found
110
+ def get_field(cls, tag) # rubocop:disable Metrics/AbcSize
111
+ raise unless cls.fields.key? tag
112
+
113
+ ref = cls.fields[tag]
114
+ return host.types[ref] unless ref.is_a? ::Oso::Polar::DataFiltering::Relation
115
+
116
+ case ref.kind
117
+ when 'one'
118
+ host.types[ref.other_type]
119
+ when 'many'
120
+ host.types[Array]
121
+ end
122
+ end
123
+
124
+ # Check if a series of dot operations on a base class yield an
125
+ # instance of another class.
126
+ def handle_external_isa_with_path(data) # rubocop:disable Metrics/AbcSize
127
+ sup = host.types[data['class_tag']]
128
+ bas = host.types[data['base_tag']]
129
+ path = data['path'].map(&host.method(:to_ruby))
130
+ sub = path.reduce(bas) { |cls, tag| get_field(cls, tag) }
131
+ answer = sub.klass.get <= sup.klass.get
132
+ question_result(answer, call_id: data['call_id'])
133
+ rescue StandardError => e
134
+ application_error e.message
135
+ question_result(nil, call_id: data['call_id'])
136
+ end
137
+
89
138
  def handle_next_external(call_id, iterable)
90
139
  unless calls.key? call_id
91
140
  value = host.to_ruby iterable
@@ -114,12 +163,12 @@ module Oso
114
163
  host.make_instance(cls_name, args: args, kwargs: kwargs, id: id)
115
164
  end
116
165
 
117
- # Create a generator that can be polled to advance the query loop.
166
+ # Run the main Polar loop, yielding results as they are emitted from the VM.
118
167
  #
119
168
  # @yieldparam [Hash<String, Object>]
120
169
  # @return [Enumerator]
121
170
  # @raise [Error] if any of the FFI calls raise one.
122
- def each # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
171
+ def run # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
123
172
  loop do # rubocop:disable Metrics/BlockLength
124
173
  event = ffi_query.next_event
125
174
  case event.kind
@@ -129,6 +178,8 @@ module Oso
129
178
  yield event.data['bindings'].transform_values { |v| host.to_ruby(v) }
130
179
  when 'MakeExternal'
131
180
  handle_make_external(event.data)
181
+ when 'ExternalIsaWithPath'
182
+ handle_external_isa_with_path(event.data)
132
183
  when 'ExternalCall'
133
184
  call_id = event.data['call_id']
134
185
  instance = event.data['instance']
@@ -142,6 +193,12 @@ module Oso
142
193
  right_tag = event.data['right_class_tag']
143
194
  answer = host.subspecializer?(instance_id, left_tag: left_tag, right_tag: right_tag)
144
195
  question_result(answer, call_id: event.data['call_id'])
196
+ when 'ExternalIsSubclass'
197
+ call_id = event.data['call_id']
198
+ left = event.data['left_class_tag']
199
+ right = event.data['right_class_tag']
200
+ answer = host.subclass?(left_tag: left, right_tag: right)
201
+ question_result(answer, call_id: call_id)
145
202
  when 'ExternalIsa'
146
203
  instance = event.data['instance']
147
204
  class_tag = event.data['class_tag']
@@ -175,6 +232,35 @@ module Oso
175
232
  end
176
233
  end
177
234
  end
235
+
236
+ def get_relationship(cls, attr)
237
+ typ = host.types[cls]
238
+ return unless typ
239
+
240
+ rel = typ.fields[attr]
241
+ return unless rel.is_a? ::Oso::Polar::DataFiltering::Relation
242
+
243
+ rel
244
+ end
245
+
246
+ def handle_relationship(call_id, instance, rel) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
247
+ typ = host.types[rel.other_type]
248
+ constraint = ::Oso::Polar::DataFiltering::Filter.new(
249
+ kind: 'Eq',
250
+ field: rel.other_field,
251
+ value: instance.send(rel.my_field)
252
+ )
253
+ res = typ.exec_query[typ.build_query[[constraint]]]
254
+
255
+ if rel.kind == 'one'
256
+ raise "multiple parents: #{res}" unless res.length == 1
257
+
258
+ res = res[0]
259
+ end
260
+
261
+ res = JSON.dump host.to_polar res
262
+ call_result(res, call_id: call_id)
263
+ end
178
264
  end
179
265
  end
180
266
  end