gitlab-glaz 2.0.0-x86_64-linux-gnu → 2.2.0-x86_64-linux-gnu

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,20 +9,22 @@ module Gitlab
9
9
  # policy evaluator.
10
10
  #
11
11
  # Evaluation is trigger-keyed: the caller supplies an event trigger and
12
- # the context document, and the engine fetches the applicable policies
13
- # from the Policy Store REST API named at construction. Authentication
14
- # is per request (+store_authorization+ on #evaluate), never per engine
15
- # and never from the environment. The engine executes no actions; the
16
- # caller does.
12
+ # the context document. The policies come either with the call
13
+ # (+policies:+ on #evaluate, for a caller that already has them, such as
14
+ # the Policy Store itself) or from the Policy Store REST API named at
15
+ # construction. Authentication for the REST API is per request
16
+ # (+store_authorization+ on #evaluate), never per engine and never from
17
+ # the environment. The engine executes no actions; the caller does.
17
18
  #
18
19
  # #validate and #debug_evaluate never touch the store, so an engine
19
- # built without one (the Policy Store itself) still serves them and
20
- # fails #evaluate closed with an ArgumentError.
20
+ # built without one still serves them, and serves #evaluate when
21
+ # +policies:+ is given. An #evaluate call without +policies:+ on such an
22
+ # engine fails closed with an ArgumentError.
21
23
  #
22
- # #evaluate releases the GVL around the store lookup. It cannot be
23
- # interrupted from Ruby until it returns, which is bounded by the
24
- # lookup timeouts plus the batch evaluation. Instances are safe to
25
- # share across threads and across a fork.
24
+ # #evaluate releases the GVL around the native call. It cannot be
25
+ # interrupted from Ruby until it returns, which is bounded by the lookup
26
+ # timeouts plus the batch evaluation. Instances are safe to share across
27
+ # threads and across a fork.
26
28
  class GovernPolicyEngine
27
29
  # Argument checks, raising ArgumentError instead of letting a
28
30
  # Google::Protobuf::TypeError or JSON error escape from the wire layer.
@@ -81,6 +83,138 @@ module Gitlab
81
83
  string.nil? || string.empty? ? nil : string
82
84
  end
83
85
 
86
+ MODES = {
87
+ "enforce" => :GOVERN_ENFORCEMENT_MODE_ENFORCE,
88
+ "audit" => :GOVERN_ENFORCEMENT_MODE_AUDIT,
89
+ "warn" => :GOVERN_ENFORCEMENT_MODE_WARN
90
+ }.freeze
91
+ private_constant :MODES
92
+
93
+ # Builds the wire set from policy Hashes shaped like the Policy
94
+ # Store's policy entity. String or Symbol keys; keys the engine does
95
+ # not read, such as +lifecycle_state+, are ignored. Only the shape is
96
+ # checked here. Whether each policy belongs to the request's trigger
97
+ # and organization is the engine's check.
98
+ def policies!(policies)
99
+ return nil if policies.nil?
100
+ raise ArgumentError, "policies must be nil or an Array; got #{policies.class}" unless policies.is_a?(Array)
101
+
102
+ ::Gitlab::Glaz::Govern::V1::GovernPolicySet.new(
103
+ policies: policies.each_with_index.map { |policy, index| policy!(policy, "policies[#{index}]") }
104
+ )
105
+ end
106
+
107
+ def policy!(policy, name)
108
+ raise ArgumentError, "#{name} must be a Hash; got #{policy.class}" unless policy.is_a?(Hash)
109
+
110
+ policy = policy.transform_keys(&:to_s)
111
+ # Name the policy by id as well, as the native layer's errors do.
112
+ name = "#{name} (id #{policy['id']})" if policy["id"].is_a?(Integer)
113
+ dimensions, namespace_id = policy.values_at("scope_dimensions", "namespace_id")
114
+
115
+ attributes = {
116
+ id: integer!(policy["id"], "#{name}.id"),
117
+ name: optional_string!(policy["name"], "#{name}.name"),
118
+ trigger: string!(policy["trigger_type"], "#{name}.trigger_type"),
119
+ organization_id: integer!(policy["organization_id"], "#{name}.organization_id"),
120
+ mode: mode!(policy["mode"], "#{name}.mode"),
121
+ scope_rego: optional_string!(policy["scope_rego"], "#{name}.scope_rego"),
122
+ scope_dimensions: dimensions.nil? ? nil : scope_dimensions!(dimensions, "#{name}.scope_dimensions"),
123
+ policy_rego: string!(policy["policy_rego"], "#{name}.policy_rego"),
124
+ actions: actions!(policy["actions"], "#{name}.actions"),
125
+ violation_details: violation_details!(policy["violation_details"], "#{name}.violation_details")
126
+ }
127
+ # The owner is explicit on the wire: nil is an organization-level
128
+ # policy, never an unset field, so a missing key is an error rather
129
+ # than a silent default.
130
+ unless policy.key?("namespace_id")
131
+ raise ArgumentError, "#{name}.namespace_id must be present (nil for an organization-level policy)"
132
+ end
133
+
134
+ if namespace_id.nil?
135
+ attributes[:organization_level] = true
136
+ else
137
+ attributes[:namespace_id] = integer!(namespace_id, "#{name}.namespace_id")
138
+ end
139
+
140
+ ::Gitlab::Glaz::Govern::V1::GovernPolicy.new(attributes)
141
+ end
142
+
143
+ def integer!(value, name)
144
+ unless value.is_a?(Integer) && !value.negative? && value.bit_length <= 64
145
+ raise ArgumentError, "#{name} must be a non-negative Integer of at most 64 bits; got #{value.inspect}"
146
+ end
147
+
148
+ value
149
+ end
150
+
151
+ def optional_string!(value, name)
152
+ value.nil? ? "" : string!(value, name)
153
+ end
154
+
155
+ def mode!(value, name)
156
+ MODES.fetch(value.to_s) do
157
+ raise ArgumentError, "#{name} must be one of #{MODES.keys.join(', ')}; got #{value.inspect}"
158
+ end
159
+ end
160
+
161
+ # The engine stamps a mode on every decision, so an unspecified or
162
+ # unknown one is an engine fault, not a caller mistake.
163
+ def mode_name(value)
164
+ MODES.key(value) || raise("decision carries no enforcement mode; got #{value.inspect}")
165
+ end
166
+
167
+ def scope_dimensions!(value, name)
168
+ unless value.is_a?(Array) && value.all?(String)
169
+ raise ArgumentError, "#{name} must be nil or an Array of Strings; got #{value.inspect}"
170
+ end
171
+
172
+ ::Gitlab::Glaz::Govern::V1::GovernScopeDimensions.new(paths: value)
173
+ end
174
+
175
+ # The store entity's saved constraints, forwarded onto the policy's
176
+ # decision as the store lookup forwards them: nil means none, a nil
177
+ # or missing +expected+ names no value, and other keys are ignored.
178
+ # The engine checks each path's segments.
179
+ def violation_details!(value, name)
180
+ return [] if value.nil?
181
+ raise ArgumentError, "#{name} must be nil or an Array; got #{value.class}" unless value.is_a?(Array)
182
+
183
+ value.each_with_index.map { |detail, index| violation_detail!(detail, "#{name}[#{index}]") }
184
+ end
185
+
186
+ def violation_detail!(detail, name)
187
+ raise ArgumentError, "#{name} must be a Hash; got #{detail.class}" unless detail.is_a?(Hash)
188
+
189
+ detail = detail.transform_keys(&:to_s)
190
+ expected = detail["expected"]
191
+ ::Gitlab::Glaz::Govern::V1::GovernViolationDetail.new(
192
+ path: string!(detail["path"], "#{name}.path"),
193
+ expected_json: expected.nil? ? "" : JSON.generate(expected)
194
+ )
195
+ rescue JSON::JSONError => e
196
+ raise ArgumentError, "#{name}.expected must serialize to JSON; #{e.message}"
197
+ end
198
+
199
+ def actions!(value, name)
200
+ return [] if value.nil?
201
+ raise ArgumentError, "#{name} must be nil or an Array; got #{value.class}" unless value.is_a?(Array)
202
+
203
+ value.each_with_index.map { |action, index| action!(action, "#{name}[#{index}]") }
204
+ end
205
+
206
+ def action!(action, name)
207
+ raise ArgumentError, "#{name} must be a Hash; got #{action.class}" unless action.is_a?(Hash)
208
+
209
+ action = action.transform_keys(&:to_s)
210
+ value = action["value"]
211
+
212
+ ::Gitlab::Glaz::Govern::V1::GovernPolicyAction.new(
213
+ action_type: string!(action["type"], "#{name}.type"),
214
+ value_json: value.nil? ? "" : json!(value, "#{name}.value")
215
+ )
216
+ end
217
+
84
218
  def valid_store_base_url?(parsed_uri)
