globiguard 0.1.0 → 0.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.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -3
  3. data/README.md +24 -6
  4. data/lib/globiguard.rb +325 -72
  5. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dfe6ca1d82d5c27cd8b501fbf9306911785035cd09384c41e081e1c286cac576
4
- data.tar.gz: ae191efdeca8f3ca701fdfd999878bdb46e60e1ca473e87db8af0d997a678957
3
+ metadata.gz: 6a0b4507bcc2fe64c1b8437f9b7c8db800c3e64bd4dd76f4919f42e95185dd8b
4
+ data.tar.gz: a5e52803a7fc11a6abe086a8704331922597170be3bf6f5bddbc654494488562
5
5
  SHA512:
6
- metadata.gz: 698b401af1ea2603753d2d99093bfc559a87b5c0b02ca30a5e59c0cb071f3a088d10ab2849a86d976fffcb35fc2049fb96d7896b88d72aaf96fd12492d1d0cd9
7
- data.tar.gz: c0dd8138ca55a3575ab55df1e829a0660caf014ef09ff42e15639dc9c98d5f47b3c9072d015b2cdf054d501c40a706852b64db6de751d7bcfa1d0db8a4e07694
6
+ metadata.gz: f0791129522cb195a6c654fc9c4a23888b52c286247a618a83e03b765818bee657479b00ab5b600054ba3298023f4266869aa3f301f97e298405ba8d248a7329
7
+ data.tar.gz: 3e01b131159716c7f96e7950b57ce930fe86d15972c5b2a473fa989c14350454c35aea030b053f0d903b2440a62572564d905a57ba40c7cc32d37ebefbcb9963
data/CHANGELOG.md CHANGED
@@ -1,6 +1,13 @@
1
- # Changelog
2
-
3
- ## 0.1.0
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ - Tightened governed-action execution so only a current, obligation-free
6
+ `ALLOW` decision is executable.
7
+ - Added regression coverage for `MODIFY`, queued approvals, and other
8
+ non-executable decisions.
9
+
10
+ ## 0.1.0
4
11
 
5
12
  - Initial dependency-minimal Ruby SDK foundation.
6
13
  - Added credential helpers, safe request transport, resource clients, governed action helpers, trust webhook verification, bootstrap helpers, and offline entitlement manifest verification through Ruby OpenSSL Ed25519 support.
data/README.md CHANGED
@@ -21,12 +21,30 @@ client = GlobiGuard::Client.server(
21
21
  credential: GlobiGuard::Credential.secret("proj_example", "ggsk_example_replace_me", "sandbox")
22
22
  )
23
23
 
24
- decision = client.governed_actions.authorize_action_or_throw(
25
- actionType: "refund",
26
- actor: { id: "user_123" },
27
- target: { id: "order_456" }
28
- )
29
- ```
24
+ decision = client.governed_actions.authorize_action_or_throw(
25
+ context: {
26
+ actionType: "refund.create",
27
+ destination: {
28
+ type: "custom",
29
+ name: "payments-production"
30
+ },
31
+ dataClasses: ["CONFIDENTIAL"],
32
+ actor: {
33
+ id: "support-agent-123",
34
+ type: "agent"
35
+ },
36
+ purpose: "Resolve an approved customer escalation",
37
+ correlationId: "case_456",
38
+ idempotencyKey: "case_456:refund:v1"
39
+ }
40
+ )
41
+ ```
42
+
43
+ The governed client includes approval polling, queue review, evidence export,
44
+ evidence-package summaries, and incident replay. Only a current, short-lived,
45
+ obligation-free `ALLOW` explicitly authorizes the exact action once. `MODIFY`,
46
+ `QUEUE`, `BLOCK`, dry-run, expired, and incomplete responses stop the
47
+ authorize-or-throw path before a downstream action runs.
30
48
 
31
49
  ## Webhooks
32
50
 
data/lib/globiguard.rb CHANGED
@@ -28,28 +28,29 @@ module GlobiGuard
28
28
  class Client
29
29
  attr_reader :transport
30
30
 
31
- def self.server(environment:, services:, credential:)
32
- raise ArgumentError, "Server clients require secret or local credentials." if credential.kind == "publishable"
33
- new(environment: environment, services: services, credential: credential)
31
+ def self.server(environment:, services:, credential:)
32
+ raise ArgumentError, "Server clients require secret or local credentials." if credential.kind == "publishable"
33
+ new(environment: environment, services: services, credential: credential, read_only: false)
34
34
  end
