gitlab-glaz 1.1.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.1.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,65 +59,155 @@ impl PermissionCheckEngine {
53
59
  }
54
60
 
55
61
  /// Thin 1:1 wrapper around `glaz_module::GovernPolicyCheckEngine`, the Rego
56
- /// governance evaluator. Stateless between calls; both methods take an
57
- /// encoded `glaz.govern.v1.EvaluateGovernPolicyRequest`. `evaluate` raises
58
- /// on failure (matching `PermissionCheckEngine`'s convention) and returns
59
- /// the encoded `EvaluateGovernPolicyResponse` on success — `ArgumentError`
60
- /// for bad policy/input, `RuntimeError` for an engine-side fault, see
61
- /// `map_error`; `debug_evaluate` is infallible and carries failures in-band
62
- /// in its JSON response instead, since it's a raw debugging escape hatch.
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.
63
70
  ///
64
- /// Registered as `Glaz::Native::GovernPolicyEngine` (not
65
- /// `Glaz::GovernPolicyEngine`) specifically to avoid colliding, in name,
66
- /// with the polished `Gitlab::Glaz::GovernPolicyEngine` wrapper — this
67
- /// class is raw FFI plumbing; prefer the wrapper unless you specifically
68
- /// 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.
69
74
  #[magnus::wrap(class = "Glaz::Native::GovernPolicyEngine")]
70
75
  struct GovernPolicyEngine {
71
- engine: glaz_module::GovernPolicyCheckEngine,
76
+ // Shared with the per-call lookup thread, see `on_own_thread`.
77
+ engine: Arc<glaz_module::GovernPolicyCheckEngine>,
72
78
  }
73
79
 
74
80
  impl GovernPolicyEngine {
75
- 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
+ }
76
103
  Self {
77
- engine: glaz_module::GovernPolicyCheckEngine::new(),
104
+ engine: Arc::new(glaz_module::GovernPolicyCheckEngine::with_store(config)),
78
105
  }
79
106
  }
80
107
 
81
- /// Evaluate using the request's `query` field. `query` must be
82
- /// non-blank; a blank query is reported in-band as an error rather than
83
- /// 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.
84
116
  fn debug_evaluate(
85
117
  ruby: &Ruby,
86
118
  rb_self: &Self,
87
119
  proto_bytes: magnus::RString,
88
120
  ) -> Result<magnus::RString, Error> {
89
- let bytes = unsafe { proto_bytes.as_slice() };
90
- 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))?;
91
129
  Ok(ruby.str_from_slice(&response))
92
130
  }
93
131
 
94
- /// Evaluate by auto-discovering the policy's `violation`, `deny`,
95
- /// 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`.
96
140
  ///
97
- /// Raises on any evaluation failure rather than returning an in-band
98
- /// error, matching `PermissionCheckEngine#check_action`'s convention.
99
- /// `map_error` raises `ArgumentError` for malformed protobuf, invalid
100
- /// policy, oversized documents, undefined results, or an unrecognized
101
- /// result shape - all caller-fixable - and `RuntimeError` for an
102
- /// engine-side fault (e.g. the Rego evaluation time budget was
103
- /// exceeded), which is not.
104
- 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(
105
145
  ruby: &Ruby,
106
146
  rb_self: &Self,
107
147
  proto_bytes: magnus::RString,
108
148
  ) -> Result<magnus::RString, Error> {
109
- let bytes = unsafe { proto_bytes.as_slice() };
110
- let response = rb_self
111
- .engine
112
- .evaluate(bytes)
113
- .map_err(|e| map_error(ruby, e))?;
114
- 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))
162
+ }
163
+
164
+ /// Parse and compile a Rego policy without evaluating it against input.
165
+ ///
166
+ /// Infallible for policy errors: parse failures and oversized input are
167
+ /// returned in-band as `{ valid: false, errors: [...] }` rather than
168
+ /// raised. Only a malformed protobuf payload raises `ArgumentError`; an
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.
175
+ fn validate(
176
+ ruby: &Ruby,
177
+ rb_self: &Self,
178
+ proto_bytes: magnus::RString,
179
+ ) -> Result<magnus::RString, Error> {
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
+ ))
115
211
  }
116
212
  }
117
213
 
@@ -134,6 +230,109 @@ fn roles(ruby: &Ruby) -> Result<magnus::RArray, Error> {
134
230
  Ok(array)
135
231
  }
136
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
+
137
336
  fn map_error(ruby: &Ruby, e: glaz_module::GlazError) -> Error {
138
337
  use glaz_module::GlazError::*;
139
338
  match e {
@@ -166,11 +365,15 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
166
365
  )?;
167
366
  let native = glaz.define_module("Native")?;
168
367
  let govern = native.define_class("GovernPolicyEngine", ruby.class_object())?;
169
- govern.define_singleton_method("new", magnus::function!(GovernPolicyEngine::initialize, 0))?;
368
+ govern.define_singleton_method("new", magnus::function!(GovernPolicyEngine::initialize, 3))?;
170
369
  govern.define_method(
171
370
  "debug_evaluate",
172
371
  magnus::method!(GovernPolicyEngine::debug_evaluate, 1),
173
372
  )?;
174
- govern.define_method("evaluate", magnus::method!(GovernPolicyEngine::evaluate, 1))?;
373
+ govern.define_method(
374
+ "evaluate_policies",
375
+ magnus::method!(GovernPolicyEngine::evaluate_policies, 1),
376
+ )?;
377
+ govern.define_method("validate", magnus::method!(GovernPolicyEngine::validate, 1))?;
175
378
  Ok(())
176
379
  }