gitlab-glaz 1.2.0 → 2.0.0

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.
data/Cargo.toml CHANGED
@@ -4,7 +4,7 @@ members = ["ext/*"]
4
4
  resolver = "2"
5
5
 
6
6
  [workspace.package]
7
- version = "1.2.0"
7
+ version = "2.0.0"
8
8
  edition = "2024"
9
9
 
10
10
  # Shared dependencies for all crates
@@ -12,6 +12,6 @@ edition = "2024"
12
12
  magnus = { version = "0.8.2", features = ["rb-sys"] }
13
13
  rb-sys = "0.9.128"
14
14
  prost = "0.14"
15
- glaz-module = { git = "https://gitlab.com/gitlab-org/auth/glaz.git", rev = "af9019a186fbb42cede0ad5ba7496aff2f88cb41" }
16
- glaz-roles = { git = "https://gitlab.com/gitlab-org/auth/glaz.git", rev = "af9019a186fbb42cede0ad5ba7496aff2f88cb41" }
17
- glaz-proto = { git = "https://gitlab.com/gitlab-org/auth/glaz.git", rev = "af9019a186fbb42cede0ad5ba7496aff2f88cb41" }
15
+ glaz-module = { git = "https://gitlab.com/gitlab-org/auth/glaz.git", rev = "0c90ffcd6e6f74b7a1998e196e7d324ad7d54b56" }
16
+ glaz-roles = { git = "https://gitlab.com/gitlab-org/auth/glaz.git", rev = "0c90ffcd6e6f74b7a1998e196e7d324ad7d54b56" }
17
+ glaz-proto = { git = "https://gitlab.com/gitlab-org/auth/glaz.git", rev = "0c90ffcd6e6f74b7a1998e196e7d324ad7d54b56" }
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > [!WARNING]
4
4
  > This gem is currently designed entirely for internal use at GitLab.
5
5
 