85
219
  parsed_uri.is_a?(URI::HTTP) && !parsed_uri.host.to_s.empty? &&
86
220
  parsed_uri.userinfo.nil? && parsed_uri.query.nil? && parsed_uri.fragment.nil? &&
@@ -172,24 +306,46 @@ module Gitlab
172
306
  # @param store_authorization [String, nil] the `Authorization` header
173
307
  # value the lookup presents to the Policy Store, verbatim (target
174
308
  # scheme `"Bearer <jwt>"`). nil sends no header. Never logged or
175
- # echoed in errors.
309
+ # echoed in errors. Ignored when +policies+ is given.
310
+ # @param policies [Array<Hash>, nil] the policies to evaluate, for a
311
+ # caller that already has them. When given, the engine evaluates
312
+ # exactly these and never contacts the store; an empty Array is a
313
+ # successful, empty evaluation. nil fetches them from the store.
314
+ # Pass the active policies of +trigger+ in the context's
315
+ # organization, each shaped like the Policy Store's policy entity
316
+ # (String or Symbol keys): `id`, `name`, `trigger_type`,
317
+ # `organization_id`, `namespace_id` (required; nil for an
318
+ # organization-level policy), `mode` (`"enforce"`, `"audit"` or
319
+ # `"warn"`), `scope_rego` (nil or empty when unscoped),
320
+ # `scope_dimensions` (Array<String>, or nil when unknown),
321
+ # `policy_rego`, `actions` (`{ type:, value: }` entries) and
322
+ # `violation_details` (nil, or `{ path:, expected: }` entries: the
323
+ # constraints the store saved from {#validate}, forwarded onto the
324
+ # policy's decision; `path` is a String and `expected`, nil when the
325
+ # policy names no compliant value, must serialize to JSON). Other
326
+ # keys are ignored.
176
327
  # @raise [ArgumentError] for a caller fault: wrong argument types, a
177
328
  # document that does not serialize to JSON, a blank trigger, a blank
178
329
  # +principal+ or +resource+ (or one exceeding 64 KiB or carrying a
179
330
  # newline), missing organization scope, an oversized input document,
180
- # or an engine constructed without a store.
331
+ # a policy in +policies+ that is malformed or belongs to another
332
+ # trigger, organization or namespace, more than the per-trigger cap
333
+ # of them, or an engine without a store called without +policies+.
181
334
  # @raise [RuntimeError] for a backend fault: the store lookup failed
182
335
  # or was rejected, or resolved more policies than the per-trigger