35
35
 
36
- def self.browser(environment:, services:, credential:)
37
- raise ArgumentError, "Browser clients cannot use secret credentials." if credential.kind == "secret"
38
- new(environment: environment, services: services, credential: credential)
39
- end
40
-
41
- def initialize(environment:, services:, credential:)
42
- @transport = Transport.new(environment: environment, services: services, credential: credential)
43
- end
44
-
45
- def actions = ResourceClient.new(@transport, "/v1/actions")
46
- def audit = ResourceClient.new(@transport, "/v1/audit")
47
- def installs = ResourceClient.new(@transport, "/v1/installs")
48
- def orgs = ResourceClient.new(@transport, "/v1/orgs")
49
- def policies = ResourceClient.new(@transport, "/v1/policies")
50
- def queue = ResourceClient.new(@transport, "/v1/queue")
51
- def workflows = ResourceClient.new(@transport, "/v1/workflows")
52
- def governed_actions = GovernedActions.new(@transport)
36
+ def self.browser(environment:, services:, credential:)
37
+ raise ArgumentError, "Browser clients cannot use secret credentials." if credential.kind == "secret"
38
+ new(environment: environment, services: services, credential: credential, read_only: true)
39
+ end
40
+
41
+ def initialize(environment:, services:, credential:, read_only:)
42
+ @transport = Transport.new(environment: environment, services: services, credential: credential)
43
+ @read_only = read_only
44
+ end
45
+
46
+ def actions = ResourceClient.new(@transport, "/v1/actions", read_only: @read_only)
47
+ def audit = ResourceClient.new(@transport, "/v1/audit", read_only: @read_only)
48
+ def installs = ResourceClient.new(@transport, "/v1/installs", read_only: @read_only)
49
+ def orgs = ResourceClient.new(@transport, "/v1/orgs", read_only: @read_only)
50
+ def policies = ResourceClient.new(@transport, "/v1/policies", read_only: @read_only)
51
+ def queue = ResourceClient.new(@transport, "/v1/queue", read_only: @read_only)
52
+ def workflows = ResourceClient.new(@transport, "/v1/workflows", read_only: @read_only)
53
+ def governed_actions = GovernedActions.new(@transport, read_only: @read_only)
53
54
  end
54
55
 
55
56
  class Transport
@@ -77,13 +78,15 @@ module GlobiGuard
77
78
  end
78
79
  end
79
80
 
80
- def request(method, path, body: nil, headers: {})
81
- self.class.validate_path(path)
81
+ def request(method, path, body: nil, headers: {}, query: {})
82
+ self.class.validate_path(path)
82
83
  headers.each_key do |name|
83
84
  raise ArgumentError, "Reserved GlobiGuard header cannot be overridden: #{name}" if RESERVED_HEADERS.include?(name.downcase)
84
85
  end
85
86
 
86
- uri = URI(@base_uri.to_s.sub(%r{/+\z}, "") + path)
87
+ uri = URI(@base_uri.to_s.sub(%r{/+\z}, "") + path)
88
+ filtered_query = query.reject { |_key, value| value.nil? || value.to_s.empty? }
89
+ uri.query = URI.encode_www_form(filtered_query) unless filtered_query.empty?
87
90
  request = Net::HTTP.const_get(method.capitalize).new(uri)
88
91
  auth_headers.merge(headers).each { |name, value| request[name] = value }
89
92
  request["content-type"] = "application/json" if body
@@ -113,11 +116,15 @@ module GlobiGuard
113
116
 
114
117
  def self.validate_path(path)
115
118
  raise ArgumentError, "Request path must start with /." unless path.start_with?("/")
116
- if path.start_with?("//") || path.include?("\\") || path.include?("?") || path.include?("#") || path.match?(/%(?![0-9A-Fa-f]{2})/)
117
- raise ArgumentError, "Unsafe request path."
118
- end
119
- raise ArgumentError, "Absolute request paths are not allowed." if URI(path).absolute?
120
- raise ArgumentError, "Dot segments are not allowed." if path.split("/").any? { |segment| segment == "." || segment == ".." }
119
+ if path.start_with?("//") || path.include?("//") || path.include?("\\") || path.include?("?") || path.include?("#") || path.match?(/%(?![0-9A-Fa-f]{2})/)
120
+ raise ArgumentError, "Unsafe request path."
121
+ end
122
+ raise ArgumentError, "Absolute request paths are not allowed." if URI(path).absolute?
123
+ unsafe_segment = path.split("/").any? do |segment|
124
+ decoded = URI.decode_www_form_component(segment)
125
+ decoded == "." || decoded == ".." || decoded.include?("/") || decoded.include?("\\")
126
+ end
127
+ raise ArgumentError, "Encoded separators and dot segments are not allowed." if unsafe_segment
121
128
  end
