ace-lab 0.8.0 → 0.13.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 +363 -92
- data/README.md +66 -100
- data/docs/usage.md +529 -404
- data/lib/ace/lab/atoms/install_schema.rb +373 -0
- data/lib/ace/lab/cli/commands/install.rb +161 -0
- data/lib/ace/lab/cli/commands/prepare.rb +118 -0
- data/lib/ace/lab/cli/commands/project_add.rb +101 -0
- data/lib/ace/lab/cli/commands/status.rb +109 -0
- data/lib/ace/lab/cli/commands/verify.rb +115 -0
- data/lib/ace/lab/cli.rb +21 -13
- data/lib/ace/lab/install_manifest_path.rb +23 -0
- data/lib/ace/lab/models/status_report.rb +38 -0
- data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
- data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
- data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
- data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +49 -23
- data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
- data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
- data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
- data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
- data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
- data/lib/ace/lab/molecules/project_registry.rb +251 -0
- data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
- data/lib/ace/lab/molecules/role_verifier.rb +223 -0
- data/lib/ace/lab/organisms/install.rb +213 -0
- data/lib/ace/lab/organisms/prepare.rb +156 -0
- data/lib/ace/lab/organisms/project_add.rb +170 -0
- data/lib/ace/lab/organisms/status_service.rb +70 -0
- data/lib/ace/lab/organisms/verify.rb +768 -0
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +34 -6
- metadata +34 -102
- data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
- data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
- data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
- data/lib/ace/lab/atoms/service_input.rb +0 -92
- data/lib/ace/lab/cli/commands/assignment.rb +0 -45
- data/lib/ace/lab/cli/commands/service.rb +0 -240
- data/lib/ace/lab/models/service_receipt.rb +0 -18
- data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
- data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
- data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
- data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
- data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
- data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -139
- data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
- data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
- data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
- data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
- data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
- data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
- data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
- data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
- data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
- data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
- data/lib/ace/lab/molecules/service_executor.rb +0 -99
- data/lib/ace/lab/molecules/service_policy.rb +0 -289
- data/lib/ace/lab/organisms/authority_composition.rb +0 -87
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -149
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -123
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -688
- data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
- data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
- data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
- data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
- data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
- data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
- data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
- data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
- data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
- data/lib/ace/lab/organisms/service_request_service.rb +0 -249
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
require "json"
|
|
3
|
-
require "digest"
|
|
4
|
-
require "net/http"
|
|
5
|
-
require "timeout"
|
|
6
|
-
require "rubygems/package"
|
|
7
|
-
require "etc"
|
|
8
|
-
require "ace/herdr/molecules/bounded_process"
|
|
9
|
-
require "ace/assign/authority/private_directory"
|
|
10
|
-
|
|
11
|
-
module Ace
|
|
12
|
-
module Lab
|
|
13
|
-
module Molecules
|
|
14
|
-
# The admitted receiver owns permission. This boundary only invokes the
|
|
15
|
-
# existing single-artifact publisher and independently checks registry bytes.
|
|
16
|
-
class ProtectedPublicationPublisher
|
|
17
|
-
def initialize(process: Ace::Herdr::Molecules::BoundedProcess, http: Net::HTTP)
|
|
18
|
-
@process, @http = process, http
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def verify!(publication:, artifact_digest:, platform:, deadline:)
|
|
22
|
-
remaining = remaining!(deadline)
|
|
23
|
-
path = "/api/v2/rubygems/#{publication.fetch('gem_name')}/versions/#{publication.fetch('version')}.json?platform=#{URI.encode_www_form_component(platform)}"
|
|
24
|
-
connection = @http.new("rubygems.org", 443, nil)
|
|
25
|
-
connection.use_ssl = true
|
|
26
|
-
connection.open_timeout = remaining
|
|
27
|
-
connection.read_timeout = remaining
|
|
28
|
-
connection.write_timeout = remaining
|
|
29
|
-
connection.max_retries = 0
|
|
30
|
-
response = nil
|
|
31
|
-
body = +""
|
|
32
|
-
Timeout.timeout(remaining, IOError) do
|
|
33
|
-
connection.start do |session|
|
|
34
|
-
connection.read_timeout = remaining!(deadline)
|
|
35
|
-
session.request(Net::HTTP::Get.new(path)) do |received|
|
|
36
|
-
response = received
|
|
37
|
-
received.read_body do |chunk|
|
|
38
|
-
remaining!(deadline)
|
|
39
|
-
raise IOError, "registry response exceeds bound" if body.bytesize + chunk.bytesize > 65_536
|
|
40
|
-
body << chunk
|
|
41
|
-
connection.read_timeout = remaining!(deadline)
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
remaining!(deadline)
|
|
47
|
-
return result("absent", "registry_version_absent") if response.is_a?(Net::HTTPNotFound)
|
|
48
|
-
return result("uncertain", "registry_verification_unavailable") unless response.is_a?(Net::HTTPSuccess)
|
|
49
|
-
value = JSON.parse(body, object_class: UniqueObject, max_nesting: 16)
|
|
50
|
-
unless value.is_a?(Hash) && value["name"] == publication.fetch("gem_name") &&
|
|
51
|
-
value["version"] == publication.fetch("version") && value["platform"] == platform &&
|
|
52
|
-
value["sha"].is_a?(String) && value["sha"].match?(/\A[0-9a-f]{64}\z/) && value["yanked"] == false
|
|
53
|
-
return result("uncertain", "registry_verification_invalid")
|
|
54
|
-
end
|
|
55
|
-
value["sha"] == artifact_digest ? result("succeeded", "registry_exact_artifact") : result("failed", "registry_artifact_conflict")
|
|
56
|
-
rescue StandardError
|
|
57
|
-
result("uncertain", "registry_verification_unavailable")
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def push!(operation:, publication:, artifact:, artifact_digest:, otp:, deadline:)
|
|
61
|
-
unless Process.uid == operation.fetch("executor_uid") && Process.euid == Process.uid &&
|
|
62
|
-
operation.fetch("argv").last == "--scoped-publication" &&
|
|
63
|
-
(otp.nil? || otp.is_a?(String) && otp.match?(/\A[0-9]{6}\z/))
|
|
64
|
-
raise SecurityError, "fixed publication executor differs"
|
|
65
|
-
end
|
|
66
|
-
environment = {"PATH" => "/usr/bin:/bin", "LANG" => "C.UTF-8", "HOME" => Etc.getpwuid(Process.uid).dir}
|
|
67
|
-
environment["GEM_HOST_OTP_CODE"] = otp if otp
|
|
68
|
-
input = {"artifact" => artifact, "artifact_digest" => artifact_digest,
|
|
69
|
-
"gem_name" => publication.fetch("gem_name"), "version" => publication.fetch("version")}
|
|
70
|
-
process = @process.call(operation.fetch("argv"), stdin_data: JSON.generate(input),
|
|
71
|
-
timeout_s: [remaining!(deadline), 30].min, output_limit: 1024, stderr_limit: 1024,
|
|
72
|
-
cleanup_group: true, environment: environment, chdir: File.dirname(artifact))
|
|
73
|
-
remaining!(deadline)
|
|
74
|
-
return result("uncertain", "publisher_reply_unavailable") if process.oversized || !process.status.success?
|
|
75
|
-
value = JSON.parse(process.stdout, object_class: UniqueObject, max_nesting: 4)
|
|
76
|
-
allowed = {"otp-required" => "provider_rejected_before_acceptance", "otp-rejected" => "provider_rejected_before_acceptance",
|
|
77
|
-
"otp-expired" => "provider_rejected_before_acceptance", "failed" => %w[provider_rejected_non_otp executor_credentials_unavailable invalid_artifact_selection artifact_identity_mismatch artifact_metadata_mismatch invalid_scoped_input],
|
|
78
|
-
"uncertain" => %w[registry_verification_required provider_result_ambiguous unclassified_push_result publisher_interrupted artifact_changed_during_push]}
|
|
79
|
-
unless value.is_a?(Hash) && value.keys.sort == %w[classification code] && Array(allowed[value["classification"]]).include?(value["code"])
|
|
80
|
-
return result("uncertain", "publisher_reply_invalid")
|
|
81
|
-
end
|
|
82
|
-
value.freeze
|
|
83
|
-
rescue StandardError
|
|
84
|
-
result("uncertain", "publisher_reply_unavailable")
|
|
85
|
-
ensure
|
|
86
|
-
environment&.delete("GEM_HOST_OTP_CODE")
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
private
|
|
90
|
-
|
|
91
|
-
class UniqueObject < Hash
|
|
92
|
-
def []=(key, value)
|
|
93
|
-
raise JSON::ParserError, "duplicate registry field" if key?(key)
|
|
94
|
-
super
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def remaining!(deadline)
|
|
99
|
-
value = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
100
|
-
raise IOError, "publication deadline expired" unless value.positive?
|
|
101
|
-
value
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def result(classification, code)
|
|
105
|
-
{"classification" => classification, "code" => code}.freeze
|
|
106
|
-
end
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
end
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "ace/herdr/molecules/bounded_process"
|
|
5
|
-
require "timeout"
|
|
6
|
-
require "ace/assign/authority/private_directory"
|
|
7
|
-
|
|
8
|
-
module Ace
|
|
9
|
-
module Lab
|
|
10
|
-
module Molecules
|
|
11
|
-
# Source-owned receiver handler. Permission admission is a separate
|
|
12
|
-
# authority operation; this runner never grants or reconstructs it.
|
|
13
|
-
class ProtectedServiceHandler
|
|
14
|
-
MAX_STDOUT = 16 * 1024
|
|
15
|
-
MAX_STDERR = 8 * 1024
|
|
16
|
-
|
|
17
|
-
def execute(operation:, envelope:, candidate_root:)
|
|
18
|
-
unless Process.uid == operation.fetch("executor_uid") && Process.euid == operation.fetch("executor_uid")
|
|
19
|
-
raise SecurityError, "handler executor differs"
|
|
20
|
-
end
|
|
21
|
-
root = Ace::Assign::Authority::PrivateDirectory.verify!(candidate_root)
|
|
22
|
-
result = Ace::Herdr::Molecules::BoundedProcess.call(operation.fetch("argv"),
|
|
23
|
-
stdin_data: JSON.generate(envelope), timeout_s: 30,
|
|
24
|
-
output_limit: MAX_STDOUT, stderr_limit: MAX_STDERR, cleanup_group: true,
|
|
25
|
-
environment: {"PATH" => "/usr/bin:/bin", "LANG" => "C.UTF-8", "HOME" => root}, chdir: root)
|
|
26
|
-
return nil if result.oversized || !result.status.success?
|
|
27
|
-
out = result.stdout
|
|
28
|
-
response = JSON.parse(out)
|
|
29
|
-
request = envelope.fetch("request")
|
|
30
|
-
unless response.is_a?(Hash) && response.keys.sort == %w[evidence input_digest outcome request_id] &&
|
|
31
|
-
response["request_id"] == request.fetch("request_id") && response["input_digest"] == request.fetch("input_digest") &&
|
|
32
|
-
%w[succeeded failed].include?(response["outcome"]) && response["evidence"].is_a?(Array)
|
|
33
|
-
return nil
|
|
34
|
-
end
|
|
35
|
-
response
|
|
36
|
-
rescue JSON::ParserError, Timeout::Error, Ace::Herdr::Molecules::BoundedProcess::PostLaunchError,
|
|
37
|
-
IOError, SystemCallError
|
|
38
|
-
nil
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
end
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "etc"
|
|
5
|
-
require "ace/assign/atoms/evidence_digest"
|
|
6
|
-
require_relative "../authorization_path"
|
|
7
|
-
require_relative "../atoms/service_input"
|
|
8
|
-
require "ace/git/atoms/service_pr_input"
|
|
9
|
-
require_relative "grant_resolver"
|
|
10
|
-
require_relative "caller_authorizer"
|
|
11
|
-
require_relative "service_policy"
|
|
12
|
-
require_relative "../atoms/protected_workspace_prune_input"
|
|
13
|
-
require_relative "../atoms/protected_publication_input"
|
|
14
|
-
|
|
15
|
-
module Ace
|
|
16
|
-
module Lab
|
|
17
|
-
module Molecules
|
|
18
|
-
# The receiver and authority share the existing input/policy owners.
|
|
19
|
-
# Only composition supplies collaborators; no wire-selected policy,
|
|
20
|
-
# executable, input path or caller identity is accepted here.
|
|
21
|
-
class ProtectedServicePolicy
|
|
22
|
-
def initialize(proposal_resolver:, document_loader: nil, cleanup_owner: nil)
|
|
23
|
-
@proposal_resolver = proposal_resolver
|
|
24
|
-
@cleanup_owner = cleanup_owner
|
|
25
|
-
@document_loader = document_loader || -> { GrantResolver.trusted_document(Ace::Lab.authorization_path) }
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def input_binding(bytes, expected_digest:, expected_target:, operation:)
|
|
29
|
-
unless bytes.is_a?(String) && bytes.bytesize.between?(1, Atoms::ServiceInput::MAX_BYTES)
|
|
30
|
-
raise ArgumentError, "structured service input exceeds its fixed limit"
|
|
31
|
-
end
|
|
32
|
-
input = if operation == "prune-preserved-workspace"
|
|
33
|
-
Atoms::ProtectedWorkspacePruneInput.parse(bytes)
|
|
34
|
-
elsif operation == "publish"
|
|
35
|
-
Atoms::ProtectedPublicationInput.decode!(bytes)
|
|
36
|
-
else
|
|
37
|
-
JSON.parse(bytes)
|
|
38
|
-
end
|
|
39
|
-
raise ArgumentError, "structured service input must be an object" unless input.is_a?(Hash)
|
|
40
|
-
Atoms::ServiceInput.validate!(input)
|
|
41
|
-
if Ace::Git::Atoms::ServicePrInput::OPERATIONS.include?(operation)
|
|
42
|
-
Ace::Git::Atoms::ServicePrInput.validate(input, operation: operation)
|
|
43
|
-
end
|
|
44
|
-
digest = Atoms::ServiceInput.digest(input)
|
|
45
|
-
target = Atoms::ServiceInput.target(input)
|
|
46
|
-
unless digest == expected_digest && target == expected_target
|
|
47
|
-
raise SecurityError, "structured service input differs from the bound digest or target"
|
|
48
|
-
end
|
|
49
|
-
deep_freeze(input: input, input_digest: digest, target: target)
|
|
50
|
-
rescue JSON::ParserError
|
|
51
|
-
raise ArgumentError, "structured service input must be valid JSON"
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def prepare!(binding, input_bytes:)
|
|
55
|
-
input = input_binding(input_bytes, expected_digest: binding.fetch("input_digest"),
|
|
56
|
-
expected_target: binding.fetch("target"), operation: binding.fetch("operation"))
|
|
57
|
-
if binding["operation"] == "publish" && input.fetch(:input).dig("publication", "head") != binding.fetch("candidate_head")
|
|
58
|
-
raise SecurityError, "publication input head differs from admitted candidate"
|
|
59
|
-
end
|
|
60
|
-
authorized = authorize!(binding)
|
|
61
|
-
canonical = snapshot(binding).merge("executor_uid" => authorized.fetch(:operation).fetch("executor_uid"), "transport" => "unix")
|
|
62
|
-
deep_freeze(authorized.merge(binding: canonical, input: input.fetch(:input)))
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Selected by installed composition, independently of receiver fields.
|
|
66
|
-
# Ordinary services never acquire this domain-specific root capability.
|
|
67
|
-
def dispatch_owner_binding!(binding)
|
|
68
|
-
return nil unless binding.fetch("operation") == "prune-preserved-workspace"
|
|
69
|
-
unless @cleanup_owner
|
|
70
|
-
raise SecurityError, "fixed cleanup owner composition is unavailable"
|
|
71
|
-
end
|
|
72
|
-
@cleanup_owner.identity!
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# Called again inside each claim/begin CAS retry using the immutable
|
|
76
|
-
# binding and ephemeral parsed input, never a caller filesystem path.
|
|
77
|
-
def authorize!(binding)
|
|
78
|
-
uid = binding.fetch("caller_uid")
|
|
79
|
-
document = visible_document!(project: binding.fetch("project_id"), uid: uid)
|
|
80
|
-
resolver = ->(reference, exact) { @proposal_resolver.call(binding.fetch("project_id"), reference, exact) }
|
|
81
|
-
policy = ServicePolicy.new(document, resolver)
|
|
82
|
-
operation = policy.operation!(binding.fetch("operation"), project: binding.fetch("project_id"), service_id: binding.fetch("service_id"))
|
|
83
|
-
unless operation.fetch("transport", "local") == "local"
|
|
84
|
-
raise Ace::Lab::InvalidConfigurationError, "protected receiver requires a fixed local handler operation"
|
|
85
|
-
end
|
|
86
|
-
unless operation.fetch("executor_uid").positive? && operation.fetch("executor_uid") != uid
|
|
87
|
-
raise SecurityError, "protected executor must be a separate nonroot account"
|
|
88
|
-
end
|
|
89
|
-
if binding.key?("executor_uid") && binding["executor_uid"] != operation.fetch("executor_uid")
|
|
90
|
-
raise SecurityError, "configured executor differs from the recorded claim"
|
|
91
|
-
end
|
|
92
|
-
decision = policy.authorize!(binding.fetch("authorization"), binding)
|
|
93
|
-
digest = Ace::Assign::Atoms::EvidenceDigest.digest("operation" => operation, "authorization" => decision,
|
|
94
|
-
"caller_uid" => uid, "project_id" => binding.fetch("project_id"))
|
|
95
|
-
if binding.key?("policy_digest") && binding["policy_digest"] != digest
|
|
96
|
-
raise SecurityError, "service policy changed after claim"
|
|
97
|
-
end
|
|
98
|
-
deep_freeze(operation: snapshot(operation), policy_digest: digest,
|
|
99
|
-
operation_digest: Ace::Assign::Atoms::EvidenceDigest.digest(operation))
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
# Read-only selection of the installed named cleanup receiver; no
|
|
103
|
-
# proposal authorization or effect capability is minted by preview.
|
|
104
|
-
def workspace_prune_receiver!(project:, uid:, service_id:, executor_uid:)
|
|
105
|
-
document = visible_document!(project: project, uid: uid)
|
|
106
|
-
policy = ServicePolicy.new(document, ->(*) { raise SecurityError, "preview cannot resolve proposal grants" })
|
|
107
|
-
operation = policy.operation!("prune-preserved-workspace", project: project, service_id: service_id)
|
|
108
|
-
unless operation.fetch("executor_uid") == executor_uid && executor_uid.is_a?(Integer) && executor_uid.positive? && executor_uid != uid
|
|
109
|
-
raise SecurityError, "preview receiver differs from the installed cleanup operation"
|
|
110
|
-
end
|
|
111
|
-
true
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def visible!(project:, uid:)
|
|
115
|
-
visible_document!(project: project, uid: uid)
|
|
116
|
-
true
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def authorize_update!(existing, replacement, _pending)
|
|
120
|
-
initial_effect = existing.nil? && %w[accepted uncertain].include?(replacement["state"])
|
|
121
|
-
fresh_dispatch = existing && existing["dispatch_phase"] == "issued" && replacement["dispatch_phase"] == "dispatch_started"
|
|
122
|
-
authorize!(replacement) if initial_effect || fresh_dispatch
|
|
123
|
-
true
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
private
|
|
127
|
-
|
|
128
|
-
def visible_document!(project:, uid:)
|
|
129
|
-
raise SecurityError, "protected caller UID is invalid" unless uid.is_a?(Integer) && uid.positive?
|
|
130
|
-
username = begin
|
|
131
|
-
Etc.getpwuid(uid).name
|
|
132
|
-
rescue ArgumentError
|
|
133
|
-
nil
|
|
134
|
-
end
|
|
135
|
-
document = @document_loader.call
|
|
136
|
-
identities = CallerAuthorizer.matchable_identities(username, uid)
|
|
137
|
-
visibility = CallerAuthorizer.new(principals: document.fetch("principals", {}), identity: identities)
|
|
138
|
-
raise SecurityError, "caller project grant has been revoked" unless visibility.authorized?(project)
|
|
139
|
-
document
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def snapshot(value)
|
|
143
|
-
JSON.parse(JSON.generate(value))
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def deep_freeze(value)
|
|
147
|
-
case value
|
|
148
|
-
when Hash then value.each { |key, item| deep_freeze(key); deep_freeze(item) }
|
|
149
|
-
when Array then value.each { |item| deep_freeze(item) }
|
|
150
|
-
end
|
|
151
|
-
value.freeze
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
end
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "ace/herdr/molecules/bounded_process"
|
|
5
|
-
require "socket"
|
|
6
|
-
require "timeout"
|
|
7
|
-
require "time"
|
|
8
|
-
|
|
9
|
-
module Ace
|
|
10
|
-
module Lab
|
|
11
|
-
module Molecules
|
|
12
|
-
# Executes one fixed, deployment-configured argv under its OS account.
|
|
13
|
-
# Domain handlers receive structured JSON on stdin and return a small
|
|
14
|
-
# receipt; their stdout/stderr are never journaled or returned to callers.
|
|
15
|
-
class ServiceExecutor
|
|
16
|
-
# The trusted policy is reloaded through +policy_loader+ immediately
|
|
17
|
-
# before the effect: a revocation or operation change after the claim
|
|
18
|
-
# must still stop dispatch. The candidate head is revalidated through
|
|
19
|
-
# +head_loader+ for the same reason: a concurrent commit must not let
|
|
20
|
-
# a stale reviewed candidate execute.
|
|
21
|
-
def execute(operation:, request:, input:, policy_loader:, authorization:, head_loader:)
|
|
22
|
-
fresh = policy_loader.call
|
|
23
|
-
current = fresh.operation!(request.fetch("operation"), project: request.fetch("project_id"),
|
|
24
|
-
service_id: request.fetch("service_id"))
|
|
25
|
-
raise SecurityError, "executor operation changed before dispatch" unless current == operation
|
|
26
|
-
fresh.authorize!(authorization, request)
|
|
27
|
-
unless head_loader.call == request.fetch("candidate_head")
|
|
28
|
-
raise SecurityError, "candidate head changed before dispatch"
|
|
29
|
-
end
|
|
30
|
-
if Time.iso8601(current.fetch("lease_expires_at")) <= Time.now.utc
|
|
31
|
-
raise SecurityError, "executor lease has expired"
|
|
32
|
-
end
|
|
33
|
-
out = if operation.fetch("transport", "local") == "unix"
|
|
34
|
-
invoke_unix(operation, request, input)
|
|
35
|
-
else
|
|
36
|
-
invoke_local(operation, request, input)
|
|
37
|
-
end
|
|
38
|
-
return nil unless out && out.bytesize <= 16 * 1024
|
|
39
|
-
response = JSON.parse(out)
|
|
40
|
-
return nil unless valid_response?(response, request)
|
|
41
|
-
{"outcome" => response.fetch("outcome"), "evidence" => response.fetch("evidence"),
|
|
42
|
-
"executor_uid" => current.fetch("executor_uid")}
|
|
43
|
-
rescue JSON::ParserError, Timeout::Error, SystemCallError, IOError,
|
|
44
|
-
Ace::Herdr::Molecules::BoundedProcess::PostLaunchError
|
|
45
|
-
# Transport failures after the claim (broken pipe, connection
|
|
46
|
-
# reset, socket loss) leave the effect unknown: uncertainty, not a
|
|
47
|
-
# classified crash, is the honest outcome.
|
|
48
|
-
nil
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
private
|
|
52
|
-
|
|
53
|
-
# The existing process owner bounds both streams and the whole run,
|
|
54
|
-
# and cleans the owned process group on timeout and normal exit.
|
|
55
|
-
def invoke_local(operation, request, input)
|
|
56
|
-
unless Process.uid == operation.fetch("executor_uid") && Process.euid == operation.fetch("executor_uid")
|
|
57
|
-
raise SecurityError, "current OS identity is not the configured executor"
|
|
58
|
-
end
|
|
59
|
-
result = Ace::Herdr::Molecules::BoundedProcess.call(operation.fetch("argv"),
|
|
60
|
-
stdin_data: JSON.generate({"request" => request, "input" => input}),
|
|
61
|
-
timeout_s: 30, output_limit: 16 * 1024, stderr_limit: 8192, cleanup_group: true)
|
|
62
|
-
result.stdout if result.status.success? && !result.oversized
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def invoke_unix(operation, request, input)
|
|
66
|
-
path = operation.fetch("socket_path")
|
|
67
|
-
stat = File.lstat(path)
|
|
68
|
-
unless stat.socket? && stat.uid == operation.fetch("executor_uid") && (stat.mode & 0o002).zero?
|
|
69
|
-
raise SecurityError, "service socket owner or permissions do not match the executor"
|
|
70
|
-
end
|
|
71
|
-
# One deadline covers connecting, writing, and reading: a stalled
|
|
72
|
-
# peer must leave the claimed effect uncertain, never block.
|
|
73
|
-
Timeout.timeout(30) do
|
|
74
|
-
UNIXSocket.open(path) do |socket|
|
|
75
|
-
peer_uid, = socket.getpeereid
|
|
76
|
-
raise SecurityError, "service peer identity does not match the executor" unless peer_uid == stat.uid
|
|
77
|
-
socket.write(JSON.generate({"request" => request, "input" => input}) + "\n")
|
|
78
|
-
socket.flush
|
|
79
|
-
socket.gets(16 * 1024 + 1)
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def valid_response?(response, request)
|
|
85
|
-
response.is_a?(Hash) && response.keys.sort == %w[evidence input_digest outcome request_id] &&
|
|
86
|
-
response["request_id"] == request["request_id"] &&
|
|
87
|
-
response["input_digest"] == request["input_digest"] &&
|
|
88
|
-
%w[succeeded failed].include?(response["outcome"]) &&
|
|
89
|
-
response["evidence"].is_a?(Array) && !response["evidence"].empty? &&
|
|
90
|
-
response["evidence"].all? do |item|
|
|
91
|
-
item.is_a?(Hash) && item.keys.sort == %w[ref sha256] &&
|
|
92
|
-
item["ref"].is_a?(String) && item["ref"].match?(/\A[a-zA-Z0-9_.:\/-]{1,256}\z/) &&
|
|
93
|
-
item["sha256"].is_a?(String) && item["sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
end
|