6
- Ruby bindings for the [Glaz](https://gitlab.com/gitlab-org/auth/glaz) authorization engine. A thin [Magnus](https://github.com/matsadler/magnus) FFI extension exposes the Rust `glaz-module` engine to Ruby, so Cedar policy checks run in-process.
6
+ Ruby bindings for the [Glaz](https://gitlab.com/gitlab-org/auth/glaz) authorization and governance engines. A thin [Magnus](https://github.com/matsadler/magnus) FFI extension exposes the Rust `glaz-module` engines to Ruby: Cedar permission checks run in-process, and Rego governance policies are fetched from the Policy Store and evaluated in-process.
7
7
 
8
8
  ## Installation
9
9
 
@@ -37,6 +37,30 @@ The schema is loaded once at construction and cannot change; build a new engine
37
37
 
38
38
  Schemas must declare `User` and `Resource` entities (checks build `User::"<subject_uuid>"` and `Resource::"<object_uuid>"` UIDs), and every action's context type must include `permissions: Set<Action>`, which the engine injects with the checked action.
39
39
 
40
+ ### Governance policies
41
+
42
+ `Gitlab::Glaz::GovernPolicyEngine` evaluates the Rego governance policies that apply to an event trigger. The engine fetches them itself from the Policy Store REST API, so name the store first:
43
+
44
+ ```ruby
45
+ Gitlab::Glaz::GovernPolicyEngine.policy_store_url = 'https://gitlab.com/api/v4'
46
+ # or export GLAZ_POLICY_STORE_URL; a bare http(s) URL, no credentials
47
+
48
+ result = Gitlab::Glaz.govern_policy_engine.evaluate(
49
+ trigger: 'deployment_requested',
50
+ context: { organization: { id: 'organizations/1' }, environment: { tier: 'production' }, approvals: [] },
51
+ principal: 'users/42', # who the decision is about
52
+ resource: 'organizations/1/deployments/9', # what the decision is about
53
+ store_authorization: "Bearer #{token}" # per request; nil sends no Authorization header
54
+ )
55
+ # => { decisions: [{ policy_id: 1, matched: true, actions: [{ action_type: 'block', gating: true, ... }], ... }],
56
+ # undecided_policy_ids: [],
57
+ # identifier: 'ed9cb624-7d8f-51eb-ad5a-ef394efcee74' }
58
+ ```
59
+
60
+ Each call makes one `GET {store_url}/organizations/{id}/security/policy_store?trigger_type={trigger}` (500 ms connect / 2 s total timeout by default, no redirects, TLS against the platform trust store, `*_PROXY`/`NO_PROXY` honoured), so egress rules must allow it. Deny when any returned action is `gating` or `undecided_policy_ids` is non-empty; allow otherwise. Caller mistakes raise `ArgumentError`, store faults raise `RuntimeError`, and a policy that could not be decided (broken, or over the per-evaluation time budget) is reported in-band on its decision's `error` and listed in `undecided_policy_ids`. `data` must not define a policy's own `violation`/`deny`/`allow` rules. `principal` and `resource` name the decision subject (each trimmed, and rejected if blank, newline-carrying, or over 64 KiB); the response's `identifier` is a stable UUIDv5 over `(principal, trigger, resource)`, so re-evaluating the same subject yields the same identifier for journaling, action deduplication, and approval correlation.
61
+
62
+ Without a store URL the shared engine still serves `#validate` (save-time parse check) and `#debug_evaluate` (raw single-policy evaluation); only trigger-keyed evaluation needs the store. See the class documentation for the full contract.
63
+
40
64
  ## Development
41
65
 
42
66
  ```shell
data/ext/glaz/Cargo.toml CHANGED
@@ -13,3 +13,4 @@ glaz-proto = { workspace = true }
13
13
  glaz-roles = { workspace = true }
14
14
  magnus = { workspace = true }
15
15
  prost = { workspace = true }
16
+ rb-sys = { workspace = true }
data/ext/glaz/src/lib.rs CHANGED
@@ -1,3 +1,9 @@
1
+ use std::ffi::c_void;
2
+ use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
3
+ use std::sync::Arc;
4
+ use std::time::Duration;
5
+
6
+ use magnus::rb_sys::AsRawValue;
1
7
  use magnus::{Error, Ruby, prelude::*};
2
8
  use prost::Message;
3
9
 
@@ -53,70 +59,106 @@ impl PermissionCheckEngine {
53
59
  }
54
60
 
55
61
  /// Thin 1:1 wrapper around `glaz_module::GovernPolicyCheckEngine`, the Rego
56
- /// governance evaluator. Stateless between calls; `evaluate` raises on
57
- /// failure (matching `PermissionCheckEngine`'s convention) and returns the
58
- /// encoded `EvaluateGovernPolicyResponse` on success — `ArgumentError` for
59
- /// bad policy/input, `RuntimeError` for an engine-side fault, see
60
- /// `map_error`; `debug_evaluate` is infallible and carries failures in-band
61
- /// in its JSON response instead, since it's a raw debugging escape hatch;
62
- /// `validate` is also infallible — policy errors are returned as
63
- /// `Ok(valid: false, errors: [...])` so the Policy Store can surface
64
- /// actionable feedback without treating a user mistake as an exceptional
65
- /// condition; a malformed protobuf payload raises `ArgumentError`, while
66
- /// internal engine failures (`NotInitialized`, `Runtime`) raise
67
- /// `RuntimeError` via `map_error`.
62
+ /// governance evaluator. The policy lookup fetches from the Policy Store
63
+ /// REST API named at construction; authentication is per request, on
64
+ /// `EvaluateGovernPolicyRequest.store_authorization`. Stateless between
65
+ /// calls. `evaluate_policies` raises on a whole-call failure (matching
66
+ /// `PermissionCheckEngine`'s convention): `ArgumentError` for caller
67
+ /// faults, `RuntimeError` for backend faults, see `map_error`.
68
+ /// `debug_evaluate` and `validate` are infallible for policy errors, which
69
+ /// they return in-band; only a malformed protobuf payload raises.
68
70
  ///
69
- /// Registered as `Glaz::Native::GovernPolicyEngine` (not
70
- /// `Glaz::GovernPolicyEngine`) specifically to avoid colliding, in name,
71
- /// with the polished `Gitlab::Glaz::GovernPolicyEngine` wrapper — this
72
- /// class is raw FFI plumbing; prefer the wrapper unless you specifically
73
- /// need `debug_evaluate`.
71
+ /// Registered as `Glaz::Native::GovernPolicyEngine` to avoid colliding in
72
+ /// name with the `Gitlab::Glaz::GovernPolicyEngine` wrapper — this class is
73
+ /// raw FFI plumbing; prefer the wrapper.
74
74
  #[magnus::wrap(class = "Glaz::Native::GovernPolicyEngine")]
75
75
  struct GovernPolicyEngine {
76
- engine: glaz_module::GovernPolicyCheckEngine,
76
+ // Shared with the per-call lookup thread, see `on_own_thread`.
77
+ engine: Arc<glaz_module::GovernPolicyCheckEngine>,
77
78
  }
78
79
 
79
80
  impl GovernPolicyEngine {
80
- fn initialize() -> Self {
81
+ /// `base_url` is the Policy Store REST API root (e.g.
82
+ /// `"https://gitlab.com/api/v4"`); `nil` timeouts keep
83
+ /// `glaz-govern`'s defaults. Without `base_url` the engine has no policy
84
+ /// source: `validate` and `debug_evaluate` work, `evaluate_policies`
85
+ /// fails closed (see `NoStore`).
86
+ fn initialize(
87
+ base_url: Option<String>,
88
+ connect_timeout_ms: Option<u64>,
89
+ timeout_ms: Option<u64>,
90
+ ) -> Self {
91
+ let Some(base_url) = base_url else {
92
+ return Self {
93
+ engine: Arc::new(glaz_module::GovernPolicyCheckEngine::with_lookup(NoStore)),
94
+ };
95
+ };
96
+ let mut config = glaz_module::StoreConfig::new(base_url);
97
+ if let Some(ms) = connect_timeout_ms {
98
+ config = config.with_connect_timeout(Duration::from_millis(ms));
99
+ }
100
+ if let Some(ms) = timeout_ms {
101
+ config = config.with_total_timeout(Duration::from_millis(ms));
102
+ }
81
103
  Self {
82
- engine: glaz_module::GovernPolicyCheckEngine::new(),
104
+ engine: Arc::new(glaz_module::GovernPolicyCheckEngine::with_store(config)),
83
105
  }
84
106
  }
85
107
 
86
- /// Evaluate using the request's `query` field. `query` must be
87
- /// non-blank; a blank query is reported in-band as an error rather than
88
- /// falling back to discovery (use `evaluate` for that).
108
+ /// Evaluate the request's `query` directly, with no
109
+ /// `violation`/`deny`/`allow` interpretation and no policy lookup. A
110
+ /// blank query is reported in-band.
111
+ ///
112
+ /// Runs with the GVL released (`without_gvl`), on its own thread where a
113
+ /// forked child needs one (`on_own_thread`): the parse it performs
114
+ /// parks the calling thread the same way `evaluate_policies`'s lookup
115
+ /// does. Not interruptible from Ruby until it returns.
89
116
  fn debug_evaluate(
90
117
  ruby: &Ruby,
91
118
  rb_self: &Self,
92
119
  proto_bytes: magnus::RString,
93
120
  ) -> Result<magnus::RString, Error> {
94
- let bytes = unsafe { proto_bytes.as_slice() };
95
- let response = rb_self.engine.debug_evaluate(bytes);
121
+ // Copy the request out of the Ruby string first: once the GVL is
122
+ // released the GC may move or free the buffer behind `as_slice`.
123
+ let request = unsafe { proto_bytes.as_slice() }.to_vec();
124
+ let engine = Arc::clone(&rb_self.engine);
125
+ let response = without_gvl(ruby, move || {
126
+ on_own_thread(move || Ok(engine.debug_evaluate(&request)))
127
+ })?
128
+ .map_err(|e| map_error(ruby, e))?;
96
129
  Ok(ruby.str_from_slice(&response))
97
130
  }
98
131
 
99
- /// Evaluate by auto-discovering the policy's `violation`, `deny`,
100
- /// and/or `allow` rules. The request's `query` field is ignored.
132
+ /// Trigger-keyed batch evaluation: takes an encoded
133
+ /// `EvaluateGovernPolicyRequest`, resolves the applicable policies from
134
+ /// the Policy Store (keyed on `trigger` and the context's
135
+ /// `organization.id`) and returns the encoded
136
+ /// `EvaluateGovernPolicyResponse`. Raises `ArgumentError` for caller
137
+ /// faults and `RuntimeError` for backend faults — never an empty batch,
138
+ /// which would read fail-open. A broken individual policy is reported
139
+ /// in-band on its decision's `error`.
101
140
  ///
102
- /// Raises on any evaluation failure rather than returning an in-band
103
- /// error, matching `PermissionCheckEngine#check_action`'s convention.
104
- /// `map_error` raises `ArgumentError` for malformed protobuf, invalid
105
- /// policy, oversized documents, undefined results, or an unrecognized
106
- /// result shape - all caller-fixable - and `RuntimeError` for an
107
- /// engine-side fault (e.g. the Rego evaluation time budget was
108
- /// exceeded), which is not.
109
- fn evaluate(
141
+ /// Runs with the GVL released (`without_gvl`), on its own thread where
142
+ /// a forked child needs one (`on_own_thread`). Not interruptible from
143
+ /// Ruby until it returns.
144
+ fn evaluate_policies(
110
145
  ruby: &Ruby,
111
146
  rb_self: &Self,
112
147
  proto_bytes: magnus::RString,
113
148
  ) -> Result<magnus::RString, Error> {
114
- let bytes = unsafe { proto_bytes.as_slice() };
115
- let response = rb_self
116
- .engine
117
- .evaluate(bytes)
118
- .map_err(|e| map_error(ruby, e))?;
119
- Ok(ruby.str_from_slice(&response.encode_to_vec()))
149
+ // Copy the request out of the Ruby string first: once the GVL is
150
+ // released the GC may move or free the buffer behind `as_slice`.
151
+ let request = unsafe { proto_bytes.as_slice() }.to_vec();
152
+ let engine = Arc::clone(&rb_self.engine);
153
+ let response = without_gvl(ruby, move || {
154
+ on_own_thread(move || {
155
+ engine
156
+ .evaluate_policies(&request)
157
+ .map(|response| response.encode_to_vec())
158
+ })
159
+ })?
160
+ .map_err(|e| map_error(ruby, e))?;
161
+ Ok(ruby.str_from_slice(&response))
120
162
  }
121
163
 
122
164
  /// Parse and compile a Rego policy without evaluating it against input.
@@ -125,17 +167,47 @@ impl GovernPolicyEngine {
125
167
  /// returned in-band as `{ valid: false, errors: [...] }` rather than
126
168
  /// raised. Only a malformed protobuf payload raises `ArgumentError`; an
127
169
  /// internal engine fault raises `RuntimeError` via `map_error`.
170
+ ///
171
+ /// Runs with the GVL released (`without_gvl`), on its own thread where a
172
+ /// forked child needs one (`on_own_thread`): the parse it performs
173
+ /// parks the calling thread the same way `evaluate_policies`'s lookup
174
+ /// does. Not interruptible from Ruby until it returns.
128
175
  fn validate(
129
176
  ruby: &Ruby,
130
177
  rb_self: &Self,
131
178
  proto_bytes: magnus::RString,
132
179
  ) -> Result<magnus::RString, Error> {
133
- let bytes = unsafe { proto_bytes.as_slice() };
134
- let response = rb_self
135
- .engine
136
- .validate(bytes)
137
- .map_err(|e| map_error(ruby, e))?;
138
- Ok(ruby.str_from_slice(&response.encode_to_vec()))
180
+ // Copy the request out of the Ruby string first: once the GVL is
181
+ // released the GC may move or free the buffer behind `as_slice`.
182
+ let request = unsafe { proto_bytes.as_slice() }.to_vec();
183
+ let engine = Arc::clone(&rb_self.engine);
184
+ let response = without_gvl(ruby, move || {
185
+ on_own_thread(move || {
186
+ engine
187
+ .validate(&request)
188
+ .map(|response| response.encode_to_vec())
189
+ })
190
+ })?
191
+ .map_err(|e| map_error(ruby, e))?;
192
+ Ok(ruby.str_from_slice(&response))
193
+ }
194
+ }
195
+
196
+ /// The lookup of an engine constructed without a Policy Store: every
197
+ /// lookup fails closed as `LookupError::Unavailable` (a `RuntimeError`), so
198
+ /// such an engine can never report "nothing to enforce". The Ruby wrapper
199
+ /// rejects the call earlier with configuration guidance.
200
+ struct NoStore;
201
+
202
+ impl glaz_module::PolicyLookup for NoStore {
203
+ fn lookup(
204
+ &self,
205
+ _key: &glaz_module::LookupKey,
206
+ _store_authorization: &str,
207
+ ) -> Result<Vec<glaz_module::StoredPolicy>, glaz_module::LookupError> {
208
+ Err(glaz_module::LookupError::Unavailable(
209
+ "no Policy Store configured for this engine".to_string(),
210
+ ))
139
211
  }
140
212
  }
141
213
 
@@ -158,6 +230,109 @@ fn roles(ruby: &Ruby) -> Result<magnus::RArray, Error> {
158
230
  Ok(array)
159
231
  }
160
232
 
233
+ /// Run `func` with the GVL released and hand its result back once the GVL
234
+ /// is held again. `func` must not touch the Ruby VM, so callers copy their
235
+ /// input out of Ruby objects first. A panic in `func` is caught at the
236
+ /// `extern "C"` boundary and resumed afterwards.
237
+ ///
238
+ /// No unblock function is registered, so `func` always runs to completion;
239
+ /// a Ruby interrupt aimed at this thread takes effect once it returns.
240
+ ///
241
+ /// Catching the panic requires `panic = "unwind"`, the default profile
242
+ /// setting; magnus's method wrappers already rely on it to turn panics into
243
+ /// Ruby exceptions, so an `abort` profile would break the extension as a
244
+ /// whole, not just this helper.
245
+ fn without_gvl<F, T>(ruby: &Ruby, func: F) -> Result<T, Error>
246
+ where
247
+ F: FnOnce() -> T + Send,
248
+ T: Send,
249
+ {
250
+ enum Call<F, T> {
251
+ Pending(F),
252
+ Done(std::thread::Result<T>),
253
+ }
254
+
255
+ unsafe extern "C" fn trampoline<F: FnOnce() -> T, T>(data: *mut c_void) -> *mut c_void {
256
+ // SAFETY: `data` is the `Call` below, which outlives this call.
257
+ let slot = unsafe { &mut *data.cast::<Option<Call<F, T>>>() };
258
+ let Some(Call::Pending(func)) = slot.take() else {
259
+ unreachable!("the trampoline runs exactly once, on a pending call");
260
+ };
261
+ *slot = Some(Call::Done(catch_unwind(AssertUnwindSafe(func))));
262
+ std::ptr::null_mut()
263
+ }
264
+
265
+ let mut call = Some(Call::Pending(func));
266
+ // `protect` catches Ruby unwinding out of the interrupt checks
267
+ // `rb_thread_call_without_gvl` performs before and after `func`.
268
+ let protected = magnus::rb_sys::protect(|| {
269
+ unsafe {
270
+ rb_sys::rb_thread_call_without_gvl(
271
+ Some(trampoline::<F, T>),
272
+ (&mut call as *mut Option<Call<F, T>>).cast::<c_void>(),
273
+ None,
274
+ std::ptr::null_mut(),
275
+ );
276
+ }
277
+ ruby.qnil().as_raw()
278
+ });
279
+
280
+ // A panic outranks an interrupt that arrived after it.
281
+ match call {
282
+ Some(Call::Done(Err(panic))) => resume_unwind(panic),
283
+ Some(Call::Done(Ok(value))) => protected.map(|_| value),
284
+ Some(Call::Pending(_)) | None => {
285
+ protected?;
286
+ unreachable!("rb_thread_call_without_gvl returned without running the call")
287
+ }
288
+ }
289
+ }
290
+
291
+ /// On Apple targets, run `func` on a native thread of its own; elsewhere,
292
+ /// inline.
293
+ ///
294
+ /// While resolving a host name under a timeout the HTTP client parks its
295
+ /// thread, and on Apple targets Rust's `std` parks with a per-thread
296
+ /// libdispatch semaphore. A forked child inherits the forking thread's
297
+ /// semaphore and libdispatch aborts the process (`SIGTRAP`) on its first
298
+ /// use, so a Unicorn worker evaluating on the thread its master evaluated
299
+ /// on would crash. A fresh thread gets a fresh semaphore. Linux parks on a
300
+ /// futex, which survives a fork.
301
+ fn on_own_thread<F, T>(func: F) -> Result<T, glaz_module::GlazError>
302
+ where
303
+ F: FnOnce() -> Result<T, glaz_module::GlazError> + Send + 'static,
304
+ T: Send + 'static,
305
+ {
306
+ #[cfg(not(target_vendor = "apple"))]
307
+ {
308
+ func()
309
+ }
310
+ #[cfg(target_vendor = "apple")]
311
+ {
312
+ let handle = std::thread::Builder::new()
313
+ .name("glaz-policy-lookup".to_string())
314
+ // The main thread's 8 MiB rather than the 2 MiB spawn default.
315
+ .stack_size(8 << 20)
316
+ .spawn(func)
317
+ .map_err(|e| {
318
+ glaz_module::GlazError::Runtime(format!(
319
+ "could not start the policy lookup thread: {e}"
320
+ ))
321
+ })?;
322
+ match handle.join() {
323
+ Ok(result) => result,
324
+ Err(panic) => resume_unwind(panic),
325
+ }
326
+ }
327
+ }
328
+
329
+ // The engine is called concurrently from any thread: other Ruby threads
330
+ // while one waits outside the GVL, and the lookup thread on Apple targets.
331
+ const _: () = {
332
+ const fn assert_sync_send<T: Sync + Send>() {}
333
+ assert_sync_send::<glaz_module::GovernPolicyCheckEngine>();
334
+ };
335
+
161
336
  fn map_error(ruby: &Ruby, e: glaz_module::GlazError) -> Error {
162
337
  use glaz_module::GlazError::*;
163
338
  match e {
@@ -190,12 +365,15 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
190
365
  )?;
191
366
  let native = glaz.define_module("Native")?;
192
367
  let govern = native.define_class("GovernPolicyEngine", ruby.class_object())?;
193
- govern.define_singleton_method("new", magnus::function!(GovernPolicyEngine::initialize, 0))?;
368
+ govern.define_singleton_method("new", magnus::function!(GovernPolicyEngine::initialize, 3))?;
194
369
  govern.define_method(
195
370
  "debug_evaluate",
196
371
  magnus::method!(GovernPolicyEngine::debug_evaluate, 1),
197
372
  )?;
198
- govern.define_method("evaluate", magnus::method!(GovernPolicyEngine::evaluate, 1))?;
373
+ govern.define_method(
374
+ "evaluate_policies",
375
+ magnus::method!(GovernPolicyEngine::evaluate_policies, 1),
376
+ )?;
199
377
  govern.define_method("validate", magnus::method!(GovernPolicyEngine::validate, 1))?;
200
378
  Ok(())
201
379
  }