122
129
 
123
130
  private
@@ -128,31 +135,166 @@ module GlobiGuard
128
135
  end
129
136
  end
130
137
 
131
- class ResourceClient
132
- def initialize(transport, base_path)
133
- @transport = transport
134
- @base_path = base_path
138
+ class ResourceClient
139
+ def initialize(transport, base_path, read_only:)
140
+ @transport = transport
141
+ @base_path = base_path
142
+ @read_only = read_only
135
143
  end
136
144
 
137
145
  def list = @transport.request("get", @base_path)
138
146
  def get(id) = @transport.request("get", "#{@base_path}/#{URI.encode_www_form_component(id)}")
139
- def create(body) = @transport.request("post", @base_path, body: body)
140
- def post(suffix, body) = @transport.request("post", "#{@base_path}/#{URI.encode_www_form_component(suffix.sub(%r{\A/+}, ""))}", body: body)
141
- end
142
-
143
- class GovernedActions
144
- def initialize(transport)
145
- @transport = transport
147
+ def create(body)
148
+ require_write!("Resource creation")
149
+ @transport.request("post", @base_path, body: body)
150
+ end
151
+
152
+ def post(suffix, body)
153
+ require_write!("Resource mutation")
154
+ @transport.request("post", "#{@base_path}/#{URI.encode_www_form_component(suffix.sub(%r{\A/+}, ""))}", body: body)
155
+ end
156
+
157
+ private
158
+
159
+ def require_write!(operation)
160
+ raise ArgumentError, "#{operation} requires a server client." if @read_only
161
+ end
162
+ end
163
+
164
+ class GovernedActions
165
+ def initialize(transport, read_only:)
166
+ @transport = transport
167
+ @read_only = read_only
146
168
  end
147
169
 
