ace-lab 0.4.0 → 0.5.1
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 +20 -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 +36 -6
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "etc"
|
|
5
|
-
require "ace/assign"
|
|
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"
|
|
6
14
|
|
|
7
15
|
module Ace
|
|
8
16
|
module Lab
|
|
@@ -11,18 +19,28 @@ module Ace
|
|
|
11
19
|
# Only composition supplies collaborators; no wire-selected policy,
|
|
12
20
|
# executable, input path or caller identity is accepted here.
|
|
13
21
|
class ProtectedServicePolicy
|
|
14
|
-
def initialize(proposal_resolver:, document_loader: nil)
|
|
22
|
+
def initialize(proposal_resolver:, document_loader: nil, cleanup_owner: nil)
|
|
15
23
|
@proposal_resolver = proposal_resolver
|
|
24
|
+
@cleanup_owner = cleanup_owner
|
|
16
25
|
@document_loader = document_loader || -> { GrantResolver.trusted_document(Ace::Lab.authorization_path) }
|
|
17
26
|
end
|
|
18
27
|
|
|
19
|
-
def input_binding(bytes, expected_digest:, expected_target:)
|
|
28
|
+
def input_binding(bytes, expected_digest:, expected_target:, operation:)
|
|
20
29
|
unless bytes.is_a?(String) && bytes.bytesize.between?(1, Atoms::ServiceInput::MAX_BYTES)
|
|
21
30
|
raise ArgumentError, "structured service input exceeds its fixed limit"
|
|
22
31
|
end
|
|
23
|
-
input =
|
|
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
|
|
24
39
|
raise ArgumentError, "structured service input must be an object" unless input.is_a?(Hash)
|
|
25
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
|
|
26
44
|
digest = Atoms::ServiceInput.digest(input)
|
|
27
45
|
target = Atoms::ServiceInput.target(input)
|
|
28
46
|
unless digest == expected_digest && target == expected_target
|
|
@@ -34,12 +52,26 @@ module Ace
|
|
|
34
52
|
end
|
|
35
53
|
|
|
36
54
|
def prepare!(binding, input_bytes:)
|
|
37
|
-
input = input_binding(input_bytes, expected_digest: binding.fetch("input_digest"),
|
|
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
|
|
38
60
|
authorized = authorize!(binding)
|
|
39
61
|
canonical = snapshot(binding).merge("executor_uid" => authorized.fetch(:operation).fetch("executor_uid"), "transport" => "unix")
|
|
40
62
|
deep_freeze(authorized.merge(binding: canonical, input: input.fetch(:input)))
|
|
41
63
|
end
|
|
42
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
|
+
|
|
43
75
|
# Called again inside each claim/begin CAS retry using the immutable
|
|
44
76
|
# binding and ephemeral parsed input, never a caller filesystem path.
|
|
45
77
|
def authorize!(binding)
|
|
@@ -67,6 +99,18 @@ module Ace
|
|
|
67
99
|
operation_digest: Ace::Assign::Atoms::EvidenceDigest.digest(operation))
|
|
68
100
|
end
|
|
69
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
|
+
|
|
70
114
|
def visible!(project:, uid:)
|
|
71
115
|
visible_document!(project: project, uid: uid)
|
|
72
116
|
true
|
|
@@ -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
|
|
|
@@ -99,6 +101,30 @@ module Ace
|
|
|
99
101
|
decision
|
|
100
102
|
end
|
|
101
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
|
+
|
|
102
128
|
private
|
|
103
129
|
|
|
104
130
|
# Validate the executable and every directory component on its
|
|
@@ -5,6 +5,10 @@ require "ace/assign/authority/router"
|
|
|
5
5
|
require "ace/assign/authority/launch_lifecycle"
|
|
6
6
|
require "ace/assign/authority/endcap"
|
|
7
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"
|
|
8
12
|
|
|
9
13
|
module Ace
|
|
10
14
|
module Lab
|
|
@@ -13,7 +17,7 @@ module Ace
|
|
|
13
17
|
# and placement; source selects all handlers, policy and evidence readers.
|
|
14
18
|
class AuthorityComposition
|
|
15
19
|
REQUIRED_ENDCAP = %w[submit_candidate export_candidate assign_review accept_review request_service
|
|
16
|
-
begin_dispatch complete_service complete_no_effect submit_result finish recover bind_inbox
|
|
20
|
+
begin_dispatch publication_challenge publication_continue claim_service_settlement complete_service complete_no_effect submit_result finish recover bind_inbox
|
|
17
21
|
service_status evidence_fetch].freeze
|
|
18
22
|
|
|
19
23
|
def initialize(authority_id:, deployment: Ace::Assign::Authority::Deployment.load,
|
|
@@ -54,11 +58,28 @@ module Ace
|
|
|
54
58
|
policy = Molecules::ProtectedServicePolicy.new(proposal_resolver: ->(project, reference, binding) {
|
|
55
59
|
journals.fetch(project).proposal_authorize!(reference, binding)
|
|
56
60
|
})
|
|
57
|
-
|
|
58
|
-
|
|
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)
|
|
59
66
|
router = Ace::Assign::Authority::Router.new(launch: launch, handlers: [endcap])
|
|
60
67
|
Ace::Assign::Authority::Server.new(authority_id: @authority_id, deployment: @deployment, kernel: @kernel,
|
|
61
|
-
lifecycle: router, composition: "services")
|
|
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)
|
|
62
83
|
end
|
|
63
84
|
end
|
|
64
85
|
end
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/runtime/molecules/protected_socket"
|
|
4
|
+
require "ace/runtime/molecules/protected_artifact_set"
|
|
5
|
+
require "ace/assign/authority/transfer_codec"
|
|
6
|
+
require_relative "../molecules/protected_cleanup_owner_admission"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Lab
|
|
10
|
+
module Organisms
|
|
11
|
+
# The immutable direct root entry supplies all collaborators. Durable
|
|
12
|
+
# request/outcome authority stays in its original canonical journal and
|
|
13
|
+
# Installer's retained physical result, never this transport owner.
|
|
14
|
+
class ProtectedCleanupOwner
|
|
15
|
+
Wire = Ace::Runtime::Molecules::ProtectedSocket
|
|
16
|
+
Client = Molecules::ProtectedCleanupOwnerClient
|
|
17
|
+
MAX_RETAINED = 256
|
|
18
|
+
MAX_CONNECTIONS = 16
|
|
19
|
+
|
|
20
|
+
def initialize(observer:, admission:, snapshots:, journals:, kernel:, installer:, scratch_root:,
|
|
21
|
+
protection: Ace::Runtime::Molecules::ProtectedArtifactSet::Protection.new)
|
|
22
|
+
@observer, @admission, @snapshots, @journals = observer, admission, snapshots, journals
|
|
23
|
+
@kernel, @installer = kernel, installer
|
|
24
|
+
@protection = protection
|
|
25
|
+
@codec = Ace::Assign::Authority::TransferCodec.new(root: scratch_root)
|
|
26
|
+
@lock, @consumed = Mutex.new, {}
|
|
27
|
+
@connections, @changed = [], ConditionVariable.new
|
|
28
|
+
@stopping = false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Only the trusted fixed entry supplies the installed connection group.
|
|
32
|
+
# Endpoint and lock paths remain source constants, never request inputs.
|
|
33
|
+
def serve(connect_gid:)
|
|
34
|
+
@admission.connection_group!(connect_gid)
|
|
35
|
+
@observer.observe_self!(deadline: Wire.deadline(5))
|
|
36
|
+
path = Client::PATH
|
|
37
|
+
parent = File.dirname(path)
|
|
38
|
+
Wire.root_path!(parent, directory: true)
|
|
39
|
+
parents = hold_parent!(parent)
|
|
40
|
+
parent_stat = File.lstat(parent)
|
|
41
|
+
unless parent_stat.directory? && parent_stat.uid == Process.uid && parent_stat.gid == connect_gid && (parent_stat.mode & 0o7777) == 0o2750
|
|
42
|
+
raise SecurityError, "cleanup listener parent differs"
|
|
43
|
+
end
|
|
44
|
+
parent_identity = [parent_stat.dev, parent_stat.ino]
|
|
45
|
+
lifetime = File.open("#{path}.lock", File::RDWR | File::CREAT | File::NOFOLLOW | File::NONBLOCK, 0o600)
|
|
46
|
+
lifetime.close_on_exec = true
|
|
47
|
+
stat = lifetime.stat
|
|
48
|
+
unless stat.file? && stat.uid == Process.uid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
|
|
49
|
+
raise SecurityError, "cleanup lifetime lock differs"
|
|
50
|
+
end
|
|
51
|
+
@protection.verify!("#{path}.lock", lifetime, directory: false)
|
|
52
|
+
raise SecurityError, "cleanup lifetime lock path changed" unless file_identity(File.lstat("#{path}.lock")) == file_identity(lifetime.stat)
|
|
53
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup listener already active" unless lifetime.flock(File::LOCK_EX | File::LOCK_NB)
|
|
54
|
+
raise SecurityError, "cleanup lifetime lock path changed after flock" unless file_identity(File.lstat("#{path}.lock")) == file_identity(lifetime.stat)
|
|
55
|
+
verify_parents!(parents)
|
|
56
|
+
begin
|
|
57
|
+
stale = File.lstat(path)
|
|
58
|
+
unless stale.socket? && stale.uid == Process.uid && stale.gid == connect_gid && (stale.mode & 0o7777) == 0o660
|
|
59
|
+
raise SecurityError, "cleanup stale endpoint differs"
|
|
60
|
+
end
|
|
61
|
+
File.unlink(path)
|
|
62
|
+
rescue Errno::ENOENT
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
@listener = UNIXServer.new(path)
|
|
66
|
+
@listener.close_on_exec = true
|
|
67
|
+
created = File.lstat(path)
|
|
68
|
+
unless created.socket? && created.uid == Process.uid && created.gid == connect_gid &&
|
|
69
|
+
[File.lstat(parent).dev, File.lstat(parent).ino] == parent_identity
|
|
70
|
+
raise SecurityError, "cleanup endpoint creation differs"
|
|
71
|
+
end
|
|
72
|
+
endpoint = [created.dev, created.ino, created.uid, created.gid]
|
|
73
|
+
File.chmod(0o660, path)
|
|
74
|
+
own = File.lstat(path)
|
|
75
|
+
unless own.socket? && (own.mode & 0o7777) == 0o660 && [own.dev, own.ino, own.uid, own.gid] == endpoint && [File.lstat(parent).dev, File.lstat(parent).ino] == parent_identity
|
|
76
|
+
raise SecurityError, "cleanup endpoint publication differs"
|
|
77
|
+
end
|
|
78
|
+
published = true
|
|
79
|
+
until @stopping
|
|
80
|
+
socket = @listener.accept
|
|
81
|
+
accepted_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
82
|
+
socket.close_on_exec = true
|
|
83
|
+
admitted = @lock.synchronize do
|
|
84
|
+
@connections.reject! { |thread| !thread.alive? }
|
|
85
|
+
if @connections.size >= MAX_CONNECTIONS
|
|
86
|
+
false
|
|
87
|
+
else
|
|
88
|
+
worker = Thread.new(socket, accepted_at) do |owned_socket, original_acceptance|
|
|
89
|
+
begin
|
|
90
|
+
verify_parents!(parents)
|
|
91
|
+
current = File.lstat(path)
|
|
92
|
+
unless current.socket? && (current.mode & 0o7777) == 0o660 && [current.dev, current.ino, current.uid, current.gid] == endpoint
|
|
93
|
+
raise SecurityError, "cleanup endpoint changed"
|
|
94
|
+
end
|
|
95
|
+
handle(owned_socket, accepted_at: original_acceptance)
|
|
96
|
+
rescue SecurityError, Ace::Runtime::RuntimeUnavailableError, Ace::Assign::Error, IOError, SystemCallError => error
|
|
97
|
+
# Refusal/transport failure closes only this connection.
|
|
98
|
+
# Its consumed state, including uncertainty, is preserved.
|
|
99
|
+
code = error.is_a?(SecurityError) ? "unauthorized_or_changed_selection" : "unavailable"
|
|
100
|
+
warn("cleanup connection refused: #{code}")
|
|
101
|
+
ensure
|
|
102
|
+
owned_socket.close unless owned_socket.closed?
|
|
103
|
+
@lock.synchronize { @changed.broadcast }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
@connections << worker
|
|
107
|
+
true
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
socket.close unless admitted
|
|
111
|
+
end
|
|
112
|
+
rescue IOError, Errno::EBADF
|
|
113
|
+
raise unless @stopping
|
|
114
|
+
ensure
|
|
115
|
+
@listener.close if @listener && !@listener.closed?
|
|
116
|
+
@lock.synchronize do
|
|
117
|
+
# Wait for actual callbacks, never mistake a dead callback's
|
|
118
|
+
# unconfirmed outcome for activity or physical settlement.
|
|
119
|
+
while @connections.any?(&:alive?) || @consumed.values.any? { |entry| entry[:state] == :invoked }
|
|
120
|
+
@changed.wait(@lock, 0.1)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
unconfirmed = @lock.synchronize { @consumed.values.any? { |entry| entry[:state] == :unconfirmed } }
|
|
124
|
+
if endpoint
|
|
125
|
+
begin
|
|
126
|
+
verify_parents!(parents)
|
|
127
|
+
current = File.lstat(path)
|
|
128
|
+
File.unlink(path) if current.socket? && (!published || (current.mode & 0o7777) == 0o660) && [current.dev, current.ino, current.uid, current.gid] == endpoint
|
|
129
|
+
rescue Errno::ENOENT
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
lifetime&.close
|
|
134
|
+
parents&.each { |entry| entry.fetch(:handle).close }
|
|
135
|
+
if unconfirmed && $!.nil?
|
|
136
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup invocation outcome remains unconfirmed after callback termination"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def stop
|
|
141
|
+
@stopping = true
|
|
142
|
+
@listener.close if @listener && !@listener.closed?
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Listener construction and fixed socket ownership are separately
|
|
146
|
+
# provisioned; every accepted connection still authenticates its peer.
|
|
147
|
+
def handle(socket, accepted_at: Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
148
|
+
admission_deadline = accepted_at + 5
|
|
149
|
+
peer = @kernel.peer(socket)
|
|
150
|
+
frame = Wire.read(Wire::Ingress.new(socket), deadline: admission_deadline, limit: 65_536)
|
|
151
|
+
eof!(socket, admission_deadline)
|
|
152
|
+
original = @observer.observe_self!(deadline: admission_deadline)
|
|
153
|
+
if frame == {"schema" => Client::SCHEMA, "kind" => "identity"}
|
|
154
|
+
@admission.identity_reader!(peer)
|
|
155
|
+
Wire.write(socket, {"schema" => Client::SCHEMA, "kind" => "identity", "operation_owner_binding" => original},
|
|
156
|
+
deadline: admission_deadline, limit: Client::LIMIT)
|
|
157
|
+
return
|
|
158
|
+
end
|
|
159
|
+
@admission.receiver!(peer)
|
|
160
|
+
receiver_peer = Atoms::ProtectedWorkspacePruneInput.freeze_value(JSON.parse(JSON.generate(peer)))
|
|
161
|
+
deadline = frame["kind"] == "preview" ? admission_deadline : accepted_at + 300
|
|
162
|
+
context = nil
|
|
163
|
+
@snapshots.call do |snapshot|
|
|
164
|
+
# Reuse exact immutable event bytes only during this held read.
|
|
165
|
+
# Peer admission remains fresh; neither the view nor its memo
|
|
166
|
+
# survives into Installer effects or the next connection.
|
|
167
|
+
Ace::Assign::Molecules::EvidenceJournal.with_event_read_operation do
|
|
168
|
+
snapshot.with(deadline: deadline) do |view|
|
|
169
|
+
context = if frame["kind"] == "preview"
|
|
170
|
+
@admission.preview!(frame: frame, peer: peer, operation_owner_binding: original, journal: @journals.call(view))
|
|
171
|
+
elsif frame["kind"] == "inspect"
|
|
172
|
+
@admission.inspect!(frame: frame, peer: peer, operation_owner_binding: original, journal: @journals.call(view))
|
|
173
|
+
else
|
|
174
|
+
@admission.admit!(frame: frame, peer: peer, operation_owner_binding: original, journal: @journals.call(view))
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
if frame["kind"] == "preview"
|
|
180
|
+
unless @installer.respond_to?(:preview_cleanup!)
|
|
181
|
+
raise SecurityError, "same Installer preview producer unavailable"
|
|
182
|
+
end
|
|
183
|
+
result = @installer.preview_cleanup!(context: context, receiver_peer: receiver_peer, deadline: deadline)
|
|
184
|
+
unless Ace::Assign::Atoms::EvidenceDigest.digest(@observer.observe_self!(deadline: deadline)) ==
|
|
185
|
+
Ace::Assign::Atoms::EvidenceDigest.digest(original)
|
|
186
|
+
raise SecurityError, "cleanup original owner changed during preview"
|
|
187
|
+
end
|
|
188
|
+
result = Ace::Lab::Atoms::ProtectedWorkspacePrunePreview.result!(result,
|
|
189
|
+
intent: frame.fetch("intent"), context: frame.fetch("maintenance_context"))
|
|
190
|
+
Wire.write(socket, result, deadline: deadline, limit: Client::LIMIT)
|
|
191
|
+
return
|
|
192
|
+
end
|
|
193
|
+
if frame["kind"] == "inspect"
|
|
194
|
+
inspection(socket, frame, context, original, deadline, receiver_peer: receiver_peer)
|
|
195
|
+
return
|
|
196
|
+
end
|
|
197
|
+
key = Ace::Assign::Atoms::EvidenceDigest.digest(frame)
|
|
198
|
+
retained = @lock.synchronize do
|
|
199
|
+
if (entry = @consumed[context.fetch("record").fetch("request_id")])
|
|
200
|
+
raise SecurityError, "cleanup original invocation input differs" unless entry.fetch(:key) == key
|
|
201
|
+
entry
|
|
202
|
+
else
|
|
203
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup lifetime capacity unavailable" if @consumed.size >= MAX_RETAINED
|
|
204
|
+
@consumed[context.fetch("record").fetch("request_id")] = {key: key, state: :invoking}
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
result = @lock.synchronize { retained[:result] }
|
|
208
|
+
unless result
|
|
209
|
+
authorized = @lock.synchronize do
|
|
210
|
+
next false unless retained[:state] == :invoking && !retained[:inhibited]
|
|
211
|
+
retained[:state] = :invoked
|
|
212
|
+
true
|
|
213
|
+
end
|
|
214
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup original result unavailable" unless authorized
|
|
215
|
+
begin
|
|
216
|
+
# No canonical/source lock is held over fixed privileged work.
|
|
217
|
+
unless @observer.observe_self!(deadline: [deadline, Wire.deadline(5)].min) == original
|
|
218
|
+
raise SecurityError, "cleanup original owner changed before invocation"
|
|
219
|
+
end
|
|
220
|
+
result = @installer.execute_cleanup!(context: context, receiver_peer: receiver_peer, deadline: deadline)
|
|
221
|
+
result = result!(result, frame)
|
|
222
|
+
@lock.synchronize { retained[:result] = result; retained[:state] = :completed }
|
|
223
|
+
ensure
|
|
224
|
+
@lock.synchronize do
|
|
225
|
+
if retained[:state] == :invoked
|
|
226
|
+
retained[:state] = :unconfirmed
|
|
227
|
+
retained[:inhibited] = true
|
|
228
|
+
end
|
|
229
|
+
@changed.broadcast
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
unless @observer.observe_self!(deadline: [deadline, Wire.deadline(5)].min) == original
|
|
234
|
+
raise SecurityError, "cleanup original owner changed before result"
|
|
235
|
+
end
|
|
236
|
+
ref = result.fetch(:receipt_ref)
|
|
237
|
+
Wire.write(socket, {"schema" => Client::SCHEMA, "kind" => "result", "request_id" => frame.fetch("request_id"),
|
|
238
|
+
"input_digest" => frame.fetch("input_digest"), "receipt_ref" => ref}, deadline: deadline, limit: Client::LIMIT)
|
|
239
|
+
descriptor = @codec.descriptor([result.fetch(:bytes)], purpose: :artifacts)
|
|
240
|
+
@codec.send(socket, parts: [result.fetch(:bytes)], descriptor: descriptor, purpose: :artifacts, deadline: deadline)
|
|
241
|
+
ensure
|
|
242
|
+
socket.close unless socket.closed?
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
private
|
|
246
|
+
|
|
247
|
+
def inspection(socket, frame, context, original, deadline, receiver_peer:)
|
|
248
|
+
original_owner = Ace::Assign::Atoms::EvidenceDigest.digest(context.fetch("record").fetch("operation_owner_binding")) ==
|
|
249
|
+
Ace::Assign::Atoms::EvidenceDigest.digest(original)
|
|
250
|
+
if original_owner
|
|
251
|
+
executable = frame.except("challenge_ref").merge("kind" => "execute")
|
|
252
|
+
key = Ace::Assign::Atoms::EvidenceDigest.digest(executable)
|
|
253
|
+
@lock.synchronize do
|
|
254
|
+
request = context.fetch("record").fetch("request_id")
|
|
255
|
+
retained = @consumed[request]
|
|
256
|
+
if retained
|
|
257
|
+
raise SecurityError, "cleanup original inspection input differs" unless retained.fetch(:key) == key
|
|
258
|
+
else
|
|
259
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup lifetime capacity unavailable" if @consumed.size >= MAX_RETAINED
|
|
260
|
+
retained = @consumed[request] = {key: key, state: :inhibited}
|
|
261
|
+
end
|
|
262
|
+
retained[:inhibited] = true
|
|
263
|
+
retained[:state] = :inhibited if retained[:state] == :invoking
|
|
264
|
+
while retained[:state] == :invoked
|
|
265
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
266
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup original effect remains unconfirmed" unless remaining.positive?
|
|
267
|
+
@changed.wait(@lock, remaining)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
# This is callback inhibition only. Child/writer/physical absence
|
|
271
|
+
# still requires the actual domain inspector's independent proof.
|
|
272
|
+
inhibition = {"operation_owner_binding_digest" => frame.fetch("operation_owner_binding_digest"),
|
|
273
|
+
"dispatch_event_digest" => frame.fetch("dispatch_event_digest"), "state" => "inhibited", "pending_effects" => 0}
|
|
274
|
+
context = Atoms::ProtectedWorkspacePruneInput.freeze_value(context.merge("input_inhibition" => inhibition))
|
|
275
|
+
end
|
|
276
|
+
unless @observer.observe_self!(deadline: [deadline, Wire.deadline(5)].min) == original
|
|
277
|
+
raise SecurityError, "cleanup readback owner changed before inspection"
|
|
278
|
+
end
|
|
279
|
+
inspected = @installer.inspect_cleanup!(context: context, receiver_peer: receiver_peer, deadline: deadline)
|
|
280
|
+
unless inspected.is_a?(Hash) && inspected.keys.sort == %i[bytes kind receipt_ref] &&
|
|
281
|
+
%w[succeeded failed-no-effect].include?(inspected[:kind])
|
|
282
|
+
raise SecurityError, "cleanup inspection result unavailable"
|
|
283
|
+
end
|
|
284
|
+
kind = inspected.fetch(:kind).dup.freeze
|
|
285
|
+
unless original_owner || kind == "succeeded"
|
|
286
|
+
raise Ace::Runtime::RuntimeUnavailableError, "original cleanup lifetime exclusion unavailable"
|
|
287
|
+
end
|
|
288
|
+
result = result!(inspected.except(:kind), frame)
|
|
289
|
+
unless @observer.observe_self!(deadline: [deadline, Wire.deadline(5)].min) == original
|
|
290
|
+
raise SecurityError, "cleanup readback owner changed before inspection result"
|
|
291
|
+
end
|
|
292
|
+
success = kind == "succeeded"
|
|
293
|
+
Wire.write(socket, {"schema" => Client::SCHEMA, "kind" => success ? "completed-result" : "inspection",
|
|
294
|
+
"request_id" => frame.fetch("request_id"), "input_digest" => frame.fetch("input_digest"),
|
|
295
|
+
(success ? "receipt_ref" : "inspection_ref") => result.fetch(:receipt_ref)}, deadline: deadline, limit: Client::LIMIT)
|
|
296
|
+
descriptor = @codec.descriptor([result.fetch(:bytes)], purpose: :artifacts)
|
|
297
|
+
@codec.send(socket, parts: [result.fetch(:bytes)], descriptor: descriptor, purpose: :artifacts, deadline: deadline)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def hold_parent!(parent)
|
|
301
|
+
entries = []
|
|
302
|
+
paths = []
|
|
303
|
+
cursor = parent
|
|
304
|
+
loop do
|
|
305
|
+
paths << cursor
|
|
306
|
+
break if cursor == "/"
|
|
307
|
+
cursor = File.dirname(cursor)
|
|
308
|
+
end
|
|
309
|
+
paths.reverse_each do |path|
|
|
310
|
+
handle = File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
311
|
+
handle.close_on_exec = true
|
|
312
|
+
entries << {path: path, handle: handle, identity: file_identity(handle.stat)}
|
|
313
|
+
@protection.verify!(path, handle, directory: true)
|
|
314
|
+
raise SecurityError, "cleanup parent changed while pinned" unless file_identity(File.lstat(path)) == entries.last.fetch(:identity)
|
|
315
|
+
end
|
|
316
|
+
complete = true
|
|
317
|
+
entries
|
|
318
|
+
ensure
|
|
319
|
+
entries&.each { |entry| entry.fetch(:handle).close } unless complete
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def verify_parents!(entries)
|
|
323
|
+
entries.each do |entry|
|
|
324
|
+
unless file_identity(entry.fetch(:handle).stat) == entry.fetch(:identity) &&
|
|
325
|
+
file_identity(File.lstat(entry.fetch(:path))) == entry.fetch(:identity)
|
|
326
|
+
raise SecurityError, "cleanup protected ancestry changed"
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def file_identity(stat) = [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode]
|
|
332
|
+
|
|
333
|
+
def result!(result, frame)
|
|
334
|
+
unless result.is_a?(Hash) && result.keys.sort == %i[bytes receipt_ref] && result.fetch(:bytes).is_a?(String)
|
|
335
|
+
raise SecurityError, "cleanup fixed result unavailable"
|
|
336
|
+
end
|
|
337
|
+
ref = result.fetch(:receipt_ref)
|
|
338
|
+
Atoms::ProtectedWorkspacePruneInput.reference!(ref)
|
|
339
|
+
bytes = result.fetch(:bytes)
|
|
340
|
+
unless bytes.bytesize == ref.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == ref.fetch("sha256")
|
|
341
|
+
raise SecurityError, "cleanup fixed result bytes differ"
|
|
342
|
+
end
|
|
343
|
+
result = {receipt_ref: JSON.parse(JSON.generate(ref)), bytes: bytes.dup}
|
|
344
|
+
Atoms::ProtectedWorkspacePruneInput.freeze_value(result)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def eof!(socket, deadline)
|
|
348
|
+
guarded = Wire::Ingress.new(socket)
|
|
349
|
+
loop do
|
|
350
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
351
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup request deadline expired" unless remaining.positive? && IO.select([socket], nil, nil, remaining)
|
|
352
|
+
value = guarded.read_nonblock(1, exception: false)
|
|
353
|
+
next if value == :wait_readable
|
|
354
|
+
raise SecurityError, "cleanup request contains trailing bytes" unless value.nil?
|
|
355
|
+
return
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|