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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/.rubocop.yml +3 -3
- data/Gemfile.lock +50 -27
- data/README.md +1 -3
- data/ext/oso-oso/lib/libpolar.dylib +0 -0
- data/ext/oso-oso/lib/libpolar.so +0 -0
- data/ext/oso-oso/lib/polar.dll +0 -0
- data/lib/oso/errors.rb +44 -0
- data/lib/oso/oso.rb +202 -3
- data/lib/oso/polar/data_filtering.rb +176 -0
- data/lib/oso/polar/errors.rb +2 -3
- data/lib/oso/polar/ffi/error.rb +2 -2
- data/lib/oso/polar/ffi/polar.rb +26 -18
- data/lib/oso/polar/ffi/query.rb +7 -2
- data/lib/oso/polar/host.rb +80 -13
- data/lib/oso/polar/polar.rb +138 -70
- data/lib/oso/polar/query.rb +91 -5
- data/lib/oso/polar.rb +1 -0
- data/lib/oso/version.rb +1 -1
- data/lib/oso.rb +4 -3
- data/oso-oso.gemspec +2 -0
- metadata +34 -4
data/lib/oso/polar/polar.rb
CHANGED
@@ -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
|
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
|
56
|
-
|
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
|
-
|
79
|
-
|
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
|
-
|
82
|
-
|
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
|
111
|
+
# Load Polar policy files.
|
101
112
|
#
|
102
|
-
# @param
|
103
|
-
# @raise [PolarFileExtensionError] if
|
104
|
-
# @raise [PolarFileNotFoundError] if
|
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
|
107
|
-
|
120
|
+
def load_files(filenames = [])
|
121
|
+
return if filenames.empty?
|
108
122
|
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
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)
|
159
|
+
def load_str(str, filename: nil)
|
124
160
|
raise NullByteInPolarFileError if str.chomp("\0").include?("\0")
|
125
161
|
|
126
|
-
|
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(
|
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:
|
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
|
-
|
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(
|
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
|
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
|
-
|
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)
|
data/lib/oso/polar/query.rb
CHANGED
@@ -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
|
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
|
-
#
|
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
|
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
|