gitlab-glaz 2.0.0 → 2.2.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.
- checksums.yaml +4 -4
- data/Cargo.lock +663 -598
- data/Cargo.toml +5 -5
- data/README.md +28 -5
- data/ext/glaz/src/lib.rs +79 -11
- data/lib/gitlab/glaz/govern_policy_engine.rb +344 -53
- data/lib/gitlab/glaz/version.rb +1 -1
- data/lib/gitlab/glaz.rb +7 -2
- data/lib/proto/govern_pb.rb +28 -20
- metadata +2 -2
data/Cargo.toml
CHANGED
|
@@ -4,14 +4,14 @@ members = ["ext/*"]
|
|
|
4
4
|
resolver = "2"
|
|
5
5
|
|
|
6
6
|
[workspace.package]
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.2.0"
|
|
8
8
|
edition = "2024"
|
|
9
9
|
|
|
10
10
|
# Shared dependencies for all crates
|
|
11
11
|
[workspace.dependencies]
|
|
12
|
-
magnus = { version = "0.
|
|
12
|
+
magnus = { version = "0.9.0", 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 = "
|
|
16
|
-
glaz-roles = { git = "https://gitlab.com/gitlab-org/auth/glaz.git", rev = "
|
|
17
|
-
glaz-proto = { git = "https://gitlab.com/gitlab-org/auth/glaz.git", rev = "
|
|
15
|
+
glaz-module = { git = "https://gitlab.com/gitlab-org/auth/glaz.git", rev = "432611dc73b9a98cf0a2557225155c768b9a14ea" }
|
|
16
|
+
glaz-roles = { git = "https://gitlab.com/gitlab-org/auth/glaz.git", rev = "432611dc73b9a98cf0a2557225155c768b9a14ea" }
|
|
17
|
+
glaz-proto = { git = "https://gitlab.com/gitlab-org/auth/glaz.git", rev = "432611dc73b9a98cf0a2557225155c768b9a14ea" }
|
data/README.md
CHANGED
|
@@ -39,7 +39,24 @@ Schemas must declare `User` and `Resource` entities (checks build `User::"<subje
|
|
|
39
39
|
|
|
40
40
|
### Governance policies
|
|
41
41
|
|
|
42
|
-
`Gitlab::Glaz::GovernPolicyEngine` evaluates the Rego governance policies that apply to an event trigger.
|
|
42
|
+
`Gitlab::Glaz::GovernPolicyEngine` evaluates the Rego governance policies that apply to an event trigger. A caller that already has the policies (the Policy Store itself, running the engine in-process) passes the active policies of the trigger and organization on the call, shaped like the store's policy entity, and no store is contacted:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
result = Gitlab::Glaz.govern_policy_engine.evaluate(
|
|
46
|
+
trigger: 'deployment_requested',
|
|
47
|
+
context: { organization: { id: 'organizations/1' }, environment: { tier: 'production' }, approvals: [] },
|
|
48
|
+
principal: 'users/42',
|
|
49
|
+
resource: 'organizations/1/deployments/9',
|
|
50
|
+
policies: [
|
|
51
|
+
{ id: 1, name: 'Approve production', trigger_type: 'deployment_requested', organization_id: 1,
|
|
52
|
+
namespace_id: nil, # the owning group's id, or nil for an organization-level policy
|
|
53
|
+
mode: 'enforce', scope_rego: nil, scope_dimensions: [], policy_rego: "package governance\n...",
|
|
54
|
+
actions: [{ type: 'block', value: { message: 'Needs approval' } }] }
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A policy that belongs to another trigger or organization, or to a namespace outside the hierarchy the context names in `groups`, raises `ArgumentError` for the whole call. An empty Array is a successful, empty evaluation. Any other caller leaves `policies:` out and the engine fetches the policies from the Policy Store REST API, so name the store first:
|
|
43
60
|
|
|
44
61
|
```ruby
|
|
45
62
|
Gitlab::Glaz::GovernPolicyEngine.policy_store_url = 'https://gitlab.com/api/v4'
|
|
@@ -52,14 +69,20 @@ result = Gitlab::Glaz.govern_policy_engine.evaluate(
|
|
|
52
69
|
resource: 'organizations/1/deployments/9', # what the decision is about
|
|
53
70
|
store_authorization: "Bearer #{token}" # per request; nil sends no Authorization header
|
|
54
71
|
)
|
|
55
|
-
# => { decisions: [{ policy_id: 1,
|
|
72
|
+
# => { decisions: [{ policy_id: 1, name: 'Approve production', mode: 'enforce', matched: true,
|
|
73
|
+
# actions: [{ action_type: 'block', gating: true, ... }], ... }],
|
|
56
74
|
# undecided_policy_ids: [],
|
|
57
|
-
# identifier: 'ed9cb624-7d8f-51eb-ad5a-ef394efcee74'
|
|
75
|
+
# identifier: 'ed9cb624-7d8f-51eb-ad5a-ef394efcee74',
|
|
76
|
+
# dry_run: false }
|
|
58
77
|
```
|
|
59
78
|
|
|
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.
|
|
79
|
+
Each call without `policies:` 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.
|
|
80
|
+
|
|
81
|
+
`#evaluate_dry_run` takes the same arguments and returns the same result with `dry_run: true`, to preview what the policies would decide. It is never a gate: do not deny on its result, and nothing records it.
|
|
82
|
+
|
|
83
|
+
`#list_policies(trigger:, context:, store_authorization: nil, policies: nil)` answers a different question: which policies could ever apply here. It needs only the trigger and the lookup keys (`organization.id`, plus, for the project settings triggers, the project's namespace hierarchy: `groups`, and `project.namespace_id` for a personal project), runs no policy's rules and returns `{ policies: [{ policy_id:, name:, applicability:, missing_dimensions:, error: }] }`, with nothing to gate on. `applicability` is `"applies"`, `"not_applicable"` or `"might_apply"`; a `"might_apply"` row lists in `missing_dimensions` the context paths to supply to settle it. A row whose scope could not be evaluated has a nil `applicability` and its `error`.
|
|
61
84
|
|
|
62
|
-
Without a store URL the shared engine still serves `#validate` (save-time parse check) and `#debug_evaluate` (raw single-policy evaluation); only
|
|
85
|
+
Without a store URL the shared engine still serves `#validate` (save-time parse check) and `#debug_evaluate` (raw single-policy evaluation); only `#evaluate`, `#evaluate_dry_run` and `#list_policies` need the store, and only when called without `policies:`. See the class documentation for the full contract.
|
|
63
86
|
|
|
64
87
|
## Development
|
|
65
88
|
|
data/ext/glaz/src/lib.rs
CHANGED
|
@@ -59,8 +59,10 @@ impl PermissionCheckEngine {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/// Thin 1:1 wrapper around `glaz_module::GovernPolicyCheckEngine`, the Rego
|
|
62
|
-
/// governance evaluator.
|
|
63
|
-
///
|
|
62
|
+
/// governance evaluator. A request may carry its own policies
|
|
63
|
+
/// (`EvaluateGovernPolicyRequest.policies`); then no lookup runs. Otherwise
|
|
64
|
+
/// the lookup fetches from the Policy Store REST API named at construction,
|
|
65
|
+
/// authenticated per request on
|
|
64
66
|
/// `EvaluateGovernPolicyRequest.store_authorization`. Stateless between
|
|
65
67
|
/// calls. `evaluate_policies` raises on a whole-call failure (matching
|
|
66
68
|
/// `PermissionCheckEngine`'s convention): `ArgumentError` for caller
|
|
@@ -81,8 +83,9 @@ impl GovernPolicyEngine {
|
|
|
81
83
|
/// `base_url` is the Policy Store REST API root (e.g.
|
|
82
84
|
/// `"https://gitlab.com/api/v4"`); `nil` timeouts keep
|
|
83
85
|
/// `glaz-govern`'s defaults. Without `base_url` the engine has no policy
|
|
84
|
-
///
|
|
85
|
-
///
|
|
86
|
+
/// lookup: `validate` and `debug_evaluate` work, and `evaluate_policies`
|
|
87
|
+
/// works for a request that carries its own policies. Any other
|
|
88
|
+
/// `evaluate_policies` call fails closed (see `NoStore`).
|
|
86
89
|
fn initialize(
|
|
87
90
|
base_url: Option<String>,
|
|
88
91
|
connect_timeout_ms: Option<u64>,
|
|
@@ -130,10 +133,10 @@ impl GovernPolicyEngine {
|
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
/// Trigger-keyed batch evaluation: takes an encoded
|
|
133
|
-
/// `EvaluateGovernPolicyRequest`,
|
|
134
|
-
/// the Policy Store (keyed on
|
|
135
|
-
/// `organization.id`) and returns the
|
|
136
|
-
/// `EvaluateGovernPolicyResponse`. Raises `ArgumentError` for caller
|
|
136
|
+
/// `EvaluateGovernPolicyRequest`, takes the policies from the request's
|
|
137
|
+
/// `policies` when set and from the Policy Store otherwise (keyed on
|
|
138
|
+
/// `trigger` and the context's `organization.id`), and returns the
|
|
139
|
+
/// encoded `EvaluateGovernPolicyResponse`. Raises `ArgumentError` for caller
|
|
137
140
|
/// faults and `RuntimeError` for backend faults — never an empty batch,
|
|
138
141
|
/// which would read fail-open. A broken individual policy is reported
|
|
139
142
|
/// in-band on its decision's `error`.
|
|
@@ -161,6 +164,56 @@ impl GovernPolicyEngine {
|
|
|
161
164
|
Ok(ruby.str_from_slice(&response))
|
|
162
165
|
}
|
|
163
166
|
|
|
167
|
+
/// Dry-run twin of `evaluate_policies` (GOVERN-004: a method of its own,
|
|
168
|
+
/// never a flag). Takes an encoded `EvaluateGovernPolicyRequest` with
|
|
169
|
+
/// `dry_run` set and returns the encoded response echoing it; same
|
|
170
|
+
/// errors, GVL release and thread handling as `evaluate_policies`.
|
|
171
|
+
fn evaluate_policies_dry_run(
|
|
172
|
+
ruby: &Ruby,
|
|
173
|
+
rb_self: &Self,
|
|
174
|
+
proto_bytes: magnus::RString,
|
|
175
|
+
) -> Result<magnus::RString, Error> {
|
|
176
|
+
// Copy the request out of the Ruby string first: once the GVL is
|
|
177
|
+
// released the GC may move or free the buffer behind `as_slice`.
|
|
178
|
+
let request = unsafe { proto_bytes.as_slice() }.to_vec();
|
|
179
|
+
let engine = Arc::clone(&rb_self.engine);
|
|
180
|
+
let response = without_gvl(ruby, move || {
|
|
181
|
+
on_own_thread(move || {
|
|
182
|
+
engine
|
|
183
|
+
.evaluate_policies_dry_run(&request)
|
|
184
|
+
.map(|response| response.encode_to_vec())
|
|
185
|
+
})
|
|
186
|
+
})?
|
|
187
|
+
.map_err(|e| map_error(ruby, e))?;
|
|
188
|
+
Ok(ruby.str_from_slice(&response))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// List an encoded `ListGovernPoliciesRequest`: every policy the lookup
|
|
192
|
+
/// resolves for the trigger and scope, each with whether it applies to
|
|
193
|
+
/// the context supplied. Evaluates no policy's rules, so the encoded
|
|
194
|
+
/// `ListGovernPoliciesResponse` carries nothing to gate on. Same errors,
|
|
195
|
+
/// GVL release and thread handling as `evaluate_policies`; a policy whose
|
|
196
|
+
/// scope fails to evaluate comes back in-band on its own row.
|
|
197
|
+
fn list_policies(
|
|
198
|
+
ruby: &Ruby,
|
|
199
|
+
rb_self: &Self,
|
|
200
|
+
proto_bytes: magnus::RString,
|
|
201
|
+
) -> Result<magnus::RString, Error> {
|
|
202
|
+
// Copy the request out of the Ruby string first: once the GVL is
|
|
203
|
+
// released the GC may move or free the buffer behind `as_slice`.
|
|
204
|
+
let request = unsafe { proto_bytes.as_slice() }.to_vec();
|
|
205
|
+
let engine = Arc::clone(&rb_self.engine);
|
|
206
|
+
let response = without_gvl(ruby, move || {
|
|
207
|
+
on_own_thread(move || {
|
|
208
|
+
engine
|
|
209
|
+
.list_policies(&request)
|
|
210
|
+
.map(|response| response.encode_to_vec())
|
|
211
|
+
})
|
|
212
|
+
})?
|
|
213
|
+
.map_err(|e| map_error(ruby, e))?;
|
|
214
|
+
Ok(ruby.str_from_slice(&response))
|
|
215
|
+
}
|
|
216
|
+
|
|
164
217
|
/// Parse and compile a Rego policy without evaluating it against input.
|
|
165
218
|
///
|
|
166
219
|
/// Infallible for policy errors: parse failures and oversized input are
|
|
@@ -195,8 +248,9 @@ impl GovernPolicyEngine {
|
|
|
195
248
|
|
|
196
249
|
/// The lookup of an engine constructed without a Policy Store: every
|
|
197
250
|
/// lookup fails closed as `LookupError::Unavailable` (a `RuntimeError`), so
|
|
198
|
-
/// such an engine can never report "nothing to enforce"
|
|
199
|
-
/// rejects
|
|
251
|
+
/// such an engine can never report "nothing to enforce" for a request
|
|
252
|
+
/// without its own policies. The Ruby wrapper rejects that call earlier
|
|
253
|
+
/// with configuration guidance.
|
|
200
254
|
struct NoStore;
|
|
201
255
|
|
|
202
256
|
impl glaz_module::PolicyLookup for NoStore {
|
|
@@ -213,12 +267,18 @@ impl glaz_module::PolicyLookup for NoStore {
|
|
|
213
267
|
|
|
214
268
|
/// Return the default roles embedded in the extension at build time (parsed
|
|
215
269
|
/// from glaz-roles' roles/**/*.yml) as an array of hashes shaped like
|
|
216
|
-
/// `{ id: String, name: String, permissions: [String, ...] }`.
|
|
270
|
+
/// `{ id: String, key: String, name: String, permissions: [String, ...] }`.
|
|
271
|
+
///
|
|
272
|
+
/// `key` is the role's stable machine identifier (e.g. `artifact_admin`),
|
|
273
|
+
/// distinct from the human-facing `name` and from the `id` callers put in
|
|
274
|
+
/// relationships. Callers reference a role by `key` instead of hardcoding
|
|
275
|
+
/// its UUID or matching on its display name.
|
|
217
276
|
fn roles(ruby: &Ruby) -> Result<magnus::RArray, Error> {
|
|
218
277
|
let array = ruby.ary_new();
|
|
219
278
|
for role in glaz_roles::default_roles() {
|
|
220
279
|
let hash = ruby.hash_new();
|
|
221
280
|
hash.aset(ruby.sym_new("id"), role.id.to_string())?;
|
|
281
|
+
hash.aset(ruby.sym_new("key"), role.key)?;
|
|
222
282
|
hash.aset(ruby.sym_new("name"), role.name)?;
|
|
223
283
|
let permissions = ruby.ary_new();
|
|
224
284
|
for permission in &role.permissions {
|
|
@@ -374,6 +434,14 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
374
434
|
"evaluate_policies",
|
|
375
435
|
magnus::method!(GovernPolicyEngine::evaluate_policies, 1),
|
|
376
436
|
)?;
|
|
437
|
+
govern.define_method(
|
|
438
|
+
"evaluate_policies_dry_run",
|
|
439
|
+
magnus::method!(GovernPolicyEngine::evaluate_policies_dry_run, 1),
|
|
440
|
+
)?;
|
|
441
|
+
govern.define_method(
|
|
442
|
+
"list_policies",
|
|
443
|
+
magnus::method!(GovernPolicyEngine::list_policies, 1),
|
|
444
|
+
)?;
|
|
377
445
|
govern.define_method("validate", magnus::method!(GovernPolicyEngine::validate, 1))?;
|
|
378
446
|
Ok(())
|
|
379
447
|
}
|