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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -3
- data/README.md +24 -6
- data/lib/globiguard.rb +325 -72
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6a0b4507bcc2fe64c1b8437f9b7c8db800c3e64bd4dd76f4919f42e95185dd8b
|
|
4
|
+
data.tar.gz: a5e52803a7fc11a6abe086a8704331922597170be3bf6f5bddbc654494488562
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f0791129522cb195a6c654fc9c4a23888b52c286247a618a83e03b765818bee657479b00ab5b600054ba3298023f4266869aa3f301f97e298405ba8d248a7329
|
|
7
|
+
data.tar.gz: 3e01b131159716c7f96e7950b57ce930fe86d15972c5b2a473fa989c14350454c35aea030b053f0d903b2440a62572564d905a57ba40c7cc32d37ebefbcb9963
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def
|
|
47
|
-
def
|
|
48
|
-
def
|
|
49
|
-
def
|
|
50
|
-
def
|
|
51
|
-
def
|
|
52
|
-
def
|
|
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
|
-
|
|
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)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def
|
|
145
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
result
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
raise ArgumentError, "Entitlement manifest
|
|
228
|
-
|
|
229
|
-
|
|
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.
|
|
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-
|
|
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:
|