183
336
  # cap. Never an empty batch, which would read as "nothing to
184
- # enforce".
337
+ # enforce". Also when the engine stamps the response dry_run.
185
338
  # @return [Hash] `{ decisions: Array<Hash>, undecided_policy_ids:
186
- # Array<Integer>, identifier: String }`. +decisions+ has one entry
339
+ # Array<Integer>, identifier: String,
340
+ # dry_run: Boolean }`. +decisions+ has one entry
187
341
  # per resolved, in-scope policy, in lookup order; empty means
188
342
  # nothing applies. Each decision is `{ policy_id: Integer, matched:
189
343
  # Boolean, actions: Array<Hash>, reasons: Array<Hash>, error: String
190
- # | nil }`; each action `{ action_type: String, message: String |
191
- # nil, params: Hash, gating: Boolean }`; each reason `{ message:
192
- # String | nil, details: Hash }`. +actions+ are exactly those the
344
+ # | nil, name: String, mode: String, violation_details: Array<Hash> }`; each violation
345
+ # detail is `{ path: String, expected: Object }`, with +expected+ absent when the
346
+ # policy names no compliant value; each
347
+ # action `{ action_type: String, message: String | nil, params: Hash, gating: Boolean }`; each
348
+ # reason `{ message: String | nil, details: Hash }`. +actions+ are exactly those the
193
349
  # store configured on the policy, emitted when it matched. A non-nil
194
350
  # +error+ means the policy could not be decided (broken, or over the
195
351
  # per-evaluation time budget); +matched+ is then false and its id is
@@ -201,34 +357,90 @@ module Gitlab
201
357
  #
202
358
  # Verdict: deny when any action is gating or any policy is
203
359
  # undecided, allow otherwise. +matched+ alone decides nothing.
204
- def evaluate(trigger:, context:, principal:, resource:, data: nil, store_authorization: nil)
205
- if @store_base_url.nil?
206
- raise ArgumentError, "this engine has no Policy Store to resolve policies from: pass store_base_url: to " \
207
- "Gitlab::Glaz::GovernPolicyEngine.new, or for the shared engine assign " \
208
- "Gitlab::Glaz::GovernPolicyEngine.policy_store_url or set GLAZ_POLICY_STORE_URL " \
209
- "(e.g. \"https://gitlab.com/api/v4\")"
210
- end
360
+ # +dry_run+ is always false here; see #evaluate_dry_run to preview.
361
+ def evaluate(trigger:, context:, principal:, resource:, data: nil, store_authorization: nil, policies: nil)
362
+ request = evaluation_request(
363
+ trigger, context, principal, resource, data, store_authorization, policies, dry_run: false
364
+ )
365
+
366
+ response = ::Gitlab::Glaz::Govern::V1::EvaluateGovernPolicyResponse.decode(native.evaluate_policies(request))
367
+ # A dry-run stamp on an enforcement response would let a host skip
368
+ # recording a real decision, so treat it as an engine fault.
369
+ raise "enforcement evaluation response is stamped dry_run" if response.dry_run
370
+
371
+ evaluation_result(response)
372
+ end
373
+
374
+ # Preview what #evaluate would decide, without enforcing it. A method
375
+ # of its own, never a parameter of #evaluate (GOVERN-004): a dry run is
376
+ # never a gate, so callers must not deny on its result, and nothing
377
+ # records it. Takes the same arguments, raises the same errors and
378
+ # returns the same Hash as #evaluate, with +dry_run+ true.
379
+ #
380
+ # @raise [RuntimeError] additionally, when the response does not echo
381
+ # the dry-run flag, so a dry run can never be mistaken for a verdict.
382
+ # @return [Hash] see #evaluate.
383
+ def evaluate_dry_run(
384
+ trigger:, context:, principal:, resource:, data: nil, store_authorization: nil,
385
+ policies: nil)
386
+ request = evaluation_request(
387
+ trigger, context, principal, resource, data, store_authorization, policies, dry_run: true
388
+ )
389
+
390
+ result = evaluation_result(
391
+ ::Gitlab::Glaz::Govern::V1::EvaluateGovernPolicyResponse.decode(native.evaluate_policies_dry_run(request))
392
+ )
393
+ raise "dry-run evaluation response did not echo dry_run" unless result[:dry_run]
394
+
395
+ result
396
+ end
211
397
 
