ace-lab 0.3.1 → 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 +25 -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 +5 -1
- 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 +43 -0
- data/lib/ace/lab/molecules/protected_service_policy.rb +156 -0
- data/lib/ace/lab/molecules/service_policy.rb +36 -1
- data/lib/ace/lab/organisms/authority_composition.rb +87 -0
- 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 +267 -0
- 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/organisms/service_request_service.rb +4 -2
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +2 -22
- metadata +52 -4
|
@@ -0,0 +1,110 @@
|
|
|
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
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
|
@@ -0,0 +1,156 @@
|
|
|
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,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require "ace/assign/errors"
|
|
3
5
|
require "pathname"
|
|
4
6
|
require "time"
|
|
5
7
|
|
|
@@ -12,9 +14,10 @@ module Ace
|
|
|
12
14
|
NAME = /\A[a-z][a-z0-9-]{0,63}\z/
|
|
13
15
|
ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
14
16
|
|
|
15
|
-
def initialize(document)
|
|
17
|
+
def initialize(document, proposal_resolver = nil)
|
|
16
18
|
@operations = document.fetch("operations", {})
|
|
17
19
|
@authorizations = document.fetch("authorizations", {})
|
|
20
|
+
@proposal_resolver = proposal_resolver
|
|
18
21
|
unless @operations.is_a?(Hash) && @authorizations.is_a?(Hash)
|
|
19
22
|
raise Ace::Lab::InvalidConfigurationError, "trusted service policy must contain mappings"
|
|
20
23
|
end
|
|
@@ -80,6 +83,14 @@ module Ace
|
|
|
80
83
|
|
|
81
84
|
def authorize!(reference, binding)
|
|
82
85
|
raise ArgumentError, "invalid authorization reference" unless reference.to_s.match?(ID)
|
|
86
|
+
if reference.start_with?("proposal-")
|
|
87
|
+
raise SecurityError, "canonical proposal authority is unavailable" unless @proposal_resolver
|
|
88
|
+
begin
|
|
89
|
+
return @proposal_resolver.call(reference, binding)
|
|
90
|
+
rescue Ace::Assign::Error
|
|
91
|
+
raise SecurityError, "canonical proposal does not authorize this exact effect"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
83
94
|
decision = @authorizations[reference]
|
|
84
95
|
raise SecurityError, "authorization reference is unresolved" unless decision.is_a?(Hash)
|
|
85
96
|
required = %w[operation project_id assignment_id attempt_id input_digest target candidate_head caller_uid]
|
|
@@ -90,6 +101,30 @@ module Ace
|
|
|
90
101
|
decision
|
|
91
102
|
end
|
|
92
103
|
|
|
104
|
+
# Inspection never renews effect permission or selects effect argv.
|
|
105
|
+
def inspection!(name, project:, service_id:, executor_uid:)
|
|
106
|
+
raise ArgumentError, "invalid inspection operation" unless name.is_a?(String) && name.match?(NAME)
|
|
107
|
+
operation = @operations[name]
|
|
108
|
+
unless operation.is_a?(Hash) && operation["project"] == project && operation["service_id"] == service_id &&
|
|
109
|
+
operation["executor_uid"].is_a?(Integer) && operation["executor_uid"] == executor_uid &&
|
|
110
|
+
operation.fetch("transport", "local") == "local"
|
|
111
|
+
raise SecurityError, "inspection does not match the selected original executor"
|
|
112
|
+
end
|
|
113
|
+
# This source-composed inspector uses the fixed original root SDK;
|
|
114
|
+
# configured handler argv cannot select or replace that owner.
|
|
115
|
+
if name == "prune-preserved-workspace"
|
|
116
|
+
return {"executor_uid" => executor_uid, "operation" => name}
|
|
117
|
+
end
|
|
118
|
+
argv = operation["no_effect_argv"]
|
|
119
|
+
unless argv.is_a?(Array) && argv.length.between?(1, 16) &&
|
|
120
|
+
argv.all? { |value| value.is_a?(String) && value.bytesize.between?(1, 1024) && !value.include?("\0") } &&
|
|
121
|
+
argv.first.start_with?("/")
|
|
122
|
+
raise Ace::Lab::InvalidConfigurationError, "operation has no fixed no-effect inspector"
|
|
123
|
+
end
|
|
124
|
+
executable = trusted_executable!(argv.first, executor_uid)
|
|
125
|
+
{"executor_uid" => executor_uid, "argv" => [executable] + argv.drop(1)}
|
|
126
|
+
end
|
|
127
|
+
|
|
93
128
|
private
|
|
94
129
|
|
|
95
130
|
# Validate the executable and every directory component on its
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/assign/authority/server"
|
|
4
|
+
require "ace/assign/authority/router"
|
|
5
|
+
require "ace/assign/authority/launch_lifecycle"
|
|
6
|
+
require "ace/assign/authority/endcap"
|
|
7
|
+
require_relative "../molecules/protected_service_policy"
|
|
8
|
+
require "ace/hitl/providers/lab"
|
|
9
|
+
require "ace/hitl/providers/lab/protected_assignment_binding"
|
|
10
|
+
require "ace/hitl/lifecycle/service_publication_binding"
|
|
11
|
+
require "ace/hitl/lifecycle/service"
|
|
12
|
+
|
|
13
|
+
module Ace
|
|
14
|
+
module Lab
|
|
15
|
+
module Organisms
|
|
16
|
+
# The only full-service composition. The deployed map chooses identity
|
|
17
|
+
# and placement; source selects all handlers, policy and evidence readers.
|
|
18
|
+
class AuthorityComposition
|
|
19
|
+
REQUIRED_ENDCAP = %w[submit_candidate export_candidate assign_review accept_review request_service
|
|
20
|
+
begin_dispatch publication_challenge publication_continue claim_service_settlement complete_service complete_no_effect submit_result finish recover bind_inbox
|
|
21
|
+
service_status evidence_fetch].freeze
|
|
22
|
+
|
|
23
|
+
def initialize(authority_id:, deployment: Ace::Assign::Authority::Deployment.load,
|
|
24
|
+
kernel: Ace::Runtime::Molecules::ProtectedLinux.new)
|
|
25
|
+
@authority_id, @deployment, @kernel = authority_id, deployment, kernel
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def build
|
|
29
|
+
@deployment.verify_composition!(@authority_id, composition: "services")
|
|
30
|
+
unless (REQUIRED_ENDCAP - Ace::Assign::Authority::Endcap::OPERATIONS).empty?
|
|
31
|
+
raise InvalidConfigurationError, "full-service source composition is incomplete"
|
|
32
|
+
end
|
|
33
|
+
projects = @deployment.data.fetch("launch_mappings").values
|
|
34
|
+
.select { |map| map.fetch("authority_id") == @authority_id }.map { |map| map.fetch("project_id") }.uniq
|
|
35
|
+
raise InvalidConfigurationError, "full-service authority has no projects" if projects.empty?
|
|
36
|
+
projects.each do |id|
|
|
37
|
+
project = @deployment.project(id)
|
|
38
|
+
unless project["service_receivers"].is_a?(Hash) && !project["service_receivers"].empty?
|
|
39
|
+
raise InvalidConfigurationError, "full-service project has no fixed receiver"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
journals = {}
|
|
44
|
+
endcap = nil
|
|
45
|
+
projects.each do |id|
|
|
46
|
+
project = @deployment.project(id)
|
|
47
|
+
reader = nil
|
|
48
|
+
journal = Ace::Assign::Molecules::EvidenceJournal.new(repo_root: project.fetch("journal_repository"),
|
|
49
|
+
checkout_root: project.fetch("evidence_checkout_root"), ref: project.fetch("evidence_git_ref"), mode: :protected,
|
|
50
|
+
evidence_reader: ->(reference, record, state, pending) { reader.call(reference, record, state, pending) },
|
|
51
|
+
service_authorizer: ->(existing, replacement, pending) {
|
|
52
|
+
endcap.authorize_service_update!(journal: journals.fetch(id), existing: existing,
|
|
53
|
+
replacement: replacement, pending: pending)
|
|
54
|
+
})
|
|
55
|
+
reader = Ace::Assign::Authority::ServiceEvidence.new(journal: journal)
|
|
56
|
+
journals[id] = journal
|
|
57
|
+
end
|
|
58
|
+
policy = Molecules::ProtectedServicePolicy.new(proposal_resolver: ->(project, reference, binding) {
|
|
59
|
+
journals.fetch(project).proposal_authorize!(reference, binding)
|
|
60
|
+
})
|
|
61
|
+
history = Ace::Assign::Authority::DeploymentHistory.load
|
|
62
|
+
launch = Ace::Assign::Authority::LaunchLifecycle.new(deployment: @deployment, deployment_history: history,
|
|
63
|
+
kernel: @kernel, journals: journals)
|
|
64
|
+
endcap = Ace::Assign::Authority::Endcap.new(deployment: @deployment, launch: launch, kernel: @kernel,
|
|
65
|
+
service_policy: policy, deployment_history: history)
|
|
66
|
+
router = Ace::Assign::Authority::Router.new(launch: launch, handlers: [endcap])
|
|
67
|
+
Ace::Assign::Authority::Server.new(authority_id: @authority_id, deployment: @deployment, kernel: @kernel,
|
|
68
|
+
lifecycle: router, composition: "services", hitl_service: publication_hitl(endcap, journals))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def publication_hitl(endcap, journals)
|
|
74
|
+
provider = Ace::Hitl::Providers::Lab
|
|
75
|
+
policy = provider.grants_policy(grants_path: provider::DEFAULT_GRANTS_PATH)
|
|
76
|
+
unless policy.service_uid == @deployment.authority(@authority_id).fetch("uid")
|
|
77
|
+
raise InvalidConfigurationError, "protected HITL service principal differs from installed authority"
|
|
78
|
+
end
|
|
79
|
+
ordinary = provider::ProtectedAssignmentBinding.new(authority: endcap, kernel: @kernel, journals: journals)
|
|
80
|
+
binding = Ace::Hitl::Lifecycle::ServicePublicationBinding.new(ordinary: ordinary, authority: endcap, kernel: @kernel)
|
|
81
|
+
Ace::Hitl::Lifecycle::Service.new(root: provider::DEFAULT_STORE_ROOT,
|
|
82
|
+
socket_path: provider::DEFAULT_SOCKET_PATH, binding: binding, policy: policy)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|