ace-lab 0.4.0 → 0.5.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 +14 -0
- data/docs/usage.md +10 -0
- data/lib/ace/lab/atoms/protected_publication_input.rb +50 -0
- data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +114 -0
- data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +86 -0
- data/lib/ace/lab/atoms/service_input.rb +20 -8
- data/lib/ace/lab/authorization_path.rb +20 -0
- data/lib/ace/lab/cli/commands/service.rb +45 -0
- data/lib/ace/lab/errors.rb +11 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +2 -0
- data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +163 -0
- data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +165 -0
- data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +239 -0
- data/lib/ace/lab/molecules/protected_publication_publisher.rb +110 -0
- data/lib/ace/lab/molecules/protected_service_handler.rb +9 -40
- data/lib/ace/lab/molecules/protected_service_policy.rb +49 -5
- data/lib/ace/lab/molecules/service_policy.rb +26 -0
- data/lib/ace/lab/organisms/authority_composition.rb +25 -4
- data/lib/ace/lab/organisms/protected_cleanup_owner.rb +361 -0
- data/lib/ace/lab/organisms/protected_service_client.rb +129 -0
- data/lib/ace/lab/organisms/protected_service_listener.rb +181 -0
- data/lib/ace/lab/organisms/protected_service_publication.rb +268 -0
- data/lib/ace/lab/organisms/protected_service_receiver.rb +101 -11
- data/lib/ace/lab/organisms/protected_service_recovery.rb +185 -0
- data/lib/ace/lab/organisms/protected_service_request.rb +128 -0
- data/lib/ace/lab/organisms/protected_service_worker.rb +89 -0
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +2 -22
- metadata +34 -4
|
@@ -8,6 +8,8 @@ require "ace/assign/authority/candidate_transfer"
|
|
|
8
8
|
require "ace/assign/authority/evidence_transfer"
|
|
9
9
|
require_relative "../molecules/protected_service_handler"
|
|
10
10
|
require_relative "../molecules/protected_service_policy"
|
|
11
|
+
require_relative "../atoms/protected_workspace_prune_preview"
|
|
12
|
+
require_relative "protected_service_publication"
|
|
11
13
|
|
|
12
14
|
module Ace
|
|
13
15
|
module Lab
|
|
@@ -21,8 +23,11 @@ module Ace
|
|
|
21
23
|
|
|
22
24
|
def initialize(mapping_id:, service_id:, deployment: Ace::Assign::Authority::Deployment.load,
|
|
23
25
|
kernel: Ace::Runtime::Molecules::ProtectedLinux.new, client: nil,
|
|
24
|
-
handler: Molecules::ProtectedServiceHandler.new)
|
|
26
|
+
handler: Molecules::ProtectedServiceHandler.new, cleanup_owner: nil, publication_publisher: nil, publication_hitl: nil)
|
|
25
27
|
@mapping_id, @service_id, @deployment, @kernel, @handler = mapping_id, service_id, deployment, kernel, handler
|
|
28
|
+
@cleanup_owner = cleanup_owner
|
|
29
|
+
@publication_publisher = publication_publisher || Molecules::ProtectedPublicationPublisher.new
|
|
30
|
+
@publication_hitl = publication_hitl
|
|
26
31
|
@map = deployment.mapping(mapping_id)
|
|
27
32
|
deployment.verify_composition!(@map.fetch("authority_id"), composition: "services")
|
|
28
33
|
@project = deployment.project(@map.fetch("project_id"))
|
|
@@ -31,7 +36,30 @@ module Ace
|
|
|
31
36
|
@inputs = Molecules::ProtectedServicePolicy.new(proposal_resolver: ->(*) { raise SecurityError, "proposal decisions belong to authority" })
|
|
32
37
|
end
|
|
33
38
|
|
|
34
|
-
def
|
|
39
|
+
def preview_workspace_prune(intent:, peer:, deadline:)
|
|
40
|
+
raise SecurityError, "fixed cleanup preview composition unavailable" unless @cleanup_owner
|
|
41
|
+
intent = Atoms::ProtectedWorkspacePrunePreview.intent!(JSON.parse(JSON.generate(intent)))
|
|
42
|
+
maintenance = intent.fetch("maintenance")
|
|
43
|
+
unless maintenance.fetch("mapping_id") == @mapping_id && maintenance.fetch("project_id") == @map.fetch("project_id") &&
|
|
44
|
+
peer.values_at("uid", "gid", "groups") == @map.values_at("worker_uid", "worker_gid", "worker_groups")
|
|
45
|
+
raise SecurityError, "cleanup preview worker or installed scope differs"
|
|
46
|
+
end
|
|
47
|
+
@kernel.live!(peer)
|
|
48
|
+
me = @kernel.capture(Process.pid)
|
|
49
|
+
credentials = @project.fetch("peer_credentials").fetch(@receiver.fetch("executor_uid").to_s)
|
|
50
|
+
unless me.values_at("uid", "gid", "groups") == [@receiver.fetch("executor_uid"), credentials.fetch("gid"), credentials.fetch("groups")]
|
|
51
|
+
raise SecurityError, "cleanup preview receiver principal differs"
|
|
52
|
+
end
|
|
53
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
54
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup preview deadline expired" unless remaining.positive?
|
|
55
|
+
selected = @client.call("workspace_prune_preview_context", maintenance.except("project_id", "mapping_id").merge(
|
|
56
|
+
"service_id" => @service_id, "worker_process_binding" => peer), timeout: remaining).data
|
|
57
|
+
selected = Atoms::ProtectedWorkspacePrunePreview.context!(selected)
|
|
58
|
+
result = @cleanup_owner.preview!(intent: intent, maintenance_context: selected, deadline: deadline)
|
|
59
|
+
Atoms::ProtectedWorkspacePrunePreview.result!(result, intent: intent, context: selected)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def execute(submission:, peer:, input_bytes:, mutation_id:, on_claim: nil, claim_timeout: nil)
|
|
35
63
|
contacted = false
|
|
36
64
|
request_id = nil
|
|
37
65
|
unless submission.is_a?(Hash) && submission.keys.sort == SUBMISSION.sort &&
|
|
@@ -55,13 +83,25 @@ module Ace
|
|
|
55
83
|
unless input_bytes.is_a?(String) && mutation_id.is_a?(String) && mutation_id.match?(Ace::Assign::Molecules::JournalMutation::ID)
|
|
56
84
|
raise ArgumentError, "receiver input or mutation identity is invalid"
|
|
57
85
|
end
|
|
86
|
+
if claim_timeout && (!claim_timeout.is_a?(Numeric) || !claim_timeout.finite? ||
|
|
87
|
+
!claim_timeout.positive? || claim_timeout > 5)
|
|
88
|
+
raise ArgumentError, "receiver claim deadline is invalid"
|
|
89
|
+
end
|
|
58
90
|
bytes = input_bytes.dup.freeze
|
|
59
|
-
|
|
91
|
+
mutation_id = mutation_id.dup.freeze
|
|
92
|
+
input = @inputs.input_binding(bytes, expected_digest: params.fetch("input_digest"),
|
|
93
|
+
expected_target: params.fetch("target"), operation: params.fetch("operation"))
|
|
94
|
+
cleanup = params.fetch("operation") == "prune-preserved-workspace"
|
|
95
|
+
raise SecurityError, "fixed cleanup owner composition is unavailable" if cleanup && !@cleanup_owner
|
|
60
96
|
contacted = true
|
|
61
|
-
|
|
97
|
+
claim_options = {mutation_id: mutation_id, upload_parts: [bytes], purpose: :service_input}
|
|
98
|
+
claim_options[:timeout] = claim_timeout if claim_timeout
|
|
99
|
+
claim = @client.call("request_service", params, **claim_options)
|
|
100
|
+
notify_claim(on_claim, claim, request_id) if on_claim
|
|
62
101
|
unless !claim.replayed && claim.data["claim"] == "created"
|
|
63
102
|
return projection(claim.data)
|
|
64
103
|
end
|
|
104
|
+
original_cleanup_owner = @cleanup_owner.identity! if cleanup
|
|
65
105
|
binding = params.slice("assignment_id", "attempt_id", "candidate_generation", "head", "request_id")
|
|
66
106
|
exported = @client.call("export_candidate", binding.except("request_id").merge("purpose_id" => params.fetch("request_id")),
|
|
67
107
|
download: true, purpose: :candidate, timeout: 30)
|
|
@@ -75,6 +115,10 @@ module Ace
|
|
|
75
115
|
begin_params = binding.merge("claim_binding" => claim.data.fetch("claim_binding"), "expected_generation" => claim.data.fetch("generation"))
|
|
76
116
|
started = @client.call("begin_dispatch", begin_params, mutation_id: Digest::SHA256.hexdigest("begin:#{mutation_id}"), upload_parts: [bytes], purpose: :service_input)
|
|
77
117
|
return projection(started.data) unless !started.replayed && started.data["invocation"] == "permitted"
|
|
118
|
+
if cleanup && Ace::Assign::Atoms::EvidenceDigest.digest(original_cleanup_owner) !=
|
|
119
|
+
Ace::Assign::Atoms::EvidenceDigest.digest(started.data.fetch("operation_owner_binding"))
|
|
120
|
+
raise SecurityError, "original cleanup admission owner differs"
|
|
121
|
+
end
|
|
78
122
|
|
|
79
123
|
document = Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path)
|
|
80
124
|
operation = Molecules::ServicePolicy.new(document).operation!(params.fetch("operation"),
|
|
@@ -100,9 +144,22 @@ module Ace
|
|
|
100
144
|
"executor_uid" => @receiver.fetch("executor_uid"), "authority_id" => @map.fetch("authority_id"),
|
|
101
145
|
"claim_binding" => claim.data.fetch("claim_binding"), "candidate_generation" => binding.fetch("candidate_generation"),
|
|
102
146
|
"head" => binding.fetch("head"), "staging_id" => File.basename(materialized.fetch("directory"))})
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
147
|
+
if params.fetch("operation") == "publish"
|
|
148
|
+
return begin_publication!(admitted: {params: params, claim: claim.data, started: started.data,
|
|
149
|
+
dispatch_event_digest: started.data.fetch("dispatch_event_digest"), operation: immutable(operation), envelope: envelope,
|
|
150
|
+
staging_identity: staging_identity, mutation_id: mutation_id}, materialized: materialized,
|
|
151
|
+
deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30)
|
|
152
|
+
end
|
|
153
|
+
if cleanup
|
|
154
|
+
artifacts = cleanup_result_evidence!(params, input.fetch(:input), claim.data, started.data)
|
|
155
|
+
response = {"outcome" => "succeeded", "evidence" => artifacts.each_with_index.map do |artifact, index|
|
|
156
|
+
{"ref" => "cleanup-#{index}", "sha256" => Digest::SHA256.hexdigest(artifact)}
|
|
157
|
+
end}
|
|
158
|
+
else
|
|
159
|
+
response = @handler.execute(operation: immutable(operation), envelope: envelope, candidate_root: materialized.fetch("directory"))
|
|
160
|
+
return uncertain(params.fetch("request_id")) unless response
|
|
161
|
+
artifacts = Ace::Assign::Authority::EvidenceTransfer.read_staged(root: materialized.fetch("directory"), evidence: response.fetch("evidence"))
|
|
162
|
+
end
|
|
106
163
|
receipt = canonical.slice(*Ace::Assign::Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS).merge(
|
|
107
164
|
"outcome" => response.fetch("outcome"), "evidence" => response.fetch("evidence"))
|
|
108
165
|
receipt_bytes = JSON.generate(receipt)
|
|
@@ -115,18 +172,47 @@ module Ace
|
|
|
115
172
|
cleanup_staging(materialized.fetch("directory"), staging_identity)
|
|
116
173
|
projection(completion.data)
|
|
117
174
|
rescue Ace::Assign::Error, Ace::Runtime::RuntimeUnavailableError, Ace::Lab::InvalidConfigurationError,
|
|
118
|
-
SecurityError, ArgumentError, KeyError, SystemCallError
|
|
175
|
+
SecurityError, ArgumentError, KeyError, SystemCallError, Timeout::Error
|
|
119
176
|
contacted ? uncertain(request_id) : {"request_id" => request_id, "state" => "refused", "code" => "invalid_receiver_admission"}
|
|
120
177
|
end
|
|
121
178
|
|
|
122
179
|
private
|
|
123
180
|
|
|
124
|
-
def
|
|
181
|
+
def cleanup_result_evidence!(params, input, claim, started)
|
|
182
|
+
owner = started.fetch("operation_owner_binding")
|
|
183
|
+
result = @cleanup_owner.execute!(request: {
|
|
184
|
+
"request_id" => params.fetch("request_id"), "input_digest" => params.fetch("input_digest"),
|
|
185
|
+
"claim_binding" => claim.fetch("claim_binding"), "request_event_digest" => claim.fetch("request_event_digest"),
|
|
186
|
+
"dispatch_event_digest" => started.fetch("dispatch_event_digest"), "input" => input
|
|
187
|
+
}, operation_owner_binding: owner)
|
|
188
|
+
selection = {"schema" => "ace.protected-workspace-prune-root-selection/v1",
|
|
189
|
+
"request_id" => params.fetch("request_id"), "input_digest" => params.fetch("input_digest"),
|
|
190
|
+
"operation_owner_binding_digest" => Ace::Assign::Atoms::EvidenceDigest.digest(owner),
|
|
191
|
+
"receipt_ref" => result.fetch(:receipt_ref)}
|
|
192
|
+
[result.fetch(:bytes), JSON.generate(selection)]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# A source-owned listener callback, never a wire-selected handler. Its
|
|
196
|
+
# reply exposes accepted canonical identity before long work. Transport
|
|
197
|
+
# loss in the callback is handled by that listener; it cannot resend.
|
|
198
|
+
def notify_claim(callback, claim, request_id)
|
|
199
|
+
data = claim.data
|
|
200
|
+
unless data.is_a?(Hash) && data["request_id"] == request_id &&
|
|
201
|
+
data["generation"].is_a?(Integer) && data["generation"].positive? &&
|
|
202
|
+
data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
|
|
203
|
+
[true, false].include?(claim.replayed)
|
|
204
|
+
raise SecurityError, "canonical service acceptance identity is unavailable"
|
|
205
|
+
end
|
|
206
|
+
callback.call(immutable(data.slice("request_id", "generation", "journal_commit", "state", "claim")
|
|
207
|
+
.merge("replayed" => claim.replayed)))
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def cleanup_staging(directory, identity, prefix: "candidate-")
|
|
125
211
|
quarantine = nil
|
|
126
212
|
root = File.expand_path(@receiver.fetch("staging_root"))
|
|
127
213
|
Ace::Assign::Authority::PrivateDirectory.verify!(root)
|
|
128
214
|
current = File.lstat(directory)
|
|
129
|
-
return unless File.dirname(directory) == root && File.basename(directory).start_with?(
|
|
215
|
+
return unless File.dirname(directory) == root && File.basename(directory).start_with?(prefix) &&
|
|
130
216
|
current.directory? && current.uid == Process.uid && current.dev == identity.dev && current.ino == identity.ino
|
|
131
217
|
# Capture the pathname atomically before checking the object we will
|
|
132
218
|
# delete. A concurrent replacement at the original name must survive.
|
|
@@ -154,7 +240,9 @@ module Ace
|
|
|
154
240
|
end
|
|
155
241
|
|
|
156
242
|
def projection(data)
|
|
157
|
-
data.slice("request_id", "state", "dispatch_phase", "claim", "generation", "journal_commit")
|
|
243
|
+
data.slice("request_id", "state", "dispatch_phase", "claim", "generation", "journal_commit").tap do |result|
|
|
244
|
+
result.merge!(data.slice("receipt", "completion_digest")) if data["operation"] == "publish"
|
|
245
|
+
end
|
|
158
246
|
end
|
|
159
247
|
|
|
160
248
|
def uncertain(id)
|
|
@@ -175,3 +263,5 @@ module Ace
|
|
|
175
263
|
end
|
|
176
264
|
end
|
|
177
265
|
end
|
|
266
|
+
|
|
267
|
+
require_relative "protected_service_recovery"
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Lab
|
|
5
|
+
module Organisms
|
|
6
|
+
class ProtectedServiceReceiver
|
|
7
|
+
RECOVERY_BINDING = %w[assignment_id attempt_id candidate_generation head request_id].freeze
|
|
8
|
+
RECOVERY_EXECUTION = %w[mapping_id candidate_generation head claim_binding dispatch_phase].freeze
|
|
9
|
+
CLEANUP_RECOVERY_EXECUTION = (RECOVERY_EXECUTION + %w[operation_owner_binding executor_process_binding
|
|
10
|
+
request_event_digest dispatch_event_digest]).freeze
|
|
11
|
+
RECOVERY_CHALLENGE = %w[request_id input_digest claim_binding no_effect_challenge challenge_generation
|
|
12
|
+
failure_event_digest failure_generation challenge_event_digest].freeze
|
|
13
|
+
|
|
14
|
+
def recover_no_effect(binding:, input_bytes:, mutation_id:, expected_generation:)
|
|
15
|
+
request_id = nil
|
|
16
|
+
recovery_binding!(binding, input_bytes, mutation_id, expected_generation)
|
|
17
|
+
params = immutable(binding)
|
|
18
|
+
input_bytes = input_bytes.dup.freeze
|
|
19
|
+
mutation_id = mutation_id.dup.freeze
|
|
20
|
+
recovery_binding!(params, input_bytes, mutation_id, expected_generation)
|
|
21
|
+
request_id = params.fetch("request_id")
|
|
22
|
+
recovery_executor!
|
|
23
|
+
status, context, input = recovery_status!(params, input_bytes)
|
|
24
|
+
return projection(status) if %w[succeeded failed-settled].include?(status.fetch("state"))
|
|
25
|
+
unless context["challenge"]
|
|
26
|
+
claim_params = params.merge("expected_generation" => expected_generation)
|
|
27
|
+
begin
|
|
28
|
+
@client.call("claim_service_settlement", claim_params, mutation_id: mutation_id)
|
|
29
|
+
rescue Ace::Runtime::RuntimeUnavailableError
|
|
30
|
+
# Recover a possibly accepted challenge through current status,
|
|
31
|
+
# never refreshing the original generation or silently claiming.
|
|
32
|
+
end
|
|
33
|
+
status, context, input = recovery_status!(params, input_bytes)
|
|
34
|
+
return projection(status) if %w[succeeded failed-settled].include?(status.fetch("state"))
|
|
35
|
+
return uncertain(request_id) unless context["challenge"]
|
|
36
|
+
end
|
|
37
|
+
operation = recovery_inspector!(context)
|
|
38
|
+
root = Ace::Assign::Authority::PrivateDirectory.verify!(@receiver.fetch("staging_root"))
|
|
39
|
+
directory = Dir.mktmpdir("recovery-", root)
|
|
40
|
+
File.chmod(0o700, directory)
|
|
41
|
+
identity = File.lstat(directory)
|
|
42
|
+
envelope = immutable("version" => 1, "kind" => "service_no_effect_inspection",
|
|
43
|
+
"request" => context.fetch("request"), "input" => input.fetch(:input),
|
|
44
|
+
"execution" => context.fetch("execution"), "challenge" => context.fetch("challenge"))
|
|
45
|
+
# The selected inspector supplies retained success or positive absence;
|
|
46
|
+
# neither follows from a timeout or process/scope cleanup.
|
|
47
|
+
if context.dig("request", "operation") == "prune-preserved-workspace"
|
|
48
|
+
return uncertain(request_id) unless @cleanup_owner && context.dig("execution", "dispatch_phase") == "dispatch_started"
|
|
49
|
+
inspected_kind, artifacts = cleanup_inspection_evidence!(context, input.fetch(:input))
|
|
50
|
+
if inspected_kind == "succeeded"
|
|
51
|
+
evidence = artifacts.each_with_index.map do |artifact, index|
|
|
52
|
+
{"ref" => "cleanup-#{index}", "sha256" => Digest::SHA256.hexdigest(artifact)}
|
|
53
|
+
end
|
|
54
|
+
receipt = JSON.generate(context.fetch("request").merge("outcome" => "succeeded", "evidence" => evidence))
|
|
55
|
+
completion = params.merge("claim_binding" => context.dig("execution", "claim_binding"),
|
|
56
|
+
"receipt_sha256" => Digest::SHA256.hexdigest(receipt))
|
|
57
|
+
@client.call("complete_service", completion,
|
|
58
|
+
mutation_id: Digest::SHA256.hexdigest("cleanup-completed:#{mutation_id}"),
|
|
59
|
+
upload_parts: [receipt] + artifacts, purpose: :receipt_artifacts)
|
|
60
|
+
completed, = recovery_status!(params, input_bytes)
|
|
61
|
+
return uncertain(request_id) unless completed["state"] == "succeeded"
|
|
62
|
+
cleanup_staging(directory, identity, prefix: "recovery-")
|
|
63
|
+
return projection(completed)
|
|
64
|
+
end
|
|
65
|
+
response = {"outcome" => "failed", "request_id" => request_id,
|
|
66
|
+
"input_digest" => context.dig("request", "input_digest"), "evidence" => artifacts.each_with_index.map do |artifact, index|
|
|
67
|
+
{"ref" => index.zero? ? "cleanup-inspection-no-effect" : "cleanup-inspection-selection", "sha256" => Digest::SHA256.hexdigest(artifact)}
|
|
68
|
+
end}
|
|
69
|
+
else
|
|
70
|
+
response = @handler.execute(operation: immutable(operation), envelope: envelope, candidate_root: directory)
|
|
71
|
+
end
|
|
72
|
+
return uncertain(request_id) unless response && response["outcome"] == "failed" &&
|
|
73
|
+
response["request_id"] == request_id && response["input_digest"] == context.dig("request", "input_digest")
|
|
74
|
+
artifacts ||= Ace::Assign::Authority::EvidenceTransfer.read_staged(root: directory, evidence: response.fetch("evidence"))
|
|
75
|
+
receipt = context.fetch("request").merge("outcome" => "failed", "evidence" => response.fetch("evidence"))
|
|
76
|
+
bytes = JSON.generate(receipt)
|
|
77
|
+
completion_params = params.merge("claim_binding" => context.dig("execution", "claim_binding"),
|
|
78
|
+
"reconciliation_challenge" => context.fetch("challenge").slice("no_effect_challenge", "challenge_generation", "challenge_event_digest"),
|
|
79
|
+
"receipt_sha256" => Digest::SHA256.hexdigest(bytes))
|
|
80
|
+
@client.call("complete_no_effect", completion_params,
|
|
81
|
+
mutation_id: Digest::SHA256.hexdigest("no-effect:#{mutation_id}"),
|
|
82
|
+
upload_parts: [bytes] + artifacts, purpose: :receipt_artifacts)
|
|
83
|
+
# Protected status invokes the shared canonical receipt/import reader.
|
|
84
|
+
# Never accept a terminal state string without that authentication.
|
|
85
|
+
settled, = recovery_status!(params, input_bytes)
|
|
86
|
+
return uncertain(request_id) unless settled["state"] == "failed-settled"
|
|
87
|
+
cleanup_staging(directory, identity, prefix: "recovery-")
|
|
88
|
+
projection(settled)
|
|
89
|
+
rescue Ace::Assign::Error, Ace::Runtime::RuntimeUnavailableError, Ace::Lab::InvalidConfigurationError,
|
|
90
|
+
SecurityError, ArgumentError, KeyError, TypeError, SystemCallError
|
|
91
|
+
uncertain(request_id)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def recovery_binding!(binding, bytes, mutation_id, generation)
|
|
97
|
+
unless binding.is_a?(Hash) && binding.keys.sort == RECOVERY_BINDING.sort &&
|
|
98
|
+
%w[assignment_id attempt_id request_id].all? { |key| binding[key].is_a?(String) && binding[key].match?(Ace::Assign::Molecules::JournalMutation::ID) } &&
|
|
99
|
+
binding["head"].is_a?(String) && binding["head"].match?(Ace::Assign::Authority::CandidateTransfer::SHA) &&
|
|
100
|
+
binding["candidate_generation"].is_a?(Integer) && binding["candidate_generation"] >= 0 &&
|
|
101
|
+
generation.is_a?(Integer) && generation.positive? && bytes.is_a?(String) &&
|
|
102
|
+
mutation_id.is_a?(String) && mutation_id.match?(Ace::Assign::Molecules::JournalMutation::ID)
|
|
103
|
+
raise ArgumentError, "original recovery binding is invalid"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def recovery_executor!
|
|
108
|
+
me = @kernel.capture(Process.pid)
|
|
109
|
+
credentials = @project.fetch("peer_credentials").fetch(@receiver.fetch("executor_uid").to_s)
|
|
110
|
+
unless me["uid"] == @receiver.fetch("executor_uid") && me["gid"] == credentials.fetch("gid") &&
|
|
111
|
+
me["groups"] == credentials.fetch("groups")
|
|
112
|
+
raise SecurityError, "recovery executor differs"
|
|
113
|
+
end
|
|
114
|
+
@kernel.live!(me)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def recovery_status!(params, bytes)
|
|
118
|
+
reply = @client.call("service_status", params)
|
|
119
|
+
data = reply.data
|
|
120
|
+
context = data.fetch("settlement_context")
|
|
121
|
+
unless context.is_a?(Hash) && context.keys.sort == %w[version request execution challenge].sort &&
|
|
122
|
+
context["version"].is_a?(Integer) && context["version"] == 1
|
|
123
|
+
raise SecurityError, "canonical recovery context is malformed"
|
|
124
|
+
end
|
|
125
|
+
request, execution, challenge = context.values_at("request", "execution", "challenge")
|
|
126
|
+
execution_fields = request.is_a?(Hash) && request["operation"] == "prune-preserved-workspace" &&
|
|
127
|
+
execution.is_a?(Hash) && execution["dispatch_phase"] == "dispatch_started" ? CLEANUP_RECOVERY_EXECUTION : RECOVERY_EXECUTION
|
|
128
|
+
unless request.is_a?(Hash) && request.keys.sort == Ace::Assign::Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS.sort &&
|
|
129
|
+
execution.is_a?(Hash) && execution.keys.sort == execution_fields.sort &&
|
|
130
|
+
%w[assignment_id attempt_id request_id].all? { |key| request[key] == params.fetch(key) } &&
|
|
131
|
+
request["project_id"] == @map.fetch("project_id") && request["executor_uid"] == @receiver.fetch("executor_uid") &&
|
|
132
|
+
request["transport"] == "unix" && request["candidate_head"] == params.fetch("head") &&
|
|
133
|
+
execution["mapping_id"] == @mapping_id && execution["candidate_generation"] == params.fetch("candidate_generation") &&
|
|
134
|
+
execution["head"] == params.fetch("head") && %w[issued dispatch_started].include?(execution["dispatch_phase"]) &&
|
|
135
|
+
%w[uncertain failed succeeded failed-settled].include?(data["state"]) &&
|
|
136
|
+
data["request_id"] == params.fetch("request_id") && data["service_id"] == @service_id
|
|
137
|
+
raise SecurityError, "canonical recovery original binding differs"
|
|
138
|
+
end
|
|
139
|
+
if !challenge.nil? && (!challenge.is_a?(Hash) || challenge.keys.sort != RECOVERY_CHALLENGE.sort ||
|
|
140
|
+
!%w[challenge_generation failure_generation].all? { |key| challenge[key].is_a?(Integer) && challenge[key].positive? } ||
|
|
141
|
+
challenge["request_id"] != request["request_id"] || challenge["input_digest"] != request["input_digest"] ||
|
|
142
|
+
challenge["claim_binding"] != execution["claim_binding"])
|
|
143
|
+
raise SecurityError, "canonical recovery challenge differs"
|
|
144
|
+
end
|
|
145
|
+
input = @inputs.input_binding(bytes, expected_digest: request.fetch("input_digest"),
|
|
146
|
+
expected_target: request.fetch("target"), operation: request.fetch("operation"))
|
|
147
|
+
[data, immutable(context), input]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def cleanup_inspection_evidence!(context, input)
|
|
151
|
+
request, execution = context.values_at("request", "execution")
|
|
152
|
+
result = @cleanup_owner.inspect!(request: request.slice("request_id", "input_digest").merge(
|
|
153
|
+
"claim_binding" => execution.fetch("claim_binding"),
|
|
154
|
+
"request_event_digest" => execution.fetch("request_event_digest"),
|
|
155
|
+
"dispatch_event_digest" => execution.fetch("dispatch_event_digest"), "input" => input,
|
|
156
|
+
"challenge_ref" => context.fetch("challenge").slice("challenge_event_digest")),
|
|
157
|
+
operation_owner_binding: execution.fetch("operation_owner_binding"))
|
|
158
|
+
unless result.is_a?(Hash) && result.keys.sort == %i[bytes kind receipt_ref] &&
|
|
159
|
+
%w[succeeded failed-no-effect].include?(result[:kind])
|
|
160
|
+
raise SecurityError, "original cleanup inspection kind differs"
|
|
161
|
+
end
|
|
162
|
+
success = result.fetch(:kind) == "succeeded"
|
|
163
|
+
ref, bytes = result.values_at(:receipt_ref, :bytes)
|
|
164
|
+
unless ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] && bytes.is_a?(String) &&
|
|
165
|
+
bytes.bytesize.between?(1, 65_536) && ref["bytes"].is_a?(Integer) && ref["bytes"] == bytes.bytesize &&
|
|
166
|
+
ref["sha256"] == Digest::SHA256.hexdigest(bytes)
|
|
167
|
+
raise SecurityError, "original cleanup inspection result differs"
|
|
168
|
+
end
|
|
169
|
+
selection = {"schema" => success ? Ace::Assign::Authority::ServiceCleanupEvidence::ROOT_SELECTION_SCHEMA : Ace::Assign::Authority::ServiceCleanupEvidence::ROOT_INSPECTION_SELECTION_SCHEMA,
|
|
170
|
+
"request_id" => request.fetch("request_id"), "input_digest" => request.fetch("input_digest"),
|
|
171
|
+
"operation_owner_binding_digest" => Ace::Assign::Atoms::EvidenceDigest.digest(execution.fetch("operation_owner_binding")),
|
|
172
|
+
(success ? "receipt_ref" : "inspection_ref") => ref}
|
|
173
|
+
[result.fetch(:kind), [bytes, JSON.generate(selection)].freeze].freeze
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def recovery_inspector!(context)
|
|
177
|
+
request = context.fetch("request")
|
|
178
|
+
policy = Molecules::ServicePolicy.new(Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path))
|
|
179
|
+
policy.inspection!(request.fetch("operation"), project: @map.fetch("project_id"),
|
|
180
|
+
service_id: @service_id, executor_uid: @receiver.fetch("executor_uid"))
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "ace/assign/authority/protected_assignment_context"
|
|
3
|
+
require "ace/assign/organisms/protected_delivery_coordinator"
|
|
4
|
+
require_relative "protected_service_client"
|
|
5
|
+
require "ace/git/atoms/service_pr_input"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Lab
|
|
9
|
+
module Organisms
|
|
10
|
+
# Public selection adapter. The original authority and fixed receiver
|
|
11
|
+
# own every grant/effect; this adapter has no local journal or executor.
|
|
12
|
+
class ProtectedServiceRequest
|
|
13
|
+
FLAGS = %i[mapping scope service candidate_head candidate_generation expected_generation input_digest target artifact_digest].freeze
|
|
14
|
+
LIMIT = 16_384
|
|
15
|
+
|
|
16
|
+
def initialize(context: Ace::Assign::Authority::ProtectedAssignmentContext.load, ingress_factory: nil)
|
|
17
|
+
@context = context
|
|
18
|
+
@ingress = ingress_factory || ->(**selection) { ProtectedServiceClient.new(**selection) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def selected?(options)
|
|
22
|
+
@context.protected_participant? || @context.mapping_hint? || FLAGS.any? { |key| !options[key].nil? }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def request(project:, assignment:, attempt:, operation:, input_path:, authorization:, request_id:, **options)
|
|
26
|
+
refuse_nonworker!
|
|
27
|
+
unless (options.keys - %i[mapping scope service candidate_head candidate_generation expected_generation dry_run]).empty?
|
|
28
|
+
raise ArgumentError, "protected request rejects incompatible options"
|
|
29
|
+
end
|
|
30
|
+
raise ArgumentError, "protected service request requires a fixed PR operation and no dry-run" unless Ace::Git::Atoms::ServicePrInput::OPERATIONS.include?(operation) && !options[:dry_run]
|
|
31
|
+
validate!(project, assignment, attempt, request_id, options)
|
|
32
|
+
unless authorization.is_a?(String) && authorization.match?(Molecules::ServicePolicy::ID) &&
|
|
33
|
+
options[:service].is_a?(String) && options[:service].match?(Molecules::ServicePolicy::ID) &&
|
|
34
|
+
options[:expected_generation].is_a?(Integer) && options[:expected_generation].positive?
|
|
35
|
+
raise ArgumentError, "protected service request requires receiver, authorization and original generation"
|
|
36
|
+
end
|
|
37
|
+
input = Atoms::ServiceInput.load(input_path)
|
|
38
|
+
Ace::Git::Atoms::ServicePrInput.validate(input, operation: operation)
|
|
39
|
+
target = Atoms::ServiceInput.target(input)
|
|
40
|
+
digest = Atoms::ServiceInput.digest(input)
|
|
41
|
+
prepared = resolve!(project, assignment, attempt, options)
|
|
42
|
+
selection = {"project_id" => project, "mapping_id" => options.fetch(:mapping), "assignment_id" => assignment,
|
|
43
|
+
"attempt_id" => attempt, "scope" => options.fetch(:scope), "service_id" => options.fetch(:service),
|
|
44
|
+
"candidate_head" => options.fetch(:candidate_head), "candidate_generation" => options.fetch(:candidate_generation),
|
|
45
|
+
"expected_generation" => options.fetch(:expected_generation), "request_id" => request_id,
|
|
46
|
+
"input_digest" => digest, "target" => target}
|
|
47
|
+
submission = {"assignment_id" => assignment, "attempt_id" => attempt, "expected_generation" => options.fetch(:expected_generation),
|
|
48
|
+
"candidate_generation" => options.fetch(:candidate_generation), "head" => options.fetch(:candidate_head),
|
|
49
|
+
"request_id" => request_id, "operation" => operation, "input_digest" => digest, "target" => target,
|
|
50
|
+
"authorization" => authorization}
|
|
51
|
+
mutation = Digest::SHA256.hexdigest("qkb.#{operation}.claim:v1:#{assignment}:#{attempt}:#{request_id}")
|
|
52
|
+
claim = @context.with_installed_selection(options: options, input: prepared) do |deployment, kernel, mapping|
|
|
53
|
+
@ingress.call(mapping_id: mapping, service_id: options.fetch(:service), deployment: deployment, kernel: kernel)
|
|
54
|
+
.submit(submission: submission, input_bytes: JSON.generate(input), mutation_id: mutation)
|
|
55
|
+
end
|
|
56
|
+
envelope = if claim.fetch("type") == "service_claim_accepted"
|
|
57
|
+
{"status" => "ok", "data" => {"selection" => selection, "claim" => claim}}
|
|
58
|
+
else
|
|
59
|
+
code = claim.fetch("type")
|
|
60
|
+
{"status" => "error", "error" => {"code" => code,
|
|
61
|
+
"message" => "inspect original canonical service status; no automatic resubmission",
|
|
62
|
+
"selection" => selection, "claim" => claim}}
|
|
63
|
+
end
|
|
64
|
+
bounded!(envelope, selection: selection)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def status(request_id:, **options)
|
|
68
|
+
refuse_nonworker!
|
|
69
|
+
unless (options.keys - %i[project assignment attempt mapping scope candidate_head candidate_generation input_digest target artifact_digest format]).empty?
|
|
70
|
+
raise ArgumentError, "protected status rejects incompatible options"
|
|
71
|
+
end
|
|
72
|
+
project, assignment, attempt = options.values_at(:project, :assignment, :attempt)
|
|
73
|
+
validate!(project, assignment, attempt, request_id, options)
|
|
74
|
+
unless options[:input_digest].is_a?(String) && options[:input_digest].match?(Atoms::ServiceInput::SHA256)
|
|
75
|
+
raise ArgumentError, "protected status requires original input digest"
|
|
76
|
+
end
|
|
77
|
+
target = Atoms::ServiceInput.target({"target" => {"resource" => options[:target], "artifact_digest" => options[:artifact_digest]}})
|
|
78
|
+
resolve!(project, assignment, attempt, options)
|
|
79
|
+
data = Ace::Assign::Organisms::ProtectedDeliveryCoordinator.new(client: @context.client(options: options), project_id: project)
|
|
80
|
+
.perform(assignment_id: assignment, attempt_id: attempt, operation: "status", service_request_id: request_id,
|
|
81
|
+
candidate_head: options.fetch(:candidate_head), candidate_generation: options.fetch(:candidate_generation),
|
|
82
|
+
input_digest: options.fetch(:input_digest), target: target)
|
|
83
|
+
bounded!({"status" => "ok", "data" => data})
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def refuse_nonworker!
|
|
89
|
+
if @context.protected_participant? && !@context.protected_worker?
|
|
90
|
+
raise Ace::Assign::AttemptErrors::EvidenceUnavailable, "protected service request/status is worker-only"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate!(project, assignment, attempt, request_id, options)
|
|
95
|
+
unless [project, assignment, attempt, request_id, options[:mapping]].all? { |value| value.is_a?(String) && value.match?(Molecules::ServicePolicy::ID) } &&
|
|
96
|
+
options[:scope].is_a?(String) && options[:scope].match?(Ace::Assign::Authority::PreparedWork::SCOPE) &&
|
|
97
|
+
options[:candidate_head].is_a?(String) && options[:candidate_head].match?(/\A[0-9a-f]{40}\z/) &&
|
|
98
|
+
options[:candidate_generation].is_a?(Integer) && options[:candidate_generation].positive?
|
|
99
|
+
raise ArgumentError, "protected service requires exact original scoped selectors"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def resolve!(project, assignment, attempt, options)
|
|
104
|
+
input = @context.resolve(options: options.merge(attempt: attempt), assignment_id: assignment, scope: options.fetch(:scope))
|
|
105
|
+
unless input && input.descriptor.values_at("project_id", "assignment_id", "attempt_id", "scope", "mapping_id") ==
|
|
106
|
+
[project, assignment, attempt, options.fetch(:scope), options.fetch(:mapping)]
|
|
107
|
+
raise Ace::Assign::AttemptErrors::EvidenceUnavailable, "original prepared service selection differs"
|
|
108
|
+
end
|
|
109
|
+
input
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def bounded!(envelope, selection: nil)
|
|
113
|
+
return envelope if JSON.generate(envelope).bytesize <= LIMIT
|
|
114
|
+
if selection
|
|
115
|
+
# Submission may already be durable. Never classify lost output as
|
|
116
|
+
# a pre-effect refusal or retry; retain the original lookup tuple.
|
|
117
|
+
envelope = {"status" => "error", "error" => {"code" => "service_claim_unconfirmed",
|
|
118
|
+
"message" => "claim output unavailable; inspect original canonical service status without resubmission",
|
|
119
|
+
"selection" => selection, "claim" => {"version" => 1, "type" => "service_claim_unconfirmed",
|
|
120
|
+
"required_action" => "inspect_canonical_service_status"}}}
|
|
121
|
+
return envelope if JSON.generate(envelope).bytesize <= LIMIT
|
|
122
|
+
end
|
|
123
|
+
raise Ace::Assign::AttemptErrors::EvidenceUnavailable, "protected status output exceeds bound"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "protected_service_receiver"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lab
|
|
7
|
+
module Organisms
|
|
8
|
+
# One admitted worker for one installed receiver. Capacity is acquired
|
|
9
|
+
# before the canonical claim, not after an accepted request. This owns
|
|
10
|
+
# execution lifetime only; it never substitutes for canonical status.
|
|
11
|
+
class ProtectedServiceWorker
|
|
12
|
+
def initialize(receiver:)
|
|
13
|
+
@receiver = receiver
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
@worker = nil
|
|
16
|
+
@closing = false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def start(submission:, peer:, input_bytes:, mutation_id:, on_claim:, deadline: nil)
|
|
20
|
+
raise ArgumentError, "receiver acceptance callback unavailable" unless on_claim.respond_to?(:call)
|
|
21
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
22
|
+
deadline ||= now + 5
|
|
23
|
+
unless deadline.is_a?(Numeric) && deadline.finite? && deadline > now && deadline <= now + 5
|
|
24
|
+
raise ArgumentError, "receiver acceptance deadline is invalid"
|
|
25
|
+
end
|
|
26
|
+
# Hold independent copies before returning to the socket handler.
|
|
27
|
+
submission = copy(submission)
|
|
28
|
+
peer = copy(peer)
|
|
29
|
+
input_bytes = input_bytes.dup.freeze
|
|
30
|
+
mutation_id = mutation_id.dup.freeze
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
raise Ace::Assign::AttemptErrors::Conflict, "receiver capacity unavailable" if @closing || @worker&.alive?
|
|
33
|
+
|
|
34
|
+
@worker = Thread.new do
|
|
35
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
36
|
+
next({"state" => "uncertain"}) unless remaining.positive?
|
|
37
|
+
result = @receiver.execute(submission: submission, peer: peer, input_bytes: input_bytes,
|
|
38
|
+
mutation_id: mutation_id, claim_timeout: [remaining, 5].min,
|
|
39
|
+
on_claim: lambda do |identity|
|
|
40
|
+
begin
|
|
41
|
+
on_claim.call(identity)
|
|
42
|
+
rescue IOError, SystemCallError
|
|
43
|
+
# A lost acceptance reply cannot cancel already admitted
|
|
44
|
+
# work or cause a second execution. Status owns recovery.
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end)
|
|
48
|
+
if submission.fetch("operation") == "publish"
|
|
49
|
+
while @receiver.publication_active? && !@mutex.synchronize { @closing }
|
|
50
|
+
result = @receiver.tick_publication!(request_id: submission.fetch("request_id"),
|
|
51
|
+
deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30)
|
|
52
|
+
sleep 0.25 if @receiver.publication_active?
|
|
53
|
+
end
|
|
54
|
+
@receiver.release_publication_lifetime!
|
|
55
|
+
end
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Stop admission before joining. A timed-out join preserves both the
|
|
62
|
+
# worker and closing state; the listener must retain its lifetime lock.
|
|
63
|
+
def close(timeout:)
|
|
64
|
+
unless timeout.is_a?(Numeric) && timeout.finite? && timeout >= 0
|
|
65
|
+
raise ArgumentError, "receiver close deadline is invalid"
|
|
66
|
+
end
|
|
67
|
+
worker = @mutex.synchronize do
|
|
68
|
+
@closing = true
|
|
69
|
+
@worker
|
|
70
|
+
end
|
|
71
|
+
!worker || !worker.join(timeout).nil?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def copy(value)
|
|
77
|
+
duplicate = JSON.parse(JSON.generate(value))
|
|
78
|
+
freeze_value(duplicate)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def freeze_value(value)
|
|
82
|
+
value.each_value { |child| freeze_value(child) } if value.is_a?(Hash)
|
|
83
|
+
value.each { |child| freeze_value(child) } if value.is_a?(Array)
|
|
84
|
+
value.freeze
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/ace/lab/version.rb
CHANGED
data/lib/ace/lab.rb
CHANGED
|
@@ -3,14 +3,8 @@
|
|
|
3
3
|
require_relative "lab/version"
|
|
4
4
|
require "ace/support/config"
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class Error < StandardError; end
|
|
9
|
-
|
|
10
|
-
# Raised when topology configuration violates the schema contract
|
|
11
|
-
class InvalidConfigurationError < Error; end
|
|
12
|
-
end
|
|
13
|
-
end
|
|
6
|
+
require_relative "lab/errors"
|
|
7
|
+
require_relative "lab/authorization_path"
|
|
14
8
|
|
|
15
9
|
# Load all ace-lab components
|
|
16
10
|
require_relative "lab/atoms/topology_schema"
|
|
@@ -63,20 +57,6 @@ module Ace
|
|
|
63
57
|
# Same discovery the cascade resolver performs for this namespace
|
|
64
58
|
LAB_FILE_PATTERNS = ["lab/config.yml", "lab/config.yaml"].freeze
|
|
65
59
|
|
|
66
|
-
# Authorization grants live in a single deployment-controlled file at a
|
|
67
|
-
# FIXED path — never in the caller-writable configuration cascade and
|
|
68
|
-
# never at a caller-selected location (review rounds 4-5, F3/F1). The
|
|
69
|
-
# file must be root-owned and not group/world-writable, including every
|
|
70
|
-
# directory on its real path; GrantResolver verifies and fails closed.
|
|
71
|
-
AUTHORIZATION_PATH = "/etc/lab/ace-lab/authorization.yml"
|
|
72
|
-
|
|
73
|
-
# Path of the trusted authorization grants document. A method (not a
|
|
74
|
-
# bare constant reference) so tests can stub the seam without any
|
|
75
|
-
# caller-controllable production override.
|
|
76
|
-
# @return [String]
|
|
77
|
-
def self.authorization_path
|
|
78
|
-
AUTHORIZATION_PATH
|
|
79
|
-
end
|
|
80
60
|
|
|
81
61
|
# Load configuration using Ace::Support::Config cascade.
|
|
82
62
|
# Load failures raise InvalidConfigurationError (classified by the query
|