212
- encoded = ::Glaz::Govern::V1::EvaluateGovernPolicyRequest.encode(
213
- ::Glaz::Govern::V1::EvaluateGovernPolicyRequest.new(
398
+ # List every policy that could apply to +trigger+ in the context's
399
+ # organization, each with whether it applies to the context supplied.
400
+ # A different question from #evaluate ("what must I enforce"): it needs
401
+ # only the trigger and the lookup keys (`organization.id`, plus the
402
+ # namespace ids where the trigger needs them), runs no policy's rules
403
+ # and returns no decisions, so nothing in it can be gated on.
404
+ #
405
+ # A policy whose scope reads a context path the caller did not supply
406
+ # is never evaluated, because a missing path would read as "does not
407
+ # apply". It comes back as `"might_apply"` with those paths in
408
+ # +missing_dimensions+: supply them and call again. An explicit nil in
409
+ # the context counts as absent; an empty Array is a value.
410
+ #
411
+ # @param trigger [String] the event, e.g. "deployment_requested".
412
+ # @param context [Hash] whatever context the caller has (max 1 MiB as
413
+ # JSON). Must carry `organization.id`, plus the namespace ids for a
414
+ # trigger whose policies belong to namespaces, as on #evaluate.
415
+ # @param store_authorization [String, nil] as on #evaluate.
416
+ # @param policies [Array<Hash>, nil] as on #evaluate.
417
+ # @raise [ArgumentError] for a caller fault, as on #evaluate (there is no
418
+ # +principal+ or +resource+ to check).
419
+ # @raise [RuntimeError] for a backend fault, as on #evaluate, or a row
420
+ # whose applicability the engine left unstated without saying why.
421
+ # @return [Hash] `{ policies: Array<Hash> }`, one entry per resolved
422
+ # policy in lookup order, each `{ policy_id: Integer, name: String,
423
+ # applicability: "applies" | "not_applicable" | "might_apply" | nil,
424
+ # missing_dimensions: Array<String>, error: String | nil }`.
425
+ # +missing_dimensions+ is set only with `"might_apply"`, and may be
426
+ # empty when the policy's scope paths are unknown. +applicability+
427
+ # is nil only alongside an +error+: the policy's scope could not be
428
+ # evaluated, so it is neither included nor ruled out. +name+ is for
429
+ # display; key on +policy_id+.
430
+ def list_policies(trigger:, context:, store_authorization: nil, policies: nil)
431
+ require_policy_source!(policies)
432
+
433
+ encoded = ::Gitlab::Glaz::Govern::V1::ListGovernPoliciesRequest.encode(
434
+ ::Gitlab::Glaz::Govern::V1::ListGovernPoliciesRequest.new(
214
435
  trigger: Validation.string!(trigger, "trigger"),
215
436
  input_json: Validation.json!(context, "context"),
216
- data_json: data.nil? ? "" : Validation.json!(data, "data"),
217
- principal: Validation.string!(principal, "principal"),
218
- resource: Validation.string!(resource, "resource"),
219
- store_authorization: Validation.store_authorization!(store_authorization)
437
+ store_authorization: Validation.store_authorization!(store_authorization),
438
+ policies: Validation.policies!(policies)
220
439
  )
221
440
  )
441
+ response = ::Gitlab::Glaz::Govern::V1::ListGovernPoliciesResponse.decode(native.list_policies(encoded))
222
442
 
223
- response = ::Glaz::Govern::V1::EvaluateGovernPolicyResponse.decode(
224
- native.evaluate_policies(encoded)
225
- )
226
-
227
- {
228
- decisions: response.decisions.map { |decision| decision_hash(decision) },
229
- undecided_policy_ids: response.undecided_policy_ids.to_a,
230
- identifier: response.identifier
231
- }
443
+ { policies: response.policies.map { |row| applicability_hash(row) } }
232
444
  end
233
445
 
234
446
  # Parse and compile a Rego policy without evaluating it. A policy that
@@ -237,24 +449,32 @@ module Gitlab
237
449
  # actionable feedback.
238
450
  #
239
451
  # @param policy_rego [String] Rego source (max 64 KiB).
240
- # @raise [ArgumentError] when +policy_rego+ is not a String.
241
- # @return [Hash] `{ valid: Boolean, errors: Array<Hash> }`; each error
242
- # is `{ message: String, location: String }` with +location+ in
243
- # `"LINE:COL"` format, or `""` when none is available.
244
- def validate(policy_rego:)
245
- encoded = ::Glaz::Govern::V1::ValidateGovernPolicyRequest.encode(
246
- ::Glaz::Govern::V1::ValidateGovernPolicyRequest.new(
247
- policy_rego: Validation.string!(policy_rego, "policy_rego")
452
+ # @param trigger [String, nil] the trigger the policy is authored for. When
453
+ # given and the engine embeds a schema for it, every extracted detail's
454
+ # path and expected value are checked against that schema.
455
+ # @raise [ArgumentError] when +policy_rego+ or a given +trigger+ is not a String.
456
+ # @return [Hash] `{ valid: Boolean, errors: Array<Hash>,
457
+ # violation_details: Array<Hash> }`; each error is `{ message:
458
+ # String, location: String }` with +location+ in `"LINE:COL"`
459
+ # format, or `""` when none is available. +violation_details+ has
460
+ # the shape described on #evaluate and is empty for an invalid
461
+ # policy.
462
+ def validate(policy_rego:, trigger: nil)
463
+ encoded = ::Gitlab::Glaz::Govern::V1::ValidateGovernPolicyRequest.encode(
464
+ ::Gitlab::Glaz::Govern::V1::ValidateGovernPolicyRequest.new(
465
+ policy_rego: Validation.string!(policy_rego, "policy_rego"),
466
+ trigger: trigger.nil? ? "" : Validation.string!(trigger, "trigger")
248
467
  )
249
468
  )
250
469
 
251
- response = ::Glaz::Govern::V1::ValidateGovernPolicyResponse.decode(
470
+ response = ::Gitlab::Glaz::Govern::V1::ValidateGovernPolicyResponse.decode(
252
471
  native.validate(encoded)
253
472
  )
254
473
 
255
474
  {
256
475
  valid: response.valid,
257
- errors: response.errors.map { |e| { message: e.message, location: e.location } }
476
+ errors: response.errors.map { |e| { message: e.message, location: e.location } },
477
+ violation_details: response.violation_details.map { |detail| violation_detail_hash(detail) }
258
478
  }
259
479
  end
260
480
 
@@ -275,8 +495,8 @@ module Gitlab
275
495
  # whatever the query evaluated to (parsed JSON, String keys); nil
276
496
  # when +error+ is set.
277
497
  def debug_evaluate(policy_rego:, context:, query:, data: nil)
278
- encoded = ::Glaz::Govern::V1::EvaluateGovernPolicyDebugRequest.encode(
279
- ::Glaz::Govern::V1::EvaluateGovernPolicyDebugRequest.new(
498
+ encoded = ::Gitlab::Glaz::Govern::V1::EvaluateGovernPolicyDebugRequest.encode(
499
+ ::Gitlab::Glaz::Govern::V1::EvaluateGovernPolicyDebugRequest.new(
280
500
  policy_rego: Validation.string!(policy_rego, "policy_rego"),
281
501
  query: Validation.string!(query, "query"),
282
502
  input_json: Validation.json!(context, "context"),
@@ -312,6 +532,41 @@ module Gitlab
312
532
  @native
313
533
  end
314
534
 
535
+ def require_policy_source!(policies)
536
+ return unless @store_base_url.nil? && policies.nil?
537
+
538
+ raise ArgumentError, "this engine has no Policy Store to resolve policies from: pass the policies " \
539
+ "as policies:, pass store_base_url: to Gitlab::Glaz::GovernPolicyEngine.new, or for the " \
540
+ "shared engine assign Gitlab::Glaz::GovernPolicyEngine.policy_store_url or set GLAZ_POLICY_STORE_URL " \
541
+ "(e.g. \"https://gitlab.com/api/v4\")"
542
+ end
543
+
544
+ def evaluation_request(trigger, context, principal, resource, data, store_authorization, policies, dry_run:)
545
+ require_policy_source!(policies)
546
+
547
+ ::Gitlab::Glaz::Govern::V1::EvaluateGovernPolicyRequest.encode(
548
+ ::Gitlab::Glaz::Govern::V1::EvaluateGovernPolicyRequest.new(
549
+ trigger: Validation.string!(trigger, "trigger"),
550
+ input_json: Validation.json!(context, "context"),
551
+ data_json: data.nil? ? "" : Validation.json!(data, "data"),
552
+ principal: Validation.string!(principal, "principal"),
553
+ resource: Validation.string!(resource, "resource"),
554
+ store_authorization: Validation.store_authorization!(store_authorization),
555
+ policies: Validation.policies!(policies),
556
+ dry_run: dry_run
557
+ )
558
+ )
559
+ end
560
+
561
+ def evaluation_result(response)
562
+ {
563
+ decisions: response.decisions.map { |decision| decision_hash(decision) },
564
+ undecided_policy_ids: response.undecided_policy_ids.to_a,
565
+ identifier: response.identifier,
566
+ dry_run: response.dry_run
567
+ }
568
+ end
569
+
315
570
  def build_native
316
571
  ::Glaz::Native::GovernPolicyEngine.new(@store_base_url, @connect_timeout_ms, @timeout_ms)
317
572
  end
@@ -322,10 +577,46 @@ module Gitlab
322
577
  matched: decision.matched,
323
578
  actions: decision.actions.map { |action| action_hash(action) },
324
579
  reasons: decision.reasons.map { |reason| reason_hash(reason) },
325
- error: Validation.presence(decision.error)
580
+ error: Validation.presence(decision.error),
581
+ name: decision.name,
582
+ mode: Validation.mode_name(decision.mode),
583
+ violation_details: decision.violation_details.map { |detail| violation_detail_hash(detail) }
326
584
  }
327
585
  end
328
586
 
587
+ APPLICABILITIES = {
588
+ APPLICABILITY_APPLIES: "applies",
589
+ APPLICABILITY_NOT_APPLICABLE: "not_applicable",
590
+ APPLICABILITY_MIGHT_APPLY: "might_apply"
591
+ }.freeze
592
+ private_constant :APPLICABILITIES
593
+
594
+ # UNSPECIFIED is the engine's "not stated", which it sends only with an
595
+ # error. Without one it would read as an answer, so it is a fault.
596
+ def applicability_hash(row)
597
+ error = Validation.presence(row.error)
598
+ applicability = APPLICABILITIES[row.applicability]
599
+ if applicability.nil? && error.nil?
600
+ raise "policy #{row.policy_id} has no applicability and no error; got #{row.applicability.inspect}"
601
+ end
602
+
603
+ {
604
+ policy_id: row.policy_id,
605
+ name: row.name,
606
+ applicability: applicability,
607
+ missing_dimensions: row.missing_dimensions.to_a,
608
+ error: error
609
+ }
610
+ end
611
+
612
+ # +expected_json+ may encode a scalar, which json 2 parses as a
613
+ # document; +quirks_mode+ only matters to older json versions.
614
+ def violation_detail_hash(detail)
615
+ return { path: detail.path } if detail.expected_json.empty?
616
+
617
+ { path: detail.path, expected: JSON.parse(detail.expected_json, max_nesting: false, quirks_mode: true) }
618
+ end
619
+
329
620
  # The native layer already bounds nesting, so parse without a limit.
330
621
  def action_hash(action)
331
622
  {
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  module Glaz
5
- VERSION = "2.0.0"
5
+ VERSION = "2.2.0"
6
6
  end
7
7
  end
data/lib/gitlab/glaz.rb CHANGED
@@ -16,7 +16,12 @@ module Gitlab
16
16
 
17
17
  # Returns the default roles embedded in the native extension at build time,
18
18
  # sourced from the glaz-roles crate's roles/**/*.yml definitions. Each entry
19
- # is a hash: { id: String, name: String, permissions: Array<String> }.
19
+ # is a hash:
20
+ # { id: String, key: String, name: String, permissions: Array<String> }.
21
+ #
22
+ # :key is the role's stable machine identifier (e.g. "artifact_admin"):
23
+ # reference a role by it rather than hardcoding :id or matching on :name,
24
+ # which is human-facing and may be reworded.
20
25
  def roles
21
26
  @roles ||= ::Glaz.roles.each do |role|
22
27
  role[:permissions].freeze
@@ -27,7 +32,7 @@ module Gitlab
27
32
  # Returns GovernPolicyEngine.shared, the engine configured through
28
33
  # GovernPolicyEngine.policy_store_url= / GLAZ_POLICY_STORE_URL. It is
29
34
  # rebuilt when that configuration changes, so call this each time rather
30
- # than caching the result.
35
+ # than caching the result. Not needed when #evaluate gets +policies:+.
31
36
  def govern_policy_engine
32
37
  GovernPolicyEngine.shared
33
38
  end
@@ -1,33 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
  # Generated by the protocol buffer compiler. DO NOT EDIT!
3
- # source: proto/govern.proto
3
+ # source: gitlab/glaz/govern/v1/govern.proto
4
4
 
5
5
  require 'google/protobuf'
6
6
 
7
7
 
8
- descriptor_data = "\n\x12proto/govern.proto\x12\x0eglaz.govern.v1\"m\n EvaluateGovernPolicyDebugRequest\x12\x13\n\x0bpolicy_rego\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x12\n\ninput_json\x18\x03 \x01(\t\x12\x11\n\tdata_json\x18\x04 \x01(\t\"Y\n\x0cGovernAction\x12\x13\n\x0b\x61\x63tion_type\x18\x01 \x01(\t\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x13\n\x0bparams_json\x18\x03 \x01(\t\x12\x0e\n\x06gating\x18\x04 \x01(\x08\"5\n\x0cGovernReason\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x14\n\x0c\x64\x65tails_json\x18\x02 \x01(\t\"2\n\x1bValidateGovernPolicyRequest\x12\x13\n\x0bpolicy_rego\x18\x01 \x01(\t\":\n\x15GovernValidationError\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x10\n\x08location\x18\x02 \x01(\t\"d\n\x1cValidateGovernPolicyResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x35\n\x06\x65rrors\x18\x02 \x03(\x0b\x32%.glaz.govern.v1.GovernValidationError\"\x97\x01\n\x1b\x45valuateGovernPolicyRequest\x12\x0f\n\x07trigger\x18\x01 \x01(\t\x12\x12\n\ninput_json\x18\x02 \x01(\t\x12\x11\n\tdata_json\x18\x03 \x01(\t\x12\x11\n\tprincipal\x18\x04 \x01(\t\x12\x10\n\x08resource\x18\x05 \x01(\t\x12\x1b\n\x13store_authorization\x18\x06 \x01(\t\"]\n\x19ListGovernPoliciesRequest\x12\x0f\n\x07trigger\x18\x01 \x01(\t\x12\x12\n\ninput_json\x18\x02 \x01(\t\x12\x1b\n\x13store_authorization\x18\x03 \x01(\t\"\x97\x01\n\x13PolicyApplicability\x12\x11\n\tpolicy_id\x18\x01 \x01(\x04\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x34\n\rapplicability\x18\x03 \x01(\x0e\x32\x1d.glaz.govern.v1.Applicability\x12\x1a\n\x12missing_dimensions\x18\x04 \x03(\t\x12\r\n\x05\x65rror\x18\x05 \x01(\t\"S\n\x1aListGovernPoliciesResponse\x12\x35\n\x08policies\x18\x01 \x03(\x0b\x32#.glaz.govern.v1.PolicyApplicability\"\xa1\x01\n\x0ePolicyDecision\x12\x11\n\tpolicy_id\x18\x01 \x01(\x04\x12\x0f\n\x07matched\x18\x02 \x01(\x08\x12-\n\x07\x61\x63tions\x18\x03 \x03(\x0b\x32\x1c.glaz.govern.v1.GovernAction\x12-\n\x07reasons\x18\x04 \x03(\x0b\x32\x1c.glaz.govern.v1.GovernReason\x12\r\n\x05\x65rror\x18\x05 \x01(\t\"Q\n\x10GovernDiagnostic\x12,\n\x04kind\x18\x01 \x01(\x0e\x32\x1e.glaz.govern.v1.DiagnosticKind\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xba\x01\n\x1c\x45valuateGovernPolicyResponse\x12\x31\n\tdecisions\x18\x01 \x03(\x0b\x32\x1e.glaz.govern.v1.PolicyDecision\x12\x1c\n\x14undecided_policy_ids\x18\x02 \x03(\x04\x12\x35\n\x0b\x64iagnostics\x18\x03 \x03(\x0b\x32 .glaz.govern.v1.GovernDiagnostic\x12\x12\n\nidentifier\x18\x04 \x01(\t*\x8a\x01\n\rApplicability\x12\x1d\n\x19\x41PPLICABILITY_UNSPECIFIED\x10\x00\x12\x19\n\x15\x41PPLICABILITY_APPLIES\x10\x01\x12 \n\x1c\x41PPLICABILITY_NOT_APPLICABLE\x10\x02\x12\x1d\n\x19\x41PPLICABILITY_MIGHT_APPLY\x10\x03*\x83\x01\n\x0e\x44iagnosticKind\x12\x1f\n\x1b\x44IAGNOSTIC_KIND_UNSPECIFIED\x10\x00\x12*\n&DIAGNOSTIC_KIND_INPUT_SCHEMA_VIOLATION\x10\x01\x12$\n DIAGNOSTIC_KIND_SCHEMA_NOT_FOUND\x10\x02\x42?Z=gitlab.com/gitlab-org/govern/glaz-client-go/governv1;governv1b\x06proto3"
8
+ descriptor_data = "\n\"gitlab/glaz/govern/v1/govern.proto\x12\x15gitlab.glaz.govern.v1\"m\n EvaluateGovernPolicyDebugRequest\x12\x13\n\x0bpolicy_rego\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x12\n\ninput_json\x18\x03 \x01(\t\x12\x11\n\tdata_json\x18\x04 \x01(\t\"Y\n\x0cGovernAction\x12\x13\n\x0b\x61\x63tion_type\x18\x01 \x01(\t\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x13\n\x0bparams_json\x18\x03 \x01(\t\x12\x0e\n\x06gating\x18\x04 \x01(\x08\"5\n\x0cGovernReason\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x14\n\x0c\x64\x65tails_json\x18\x02 \x01(\t\"C\n\x1bValidateGovernPolicyRequest\x12\x13\n\x0bpolicy_rego\x18\x01 \x01(\t\x12\x0f\n\x07trigger\x18\x02 \x01(\t\":\n\x15GovernValidationError\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x10\n\x08location\x18\x02 \x01(\t\"\xb4\x01\n\x1cValidateGovernPolicyResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12<\n\x06\x65rrors\x18\x02 \x03(\x0b\x32,.gitlab.glaz.govern.v1.GovernValidationError\x12G\n\x11violation_details\x18\x03 \x03(\x0b\x32,.gitlab.glaz.govern.v1.GovernViolationDetail\"<\n\x15GovernViolationDetail\x12\x0c\n\x04path\x18\x01 \x01(\t\x12\x15\n\rexpected_json\x18\x02 \x01(\t\"=\n\x12GovernPolicyAction\x12\x13\n\x0b\x61\x63tion_type\x18\x01 \x01(\t\x12\x12\n\nvalue_json\x18\x02 \x01(\t\"&\n\x15GovernScopeDimensions\x12\r\n\x05paths\x18\x01 \x03(\t\"\xc3\x03\n\x0cGovernPolicy\x12\n\n\x02id\x18\x01 \x01(\x04\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0f\n\x07trigger\x18\x03 \x01(\t\x12\x17\n\x0forganization_id\x18\x04 \x01(\x04\x12:\n\x04mode\x18\x05 \x01(\x0e\x32,.gitlab.glaz.govern.v1.GovernEnforcementMode\x12\x12\n\nscope_rego\x18\x06 \x01(\t\x12\x46\n\x10scope_dimensions\x18\x07 \x01(\x0b\x32,.gitlab.glaz.govern.v1.GovernScopeDimensions\x12\x13\n\x0bpolicy_rego\x18\x08 \x01(\t\x12:\n\x07\x61\x63tions\x18\t \x03(\x0b\x32).gitlab.glaz.govern.v1.GovernPolicyAction\x12\x16\n\x0cnamespace_id\x18\n \x01(\x04H\x00\x12\x1c\n\x12organization_level\x18\x0b \x01(\x08H\x00\x12G\n\x11violation_details\x18\x0c \x03(\x0b\x32,.gitlab.glaz.govern.v1.GovernViolationDetailB\x07\n\x05owner\"H\n\x0fGovernPolicySet\x12\x35\n\x08policies\x18\x01 \x03(\x0b\x32#.gitlab.glaz.govern.v1.GovernPolicy\"\xe2\x01\n\x1b\x45valuateGovernPolicyRequest\x12\x0f\n\x07trigger\x18\x01 \x01(\t\x12\x12\n\ninput_json\x18\x02 \x01(\t\x12\x11\n\tdata_json\x18\x03 \x01(\t\x12\x11\n\tprincipal\x18\x04 \x01(\t\x12\x10\n\x08resource\x18\x05 \x01(\t\x12\x1b\n\x13store_authorization\x18\x06 \x01(\t\x12\x38\n\x08policies\x18\x07 \x01(\x0b\x32&.gitlab.glaz.govern.v1.GovernPolicySet\x12\x0f\n\x07\x64ry_run\x18\x08 \x01(\x08\"\x97\x01\n\x19ListGovernPoliciesRequest\x12\x0f\n\x07trigger\x18\x01 \x01(\t\x12\x12\n\ninput_json\x18\x02 \x01(\t\x12\x1b\n\x13store_authorization\x18\x03 \x01(\t\x12\x38\n\x08policies\x18\x04 \x01(\x0b\x32&.gitlab.glaz.govern.v1.GovernPolicySet\"\x9e\x01\n\x13PolicyApplicability\x12\x11\n\tpolicy_id\x18\x01 \x01(\x04\x12\x0c\n\x04name\x18\x02 \x01(\t\x12;\n\rapplicability\x18\x03 \x01(\x0e\x32$.gitlab.glaz.govern.v1.Applicability\x12\x1a\n\x12missing_dimensions\x18\x04 \x03(\t\x12\r\n\x05\x65rror\x18\x05 \x01(\t\"Z\n\x1aListGovernPoliciesResponse\x12<\n\x08policies\x18\x01 \x03(\x0b\x32*.gitlab.glaz.govern.v1.PolicyApplicability\"\xc2\x02\n\x0ePolicyDecision\x12\x11\n\tpolicy_id\x18\x01 \x01(\x04\x12\x0f\n\x07matched\x18\x02 \x01(\x08\x12\x34\n\x07\x61\x63tions\x18\x03 \x03(\x0b\x32#.gitlab.glaz.govern.v1.GovernAction\x12\x34\n\x07reasons\x18\x04 \x03(\x0b\x32#.gitlab.glaz.govern.v1.GovernReason\x12\r\n\x05\x65rror\x18\x05 \x01(\t\x12\x0c\n\x04name\x18\x06 \x01(\t\x12:\n\x04mode\x18\x07 \x01(\x0e\x32,.gitlab.glaz.govern.v1.GovernEnforcementMode\x12G\n\x11violation_details\x18\x08 \x03(\x0b\x32,.gitlab.glaz.govern.v1.GovernViolationDetail\"X\n\x10GovernDiagnostic\x12\x33\n\x04kind\x18\x01 \x01(\x0e\x32%.gitlab.glaz.govern.v1.DiagnosticKind\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xd9\x01\n\x1c\x45valuateGovernPolicyResponse\x12\x38\n\tdecisions\x18\x01 \x03(\x0b\x32%.gitlab.glaz.govern.v1.PolicyDecision\x12\x1c\n\x14undecided_policy_ids\x18\x02 \x03(\x04\x12<\n\x0b\x64iagnostics\x18\x03 \x03(\x0b\x32\'.gitlab.glaz.govern.v1.GovernDiagnostic\x12\x12\n\nidentifier\x18\x04 \x01(\t\x12\x0f\n\x07\x64ry_run\x18\x05 \x01(\x08*\xaa\x01\n\x15GovernEnforcementMode\x12\'\n#GOVERN_ENFORCEMENT_MODE_UNSPECIFIED\x10\x00\x12!\n\x1dGOVERN_ENFORCEMENT_MODE_AUDIT\x10\x01\x12 \n\x1cGOVERN_ENFORCEMENT_MODE_WARN\x10\x02\x12#\n\x1fGOVERN_ENFORCEMENT_MODE_ENFORCE\x10\x03*\x8a\x01\n\rApplicability\x12\x1d\n\x19\x41PPLICABILITY_UNSPECIFIED\x10\x00\x12\x19\n\x15\x41PPLICABILITY_APPLIES\x10\x01\x12 \n\x1c\x41PPLICABILITY_NOT_APPLICABLE\x10\x02\x12\x1d\n\x19\x41PPLICABILITY_MIGHT_APPLY\x10\x03*\x83\x01\n\x0e\x44iagnosticKind\x12\x1f\n\x1b\x44IAGNOSTIC_KIND_UNSPECIFIED\x10\x00\x12*\n&DIAGNOSTIC_KIND_INPUT_SCHEMA_VIOLATION\x10\x01\x12$\n DIAGNOSTIC_KIND_SCHEMA_NOT_FOUND\x10\x02\x62\x06proto3"
9
9
 
10
10
  pool = ::Google::Protobuf::DescriptorPool.generated_pool
11
11
  pool.add_serialized_file(descriptor_data)
12
12
 
13
- module Glaz
14
- module Govern
15
- module V1
16
- EvaluateGovernPolicyDebugRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("glaz.govern.v1.EvaluateGovernPolicyDebugRequest").msgclass
17
- GovernAction = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("glaz.govern.v1.GovernAction").msgclass
18
- GovernReason = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("glaz.govern.v1.GovernReason").msgclass
19
- ValidateGovernPolicyRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("glaz.govern.v1.ValidateGovernPolicyRequest").msgclass
20
- GovernValidationError = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("glaz.govern.v1.GovernValidationError").msgclass
21
- ValidateGovernPolicyResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("glaz.govern.v1.ValidateGovernPolicyResponse").msgclass
22
- EvaluateGovernPolicyRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("glaz.govern.v1.EvaluateGovernPolicyRequest").msgclass
23
- ListGovernPoliciesRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("glaz.govern.v1.ListGovernPoliciesRequest").msgclass
24
- PolicyApplicability = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("glaz.govern.v1.PolicyApplicability").msgclass
25
- ListGovernPoliciesResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("glaz.govern.v1.ListGovernPoliciesResponse").msgclass
26
- PolicyDecision = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("glaz.govern.v1.PolicyDecision").msgclass
27
- GovernDiagnostic = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("glaz.govern.v1.GovernDiagnostic").msgclass
28
- EvaluateGovernPolicyResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("glaz.govern.v1.EvaluateGovernPolicyResponse").msgclass
29
- Applicability = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("glaz.govern.v1.Applicability").enummodule
30
- DiagnosticKind = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("glaz.govern.v1.DiagnosticKind").enummodule
13
+ module Gitlab
14
+ module Glaz
15
+ module Govern
16
+ module V1
17
+ EvaluateGovernPolicyDebugRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.EvaluateGovernPolicyDebugRequest").msgclass
18
+ GovernAction = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.GovernAction").msgclass
19
+ GovernReason = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.GovernReason").msgclass
20
+ ValidateGovernPolicyRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.ValidateGovernPolicyRequest").msgclass
21
+ GovernValidationError = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.GovernValidationError").msgclass
22
+ ValidateGovernPolicyResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.ValidateGovernPolicyResponse").msgclass
23
+ GovernViolationDetail = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.GovernViolationDetail").msgclass
24
+ GovernPolicyAction = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.GovernPolicyAction").msgclass
25
+ GovernScopeDimensions = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.GovernScopeDimensions").msgclass
26
+ GovernPolicy = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.GovernPolicy").msgclass
27
+ GovernPolicySet = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.GovernPolicySet").msgclass
28
+ EvaluateGovernPolicyRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.EvaluateGovernPolicyRequest").msgclass
29
+ ListGovernPoliciesRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.ListGovernPoliciesRequest").msgclass
30
+ PolicyApplicability = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.PolicyApplicability").msgclass
31
+ ListGovernPoliciesResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.ListGovernPoliciesResponse").msgclass
32
+ PolicyDecision = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.PolicyDecision").msgclass
33
+ GovernDiagnostic = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.GovernDiagnostic").msgclass
34
+ EvaluateGovernPolicyResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.EvaluateGovernPolicyResponse").msgclass
35
+ GovernEnforcementMode = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.GovernEnforcementMode").enummodule
36
+ Applicability = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.Applicability").enummodule
37
+ DiagnosticKind = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.glaz.govern.v1.DiagnosticKind").enummodule
38
+ end
31
39
  end
32
40
  end
33
41
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-glaz
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.2.0
5
5
  platform: x86_64-linux-gnu
6
6
  authors:
7
7
  - group::authorization
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-23 00:00:00.000000000 Z
11
+ date: 2026-11-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: google-protobuf