148
- def authorize_action_or_throw(body, idempotency_key: nil, correlation_id: nil)
149
- headers = {}
150
- headers["Idempotency-Key"] = idempotency_key if idempotency_key
151
- headers["x-correlation-id"] = correlation_id if correlation_id
152
- result = @transport.request("post", "/v1/actions/authorize", body: body, headers: headers)
153
- raise "GlobiGuard blocked the governed action." if result["decision"] == "BLOCK"
154
- result
155
- end
170
+ def authorize_action(body)
171
+ require_write!("Action authorization")
172
+ @transport.request("post", "/v1/actions/authorize", body: body)
173
+ end
174
+
175
+ def authorize_action_or_throw(body)
176
+ result = authorize_action(body)
177
+ decision = result["decision"]
178
+ if decision == "ALLOW"
179
+ raise "A dry-run decision is not an execution permit." if body[:dryRun] == true || body["dryRun"] == true
180
+ unless result["executable"] == true && result["nextAction"] == "EXECUTE_EXACT_ACTION_ONCE"
181
+ raise "The control plane marked this response as non-executable."
182
+ end
183
+ unless %w[NOT_REQUIRED APPROVED].include?(result["approvalState"])
184
+ raise "Resolve review and reauthorize before execution."
185
+ end
186
+ expires_at = begin
187
+ Time.iso8601(result["expiresAt"])
188
+ rescue ArgumentError, TypeError
189
+ nil
190
+ end
191
+ if expires_at.nil? || expires_at <= Time.now || expires_at - Time.now > 300
192
+ raise "Execution authority must have a current, bounded expiry."
193
+ end
194
+ if result["obligations"].is_a?(Array) && !result["obligations"].empty?
195
+ raise "Enforce all obligations and reauthorize before execution."
196
+ end
197
+ if result["modifications"].is_a?(Hash) && !result["modifications"].empty?
198
+ raise "Apply all modifications and reauthorize before execution."
199
+ end
200
+ return result
201
+ end
202
+ if decision == "MODIFY"
203
+ raise "Apply modifications and reauthorize the exact resulting action before execution."
204
+ end
205
+ raise "GlobiGuard blocked the governed action." if decision == "BLOCK"
206
+ if decision == "QUEUE"
207
+ raise "GlobiGuard queued the governed action for review; do not perform the downstream business action yet."
208
+ end
209
+ raise "GlobiGuard returned an unsupported decision; do not perform the downstream business action."
210
+ end
211
+
212
+ def request_approval(body)
213
+ require_write!("Approval creation")
214
+ @transport.request("post", "/v1/actions/approvals", body: body)
215
+ end
216
+
217
+ def approval_status(approval_id)
218
+ @transport.request("get", "/v1/actions/approvals/#{path_segment(approval_id)}")
219
+ end
220
+
221
+ def evidence_references(authorization_id: nil, approval_id: nil, workflow_run_id: nil)
222
+ @transport.request(
223
+ "get",
224
+ "/v1/actions/evidence",
225
+ query: {
226
+ authorizationId: authorization_id,
227
+ approvalId: approval_id,
228
+ workflowRunId: workflow_run_id
229
+ }
230
+ )
231
+ end
232
+
233
+ def export_evidence_package(body = {})
234
+ require_write!("Evidence export")
235
+ @transport.request("post", "/v1/audit/export", body: body)
236
+ end
237
+
238
+ def evidence_package_summary(evidence_package_id)
239
+ @transport.request(
240
+ "get",
241
+ "/v1/audit/evidence-packages/#{path_segment(evidence_package_id)}/summary"
242
+ )
243
+ end
244
+
245
+ def incident_replay(lookup_kind, lookup_id)
246
+ allowed = %w[workflowRunId correlationId queueEntryId auditEventId authorizationId]
247
+ raise ArgumentError, "Unsupported incident replay lookup kind." unless allowed.include?(lookup_kind)
248
+
249
+ @transport.request(
250
+ "get",
251
+ "/v1/audit/incident-replay",
252
+ query: { lookup_kind => lookup_id }
253
+ )
254
+ end
255
+
256
+ def review_queue(queue_entry_id, action, body = {})
257
+ require_write!("Queue review")
258
+ allowed = %w[approve reject modify escalate resume]
259
+ raise ArgumentError, "Unsupported queue review action." unless allowed.include?(action)
260
+
261
+ @transport.request(
262
+ "post",
263
+ "/v1/queue/#{path_segment(queue_entry_id)}/#{action}",
264
+ body: body
265
+ )
266
+ end
267
+
268
+ def wait_for_approval(queue_entry_id, max_attempts: 60, interval_seconds: 1.0)
269
+ raise ArgumentError, "max_attempts must be at least 1." if max_attempts < 1
270
+
271
+ max_attempts.times do |index|
272
+ entry = @transport.request("get", "/v1/queue/#{path_segment(queue_entry_id)}")
273
+ status = entry["status"]
274
+ return entry if %w[APPROVED AUTO_APPROVED RESUMED].include?(status)
275
+ if %w[REJECTED EXPIRED FAILED].include?(status)
276
+ raise "Queued action resolved as #{status}; do not perform the downstream business action."
277
+ end
278
+ if status == "MODIFIED"
279
+ raise "The reviewer approved a modified action summary. Rebuild the real payload and request a new authorization before executing it."
280
+ end
281
+ unless %w[PENDING ESCALATED].include?(status)
282
+ raise "GlobiGuard returned an unsupported approval state; the downstream business action remains stopped."
283
+ end
284
+ sleep(interval_seconds) if index + 1 < max_attempts
285
+ end
286
+ raise "Queued action is still pending; do not perform the downstream business action yet."
287
+ end
288
+
289
+ private
290
+
291
+ def require_write!(operation)
292
+ raise ArgumentError, "#{operation} requires a server client." if @read_only
293
+ end
294
+
295
+ def path_segment(value)
296
+ URI.encode_www_form_component(value).gsub("+", "%20")
297
+ end
156
298
  end
157
299
 
158
300
  module TrustWebhook
@@ -185,15 +327,17 @@ module GlobiGuard
185
327
 
186
328
  def install_registration(profile, package_name:, package_version:, integration_kind:, runtime_kind:)
187
329
  validate_profile(profile)
188
- {
189
- environment: profile.fetch(:environment),
330
+ {
331
+ packageName: package_name,
332
+ packageVersion: package_version,
333
+ integrationKind: integration_kind,
334
+ runtimeKind: runtime_kind,
335
+ environment: profile.fetch(:environment),
190
336
  deploymentMode: profile.fetch(:deploymentMode),
191
337
  issuerMode: profile.fetch(:issuerMode),
192
338
  installReporting: profile.fetch(:installReporting),
193
- installLabel: profile[:installLabel],
194
- package: { name: package_name, version: package_version },
195
- integration: { kind: integration_kind, runtime: runtime_kind }
196
- }
339
+ installLabel: profile[:installLabel]
340
+ }
197
341
  end
198
342
 
199
343
  def validate_profile(profile)
@@ -208,25 +352,134 @@ module GlobiGuard
208
352
  end
209
353
  end
210
354
 
211
- module Entitlements
212
- module_function
213
-
214
- def verify_signed_manifest(compact_jws, public_keys_by_id:)
215
- parts = compact_jws.split(".")
216
- raise ArgumentError, "Entitlement manifest must be compact JWS." unless parts.length == 3
217
- header = JSON.parse(base64url_decode(parts[0]))
218
- raise ArgumentError, "Entitlement manifest must use EdDSA." unless header["alg"] == "EdDSA"
219
- public_key = public_keys_by_id.fetch(header.fetch("kid"))
220
- signing_input = "#{parts[0]}.#{parts[1]}"
221
- signature = base64url_decode(parts[2])
222
- verify_ed25519!(base64url_decode(public_key), signing_input, signature)
223
- payload = JSON.parse(base64url_decode(parts[1]))
224
- raise ArgumentError, "Unsupported entitlement manifest schema." unless payload["schema"] == "globiguard.entitlement_manifest.v1"
225
- now = Time.now.to_i
226
- raise ArgumentError, "Entitlement manifest is not active yet." if payload["nbf"] && payload["nbf"].to_i > now
227
- raise ArgumentError, "Entitlement manifest is expired." if payload["exp"] && payload["exp"].to_i <= now
228
- payload
229
- end
355
+ module Entitlements
356
+ MANIFEST_TYPE = "globiguard.entitlement.v1"
357
+
358
+ module_function
359
+
360
+ def verify_signed_manifest(
361
+ compact_jws,
362
+ public_keys_by_id:,
363
+ expected_issuer: nil,
364
+ expected_org_id: nil,
365
+ expected_project_id: nil,
366
+ expected_environment: nil,
367
+ expected_deployment_mode: nil,
368
+ now: Time.now
369
+ )
370
+ parts = compact_jws.split(".")
371
+ raise ArgumentError, "Entitlement manifest must be compact JWS." unless parts.length == 3
372
+ header = decode_object(parts[0], "protected header")
373
+ unless header["alg"] == "EdDSA" && header["typ"] == MANIFEST_TYPE
374
+ raise ArgumentError, "Unsupported entitlement manifest protected header."
375
+ end
376
+ key_id = required_string(header, "kid")
377
+ public_key = public_keys_by_id.fetch(key_id) do
378
+ raise ArgumentError, "Unknown entitlement signing key."
379
+ end
380
+ signing_input = "#{parts[0]}.#{parts[1]}"
381
+ signature = base64url_decode(parts[2])
382
+ verify_ed25519!(base64url_decode(public_key), signing_input, signature)
383
+ payload = decode_object(parts[1], "payload")
384
+ validate_payload(payload)
385
+
386
+ issued_at = timestamp(payload, "issuedAt")
387
+ not_before = timestamp(payload, "notBefore")
388
+ expires_at = timestamp(payload, "expiresAt")
389
+ verification_time = now.is_a?(Time) ? now : Time.iso8601(now.to_s)
390
+ raise ArgumentError, "Entitlement manifest timestamps are inconsistent." if issued_at > expires_at || not_before >= expires_at
391
+ raise ArgumentError, "Entitlement manifest is not active yet." if not_before > verification_time
392
+ raise ArgumentError, "Entitlement manifest is expired." if expires_at <= verification_time
393
+
394
+ subject = payload.fetch("subject")
395
+ expect(expected_issuer, payload.fetch("issuer"), "issuer")
396
+ expect(expected_org_id, subject.fetch("orgId"), "organization")
397
+ expect(expected_project_id, subject.fetch("projectId"), "project")
398
+ expect(expected_environment, subject.fetch("environment"), "environment")
399
+ expect(expected_deployment_mode, subject.fetch("deploymentMode"), "deployment mode")
400
+ payload
401
+ rescue Date::Error
402
+ raise ArgumentError, "Entitlement verification now must be an ISO timestamp."
403
+ end
404
+
405
+ def validate_payload(payload)
406
+ unless payload["manifestType"] == MANIFEST_TYPE && payload["manifestVersion"] == 1
407
+ raise ArgumentError, "Unsupported entitlement manifest payload."
408
+ end
409
+ %w[manifestId issuer issuedAt notBefore expiresAt].each { |field| required_string(payload, field) }
410
+
411
+ subject = required_hash(payload, "subject")
412
+ %w[orgId workspaceName orgSlug projectId projectSlug].each { |field| required_string(subject, field) }
413
+ unless %w[sandbox live].include?(required_string(subject, "environment"))
414
+ raise ArgumentError, "Entitlement manifest subject environment is invalid."
415
+ end
416
+ unless %w[self_hosted sovereign].include?(required_string(subject, "deploymentMode"))
417
+ raise ArgumentError, "Entitlement manifest subject deployment mode is invalid."
418
+ end
419
+
420
+ commercial = required_hash(payload, "commercial")
421
+ unless %w[FREE STARTER GROWTH SCALE ENTERPRISE].include?(required_string(commercial, "commercialPlan"))
422
+ raise ArgumentError, "Entitlement manifest commercial plan is invalid."
423
+ end
424
+ unless %w[FREE PILOT ACTIVE GRACE PAST_DUE SUSPENDED CANCELED].include?(required_string(commercial, "billingStatus"))
425
+ raise ArgumentError, "Entitlement manifest billing status is invalid."
426
+ end
427
+ unless [true, false].include?(commercial["pilotActive"])
428
+ raise ArgumentError, "Entitlement manifest pilotActive must be boolean."
429
+ end
430
+
431
+ entitlements = required_hash(payload, "entitlements")
432
+ nullable_counter(entitlements, "includedQueriesPerMonth")
433
+ nullable_counter(entitlements, "frameworkSlots")
434
+ unless %w[NONE METERED CONTRACT].include?(required_string(entitlements, "overageMode"))
435
+ raise ArgumentError, "Entitlement manifest overage mode is invalid."
436
+ end
437
+ end
438
+
439
+ def decode_object(encoded, label)
440
+ value = JSON.parse(base64url_decode(encoded))
441
+ raise ArgumentError, "Entitlement manifest #{label} must be a JSON object." unless value.is_a?(Hash)
442
+ value
443
+ rescue JSON::ParserError, ArgumentError => e
444
+ raise e if e.message.start_with?("Entitlement manifest")
445
+ raise ArgumentError, "Invalid entitlement manifest #{label}."
446
+ end
447
+
448
+ def required_hash(parent, field)
449
+ value = parent[field]
450
+ raise ArgumentError, "Entitlement manifest field #{field} must be an object." unless value.is_a?(Hash)
451
+ value
452
+ end
453
+
454
+ def required_string(parent, field)
455
+ value = parent[field]
456
+ unless value.is_a?(String) && !value.strip.empty?
457
+ raise ArgumentError, "Entitlement manifest field #{field} must be a non-empty string."
458
+ end
459
+ value
460
+ end
461
+
462
+ def timestamp(parent, field)
463
+ Time.iso8601(required_string(parent, field))
464
+ rescue ArgumentError => e
465
+ raise e if e.message.start_with?("Entitlement manifest field")
466
+ raise ArgumentError, "Entitlement manifest field #{field} must be an ISO timestamp."
467
+ end
468
+
469
+ def nullable_counter(parent, field)
470
+ raise ArgumentError, "Entitlement manifest field #{field} is required." unless parent.key?(field)
471
+ value = parent[field]
472
+ return if value.nil? || (value.is_a?(Integer) && value >= 0)
473
+ raise ArgumentError, "Entitlement manifest field #{field} must be nil or a non-negative integer."
474
+ end
475
+
476
+ def expect(expected, actual, label)
477
+ return if expected.nil?
478
+ expected_digest = OpenSSL::Digest::SHA256.digest(expected.to_s)
479
+ actual_digest = OpenSSL::Digest::SHA256.digest(actual.to_s)
480
+ return if OpenSSL.fixed_length_secure_compare(expected_digest, actual_digest)
481
+ raise ArgumentError, "Entitlement manifest #{label} does not match the expected value."
482
+ end
230
483
 
231
484
  def verify_ed25519!(raw_public_key, signing_input, signature)
232
485
  prefix = [48, 42, 48, 5, 6, 3, 43, 101, 112, 3, 33, 0].pack("C*")
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: globiguard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GlobiGuard
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-29 00:00:00.000000000 Z
11
+ date: 2026-07-31 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Dependency-minimal Ruby SDK for GlobiGuard trusted access workflows.
14
14
  email: