ace-lab 0.3.1 → 0.4.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 +11 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +3 -1
- data/lib/ace/lab/molecules/protected_service_handler.rb +74 -0
- data/lib/ace/lab/molecules/protected_service_policy.rb +112 -0
- data/lib/ace/lab/molecules/service_policy.rb +10 -1
- data/lib/ace/lab/organisms/authority_composition.rb +66 -0
- data/lib/ace/lab/organisms/protected_service_receiver.rb +177 -0
- data/lib/ace/lab/organisms/service_request_service.rb +4 -2
- data/lib/ace/lab/version.rb +1 -1
- metadata +22 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bbfb351af77987d179094c39d14b12ac6209ba6825bc680e932df4fcf484684d
|
|
4
|
+
data.tar.gz: 92d60f3d735582fc4371569d1f2d19691820d924855564ac95ac71eba9e06ecf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a088f9ce2372cf71bfabe2df3f58b578ced3b1dae4f28ac1d791c90af64b4ac2795020e3d7ecbd8158cab7c75e2be6413250b22a7413459e466dc5d155404fe9
|
|
7
|
+
data.tar.gz: 38031154d986e6d25fcfac08ee890cf619553ff26536327b128076f4fbec7c862a76bf6e04c438aea2bd87a6eb566aa82299959809c44fefd00c1edb0ab7433f
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-10-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Protected service policy, receiver and authority composition; incomplete full-service startup remains refused.
|
|
15
|
+
- Resolve immutable second-commander proposals through confirmed-delivery sixteen-hour policy and canonical Assign authorization.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Declare the required direct dependencies and minimum producer versions for this coordinated release: `ace-assign ~> 0.64`, `ace-runtime ~> 0.2`.
|
|
20
|
+
|
|
10
21
|
## [0.3.1] - 2026-10-04
|
|
11
22
|
|
|
12
23
|
### Changed
|
|
@@ -33,7 +33,9 @@ module Ace
|
|
|
33
33
|
raise Ace::Lab::InvalidConfigurationError,
|
|
34
34
|
"invalid lab configuration: trusted authorization file must contain a mapping"
|
|
35
35
|
end
|
|
36
|
-
|
|
36
|
+
# Use the same principal schema and the same verified bytes for
|
|
37
|
+
# policy composition, rather than reading a second grants snapshot.
|
|
38
|
+
document.merge("principals" => parse_grants(content, path).fetch("principals"))
|
|
37
39
|
rescue Psych::Exception
|
|
38
40
|
raise Ace::Lab::InvalidConfigurationError,
|
|
39
41
|
"invalid lab configuration: trusted authorization file could not be parsed"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
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
|
+
out = nil
|
|
23
|
+
Open3.popen3({"PATH" => "/usr/bin:/bin", "LANG" => "C.UTF-8", "HOME" => root},
|
|
24
|
+
*operation.fetch("argv"), chdir: root, pgroup: true, unsetenv_others: true) do |stdin, stdout, stderr, waiter|
|
|
25
|
+
readers = []
|
|
26
|
+
begin
|
|
27
|
+
Timeout.timeout(30) do
|
|
28
|
+
readers = [Thread.new { bounded(stdout, MAX_STDOUT) }, Thread.new { bounded(stderr, MAX_STDERR) }]
|
|
29
|
+
stdin.write(JSON.generate(envelope))
|
|
30
|
+
stdin.close
|
|
31
|
+
output, errors = readers.map(&:value)
|
|
32
|
+
unless output && errors && waiter.value.success?
|
|
33
|
+
return nil
|
|
34
|
+
end
|
|
35
|
+
out = output
|
|
36
|
+
end
|
|
37
|
+
rescue Timeout::Error, IOError, SystemCallError
|
|
38
|
+
return nil
|
|
39
|
+
ensure
|
|
40
|
+
stdin.close unless stdin.closed?
|
|
41
|
+
# This group is created and owned by this receiver. Termination
|
|
42
|
+
# is cleanup, never proof that an arbitrary handler left no effect.
|
|
43
|
+
Process.kill("KILL", -waiter.pid) rescue nil
|
|
44
|
+
waiter.value
|
|
45
|
+
[stdout, stderr].each { |io| io.close unless io.closed? }
|
|
46
|
+
readers.each(&:join)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
response = JSON.parse(out)
|
|
50
|
+
request = envelope.fetch("request")
|
|
51
|
+
unless response.is_a?(Hash) && response.keys.sort == %w[evidence input_digest outcome request_id] &&
|
|
52
|
+
response["request_id"] == request.fetch("request_id") && response["input_digest"] == request.fetch("input_digest") &&
|
|
53
|
+
%w[succeeded failed].include?(response["outcome"]) && response["evidence"].is_a?(Array)
|
|
54
|
+
return nil
|
|
55
|
+
end
|
|
56
|
+
response
|
|
57
|
+
rescue JSON::ParserError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def bounded(io, limit)
|
|
64
|
+
bytes = io.read(limit + 1).to_s
|
|
65
|
+
bytes.bytesize <= limit ? bytes : nil
|
|
66
|
+
rescue IOError, SystemCallError
|
|
67
|
+
nil
|
|
68
|
+
ensure
|
|
69
|
+
io.close unless io.closed?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "etc"
|
|
5
|
+
require "ace/assign"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Lab
|
|
9
|
+
module Molecules
|
|
10
|
+
# The receiver and authority share the existing input/policy owners.
|
|
11
|
+
# Only composition supplies collaborators; no wire-selected policy,
|
|
12
|
+
# executable, input path or caller identity is accepted here.
|
|
13
|
+
class ProtectedServicePolicy
|
|
14
|
+
def initialize(proposal_resolver:, document_loader: nil)
|
|
15
|
+
@proposal_resolver = proposal_resolver
|
|
16
|
+
@document_loader = document_loader || -> { GrantResolver.trusted_document(Ace::Lab.authorization_path) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def input_binding(bytes, expected_digest:, expected_target:)
|
|
20
|
+
unless bytes.is_a?(String) && bytes.bytesize.between?(1, Atoms::ServiceInput::MAX_BYTES)
|
|
21
|
+
raise ArgumentError, "structured service input exceeds its fixed limit"
|
|
22
|
+
end
|
|
23
|
+
input = JSON.parse(bytes)
|
|
24
|
+
raise ArgumentError, "structured service input must be an object" unless input.is_a?(Hash)
|
|
25
|
+
Atoms::ServiceInput.validate!(input)
|
|
26
|
+
digest = Atoms::ServiceInput.digest(input)
|
|
27
|
+
target = Atoms::ServiceInput.target(input)
|
|
28
|
+
unless digest == expected_digest && target == expected_target
|
|
29
|
+
raise SecurityError, "structured service input differs from the bound digest or target"
|
|
30
|
+
end
|
|
31
|
+
deep_freeze(input: input, input_digest: digest, target: target)
|
|
32
|
+
rescue JSON::ParserError
|
|
33
|
+
raise ArgumentError, "structured service input must be valid JSON"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def prepare!(binding, input_bytes:)
|
|
37
|
+
input = input_binding(input_bytes, expected_digest: binding.fetch("input_digest"), expected_target: binding.fetch("target"))
|
|
38
|
+
authorized = authorize!(binding)
|
|
39
|
+
canonical = snapshot(binding).merge("executor_uid" => authorized.fetch(:operation).fetch("executor_uid"), "transport" => "unix")
|
|
40
|
+
deep_freeze(authorized.merge(binding: canonical, input: input.fetch(:input)))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Called again inside each claim/begin CAS retry using the immutable
|
|
44
|
+
# binding and ephemeral parsed input, never a caller filesystem path.
|
|
45
|
+
def authorize!(binding)
|
|
46
|
+
uid = binding.fetch("caller_uid")
|
|
47
|
+
document = visible_document!(project: binding.fetch("project_id"), uid: uid)
|
|
48
|
+
resolver = ->(reference, exact) { @proposal_resolver.call(binding.fetch("project_id"), reference, exact) }
|
|
49
|
+
policy = ServicePolicy.new(document, resolver)
|
|
50
|
+
operation = policy.operation!(binding.fetch("operation"), project: binding.fetch("project_id"), service_id: binding.fetch("service_id"))
|
|
51
|
+
unless operation.fetch("transport", "local") == "local"
|
|
52
|
+
raise Ace::Lab::InvalidConfigurationError, "protected receiver requires a fixed local handler operation"
|
|
53
|
+
end
|
|
54
|
+
unless operation.fetch("executor_uid").positive? && operation.fetch("executor_uid") != uid
|
|
55
|
+
raise SecurityError, "protected executor must be a separate nonroot account"
|
|
56
|
+
end
|
|
57
|
+
if binding.key?("executor_uid") && binding["executor_uid"] != operation.fetch("executor_uid")
|
|
58
|
+
raise SecurityError, "configured executor differs from the recorded claim"
|
|
59
|
+
end
|
|
60
|
+
decision = policy.authorize!(binding.fetch("authorization"), binding)
|
|
61
|
+
digest = Ace::Assign::Atoms::EvidenceDigest.digest("operation" => operation, "authorization" => decision,
|
|
62
|
+
"caller_uid" => uid, "project_id" => binding.fetch("project_id"))
|
|
63
|
+
if binding.key?("policy_digest") && binding["policy_digest"] != digest
|
|
64
|
+
raise SecurityError, "service policy changed after claim"
|
|
65
|
+
end
|
|
66
|
+
deep_freeze(operation: snapshot(operation), policy_digest: digest,
|
|
67
|
+
operation_digest: Ace::Assign::Atoms::EvidenceDigest.digest(operation))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def visible!(project:, uid:)
|
|
71
|
+
visible_document!(project: project, uid: uid)
|
|
72
|
+
true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def authorize_update!(existing, replacement, _pending)
|
|
76
|
+
initial_effect = existing.nil? && %w[accepted uncertain].include?(replacement["state"])
|
|
77
|
+
fresh_dispatch = existing && existing["dispatch_phase"] == "issued" && replacement["dispatch_phase"] == "dispatch_started"
|
|
78
|
+
authorize!(replacement) if initial_effect || fresh_dispatch
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def visible_document!(project:, uid:)
|
|
85
|
+
raise SecurityError, "protected caller UID is invalid" unless uid.is_a?(Integer) && uid.positive?
|
|
86
|
+
username = begin
|
|
87
|
+
Etc.getpwuid(uid).name
|
|
88
|
+
rescue ArgumentError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
document = @document_loader.call
|
|
92
|
+
identities = CallerAuthorizer.matchable_identities(username, uid)
|
|
93
|
+
visibility = CallerAuthorizer.new(principals: document.fetch("principals", {}), identity: identities)
|
|
94
|
+
raise SecurityError, "caller project grant has been revoked" unless visibility.authorized?(project)
|
|
95
|
+
document
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def snapshot(value)
|
|
99
|
+
JSON.parse(JSON.generate(value))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def deep_freeze(value)
|
|
103
|
+
case value
|
|
104
|
+
when Hash then value.each { |key, item| deep_freeze(key); deep_freeze(item) }
|
|
105
|
+
when Array then value.each { |item| deep_freeze(item) }
|
|
106
|
+
end
|
|
107
|
+
value.freeze
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -12,9 +12,10 @@ module Ace
|
|
|
12
12
|
NAME = /\A[a-z][a-z0-9-]{0,63}\z/
|
|
13
13
|
ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
14
14
|
|
|
15
|
-
def initialize(document)
|
|
15
|
+
def initialize(document, proposal_resolver = nil)
|
|
16
16
|
@operations = document.fetch("operations", {})
|
|
17
17
|
@authorizations = document.fetch("authorizations", {})
|
|
18
|
+
@proposal_resolver = proposal_resolver
|
|
18
19
|
unless @operations.is_a?(Hash) && @authorizations.is_a?(Hash)
|
|
19
20
|
raise Ace::Lab::InvalidConfigurationError, "trusted service policy must contain mappings"
|
|
20
21
|
end
|
|
@@ -80,6 +81,14 @@ module Ace
|
|
|
80
81
|
|
|
81
82
|
def authorize!(reference, binding)
|
|
82
83
|
raise ArgumentError, "invalid authorization reference" unless reference.to_s.match?(ID)
|
|
84
|
+
if reference.start_with?("proposal-")
|
|
85
|
+
raise SecurityError, "canonical proposal authority is unavailable" unless @proposal_resolver
|
|
86
|
+
begin
|
|
87
|
+
return @proposal_resolver.call(reference, binding)
|
|
88
|
+
rescue Ace::Assign::Error
|
|
89
|
+
raise SecurityError, "canonical proposal does not authorize this exact effect"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
83
92
|
decision = @authorizations[reference]
|
|
84
93
|
raise SecurityError, "authorization reference is unresolved" unless decision.is_a?(Hash)
|
|
85
94
|
required = %w[operation project_id assignment_id attempt_id input_digest target candidate_head caller_uid]
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Lab
|
|
11
|
+
module Organisms
|
|
12
|
+
# The only full-service composition. The deployed map chooses identity
|
|
13
|
+
# and placement; source selects all handlers, policy and evidence readers.
|
|
14
|
+
class AuthorityComposition
|
|
15
|
+
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 reconcile_inbox
|
|
17
|
+
service_status evidence_fetch].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(authority_id:, deployment: Ace::Assign::Authority::Deployment.load,
|
|
20
|
+
kernel: Ace::Runtime::Molecules::ProtectedLinux.new)
|
|
21
|
+
@authority_id, @deployment, @kernel = authority_id, deployment, kernel
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build
|
|
25
|
+
@deployment.verify_composition!(@authority_id, composition: "services")
|
|
26
|
+
unless (REQUIRED_ENDCAP - Ace::Assign::Authority::Endcap::OPERATIONS).empty?
|
|
27
|
+
raise InvalidConfigurationError, "full-service source composition is incomplete"
|
|
28
|
+
end
|
|
29
|
+
projects = @deployment.data.fetch("launch_mappings").values
|
|
30
|
+
.select { |map| map.fetch("authority_id") == @authority_id }.map { |map| map.fetch("project_id") }.uniq
|
|
31
|
+
raise InvalidConfigurationError, "full-service authority has no projects" if projects.empty?
|
|
32
|
+
projects.each do |id|
|
|
33
|
+
project = @deployment.project(id)
|
|
34
|
+
unless project["service_receivers"].is_a?(Hash) && !project["service_receivers"].empty?
|
|
35
|
+
raise InvalidConfigurationError, "full-service project has no fixed receiver"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
journals = {}
|
|
40
|
+
endcap = nil
|
|
41
|
+
projects.each do |id|
|
|
42
|
+
project = @deployment.project(id)
|
|
43
|
+
reader = nil
|
|
44
|
+
journal = Ace::Assign::Molecules::EvidenceJournal.new(repo_root: project.fetch("journal_repository"),
|
|
45
|
+
checkout_root: project.fetch("evidence_checkout_root"), ref: project.fetch("evidence_git_ref"), mode: :protected,
|
|
46
|
+
evidence_reader: ->(reference, record, state, pending) { reader.call(reference, record, state, pending) },
|
|
47
|
+
service_authorizer: ->(existing, replacement, pending) {
|
|
48
|
+
endcap.authorize_service_update!(journal: journals.fetch(id), existing: existing,
|
|
49
|
+
replacement: replacement, pending: pending)
|
|
50
|
+
})
|
|
51
|
+
reader = Ace::Assign::Authority::ServiceEvidence.new(journal: journal)
|
|
52
|
+
journals[id] = journal
|
|
53
|
+
end
|
|
54
|
+
policy = Molecules::ProtectedServicePolicy.new(proposal_resolver: ->(project, reference, binding) {
|
|
55
|
+
journals.fetch(project).proposal_authorize!(reference, binding)
|
|
56
|
+
})
|
|
57
|
+
launch = Ace::Assign::Authority::LaunchLifecycle.new(deployment: @deployment, kernel: @kernel, journals: journals)
|
|
58
|
+
endcap = Ace::Assign::Authority::Endcap.new(deployment: @deployment, launch: launch, kernel: @kernel, service_policy: policy)
|
|
59
|
+
router = Ace::Assign::Authority::Router.new(launch: launch, handlers: [endcap])
|
|
60
|
+
Ace::Assign::Authority::Server.new(authority_id: @authority_id, deployment: @deployment, kernel: @kernel,
|
|
61
|
+
lifecycle: router, composition: "services")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "ace/assign/authority/client"
|
|
7
|
+
require "ace/assign/authority/candidate_transfer"
|
|
8
|
+
require "ace/assign/authority/evidence_transfer"
|
|
9
|
+
require_relative "../molecules/protected_service_handler"
|
|
10
|
+
require_relative "../molecules/protected_service_policy"
|
|
11
|
+
|
|
12
|
+
module Ace
|
|
13
|
+
module Lab
|
|
14
|
+
module Organisms
|
|
15
|
+
# Fixed executor-side orchestration. All durable permission and outcome
|
|
16
|
+
# state remains in the existing authority journal. Transport supplies
|
|
17
|
+
# the kernel peer; submitted actor strings are never accepted.
|
|
18
|
+
class ProtectedServiceReceiver
|
|
19
|
+
SUBMISSION = %w[assignment_id attempt_id expected_generation candidate_generation head request_id
|
|
20
|
+
operation input_digest target authorization].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(mapping_id:, service_id:, deployment: Ace::Assign::Authority::Deployment.load,
|
|
23
|
+
kernel: Ace::Runtime::Molecules::ProtectedLinux.new, client: nil,
|
|
24
|
+
handler: Molecules::ProtectedServiceHandler.new)
|
|
25
|
+
@mapping_id, @service_id, @deployment, @kernel, @handler = mapping_id, service_id, deployment, kernel, handler
|
|
26
|
+
@map = deployment.mapping(mapping_id)
|
|
27
|
+
deployment.verify_composition!(@map.fetch("authority_id"), composition: "services")
|
|
28
|
+
@project = deployment.project(@map.fetch("project_id"))
|
|
29
|
+
@receiver = @project.fetch("service_receivers").fetch(service_id)
|
|
30
|
+
@client = client || Ace::Assign::Authority::Client.new(mapping_id: mapping_id, deployment: deployment, kernel: kernel)
|
|
31
|
+
@inputs = Molecules::ProtectedServicePolicy.new(proposal_resolver: ->(*) { raise SecurityError, "proposal decisions belong to authority" })
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def execute(submission:, peer:, input_bytes:, mutation_id:)
|
|
35
|
+
contacted = false
|
|
36
|
+
request_id = nil
|
|
37
|
+
unless submission.is_a?(Hash) && submission.keys.sort == SUBMISSION.sort &&
|
|
38
|
+
peer.fetch("uid") == @map.fetch("worker_uid") && peer["gid"] == @map.fetch("worker_gid") &&
|
|
39
|
+
peer["groups"] == @map.fetch("worker_groups")
|
|
40
|
+
raise SecurityError, "receiver submission or peer differs"
|
|
41
|
+
end
|
|
42
|
+
params = immutable(submission.merge("service_id" => @service_id, "worker_process_binding" => peer))
|
|
43
|
+
id = params.fetch("request_id")
|
|
44
|
+
unless id.is_a?(String) && id.match?(Ace::Assign::Molecules::JournalMutation::ID)
|
|
45
|
+
raise ArgumentError, "receiver request identity is invalid"
|
|
46
|
+
end
|
|
47
|
+
request_id = id
|
|
48
|
+
peer = params.fetch("worker_process_binding")
|
|
49
|
+
@kernel.live!(peer)
|
|
50
|
+
me = @kernel.capture(Process.pid)
|
|
51
|
+
credentials = @project.fetch("peer_credentials").fetch(@receiver.fetch("executor_uid").to_s)
|
|
52
|
+
unless me["uid"] == @receiver["executor_uid"] && me["gid"] == credentials["gid"] && me["groups"] == credentials["groups"]
|
|
53
|
+
raise SecurityError, "receiver principal differs"
|
|
54
|
+
end
|
|
55
|
+
unless input_bytes.is_a?(String) && mutation_id.is_a?(String) && mutation_id.match?(Ace::Assign::Molecules::JournalMutation::ID)
|
|
56
|
+
raise ArgumentError, "receiver input or mutation identity is invalid"
|
|
57
|
+
end
|
|
58
|
+
bytes = input_bytes.dup.freeze
|
|
59
|
+
input = @inputs.input_binding(bytes, expected_digest: params.fetch("input_digest"), expected_target: params.fetch("target"))
|
|
60
|
+
contacted = true
|
|
61
|
+
claim = @client.call("request_service", params, mutation_id: mutation_id, upload_parts: [bytes], purpose: :service_input)
|
|
62
|
+
unless !claim.replayed && claim.data["claim"] == "created"
|
|
63
|
+
return projection(claim.data)
|
|
64
|
+
end
|
|
65
|
+
binding = params.slice("assignment_id", "attempt_id", "candidate_generation", "head", "request_id")
|
|
66
|
+
exported = @client.call("export_candidate", binding.except("request_id").merge("purpose_id" => params.fetch("request_id")),
|
|
67
|
+
download: true, purpose: :candidate, timeout: 30)
|
|
68
|
+
unless exported.parts.is_a?(Array) && exported.parts.length == 1
|
|
69
|
+
raise SecurityError, "candidate export is incomplete"
|
|
70
|
+
end
|
|
71
|
+
materialized = Ace::Assign::Authority::CandidateTransfer.new(root: @receiver.fetch("staging_root")).materialize(
|
|
72
|
+
bytes: exported.parts.first, sha256: exported.data.fetch("sha256"), size: exported.data.fetch("bytes"),
|
|
73
|
+
head: binding.fetch("head"), tree: exported.data.fetch("tree"), root: @receiver.fetch("staging_root"))
|
|
74
|
+
staging_identity = File.lstat(materialized.fetch("directory"))
|
|
75
|
+
begin_params = binding.merge("claim_binding" => claim.data.fetch("claim_binding"), "expected_generation" => claim.data.fetch("generation"))
|
|
76
|
+
started = @client.call("begin_dispatch", begin_params, mutation_id: Digest::SHA256.hexdigest("begin:#{mutation_id}"), upload_parts: [bytes], purpose: :service_input)
|
|
77
|
+
return projection(started.data) unless !started.replayed && started.data["invocation"] == "permitted"
|
|
78
|
+
|
|
79
|
+
document = Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path)
|
|
80
|
+
operation = Molecules::ServicePolicy.new(document).operation!(params.fetch("operation"),
|
|
81
|
+
project: @map.fetch("project_id"), service_id: @service_id)
|
|
82
|
+
authorization = @client.call("service_authorization", binding.merge("claim_binding" => claim.data.fetch("claim_binding"),
|
|
83
|
+
"input_digest" => params.fetch("input_digest")), upload_parts: [bytes], purpose: :service_input)
|
|
84
|
+
expected = binding.slice("head", "candidate_generation", "request_id").merge("claim_binding" => claim.data.fetch("claim_binding"),
|
|
85
|
+
"policy_digest" => claim.data.fetch("policy_digest"), "operation_digest" => Ace::Assign::Atoms::EvidenceDigest.digest(operation))
|
|
86
|
+
unless !authorization.replayed && authorization.data == expected
|
|
87
|
+
raise SecurityError, "current authority authorization differs"
|
|
88
|
+
end
|
|
89
|
+
# This successful fresh authority read is the admission point. A
|
|
90
|
+
# later veto cannot retroactively undo it; no read creates permission.
|
|
91
|
+
reloaded = Molecules::ServicePolicy.new(Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path)).operation!(
|
|
92
|
+
params.fetch("operation"), project: @map.fetch("project_id"), service_id: @service_id)
|
|
93
|
+
unless Ace::Assign::Atoms::EvidenceDigest.digest(reloaded) == expected.fetch("operation_digest")
|
|
94
|
+
raise SecurityError, "local operation changed after admission"
|
|
95
|
+
end
|
|
96
|
+
canonical = params.slice(*Ace::Assign::Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS, "authorization", "service_id").merge(
|
|
97
|
+
"project_id" => @map.fetch("project_id"), "candidate_head" => binding.fetch("head"),
|
|
98
|
+
"caller_uid" => peer.fetch("uid"), "executor_uid" => @receiver.fetch("executor_uid"), "transport" => "unix")
|
|
99
|
+
envelope = immutable("version" => 1, "request" => canonical, "input" => input.fetch(:input), "execution" => {
|
|
100
|
+
"executor_uid" => @receiver.fetch("executor_uid"), "authority_id" => @map.fetch("authority_id"),
|
|
101
|
+
"claim_binding" => claim.data.fetch("claim_binding"), "candidate_generation" => binding.fetch("candidate_generation"),
|
|
102
|
+
"head" => binding.fetch("head"), "staging_id" => File.basename(materialized.fetch("directory"))})
|
|
103
|
+
response = @handler.execute(operation: immutable(operation), envelope: envelope, candidate_root: materialized.fetch("directory"))
|
|
104
|
+
return uncertain(params.fetch("request_id")) unless response
|
|
105
|
+
artifacts = Ace::Assign::Authority::EvidenceTransfer.read_staged(root: materialized.fetch("directory"), evidence: response.fetch("evidence"))
|
|
106
|
+
receipt = canonical.slice(*Ace::Assign::Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS).merge(
|
|
107
|
+
"outcome" => response.fetch("outcome"), "evidence" => response.fetch("evidence"))
|
|
108
|
+
receipt_bytes = JSON.generate(receipt)
|
|
109
|
+
completion = @client.call("complete_service", binding.merge("claim_binding" => claim.data.fetch("claim_binding"),
|
|
110
|
+
"receipt_sha256" => Digest::SHA256.hexdigest(receipt_bytes)),
|
|
111
|
+
mutation_id: Digest::SHA256.hexdigest("complete:#{mutation_id}"), upload_parts: [receipt_bytes] + artifacts, purpose: :receipt_artifacts)
|
|
112
|
+
unless completion.data["request_id"] == request_id && %w[succeeded failed].include?(completion.data["state"])
|
|
113
|
+
return uncertain(request_id)
|
|
114
|
+
end
|
|
115
|
+
cleanup_staging(materialized.fetch("directory"), staging_identity)
|
|
116
|
+
projection(completion.data)
|
|
117
|
+
rescue Ace::Assign::Error, Ace::Runtime::RuntimeUnavailableError, Ace::Lab::InvalidConfigurationError,
|
|
118
|
+
SecurityError, ArgumentError, KeyError, SystemCallError
|
|
119
|
+
contacted ? uncertain(request_id) : {"request_id" => request_id, "state" => "refused", "code" => "invalid_receiver_admission"}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def cleanup_staging(directory, identity)
|
|
125
|
+
quarantine = nil
|
|
126
|
+
root = File.expand_path(@receiver.fetch("staging_root"))
|
|
127
|
+
Ace::Assign::Authority::PrivateDirectory.verify!(root)
|
|
128
|
+
current = File.lstat(directory)
|
|
129
|
+
return unless File.dirname(directory) == root && File.basename(directory).start_with?("candidate-") &&
|
|
130
|
+
current.directory? && current.uid == Process.uid && current.dev == identity.dev && current.ino == identity.ino
|
|
131
|
+
# Capture the pathname atomically before checking the object we will
|
|
132
|
+
# delete. A concurrent replacement at the original name must survive.
|
|
133
|
+
quarantine = Dir.mktmpdir(".receiver-cleanup-", root)
|
|
134
|
+
File.chmod(0o700, quarantine)
|
|
135
|
+
captured = File.join(quarantine, "candidate")
|
|
136
|
+
File.rename(directory, captured)
|
|
137
|
+
moved = File.lstat(captured)
|
|
138
|
+
return unless moved.directory? && moved.uid == Process.uid &&
|
|
139
|
+
moved.dev == identity.dev && moved.ino == identity.ino
|
|
140
|
+
FileUtils.remove_entry_secure(captured)
|
|
141
|
+
rescue Ace::Assign::Error, SystemCallError
|
|
142
|
+
# A cleanup failure cannot change already-confirmed canonical truth.
|
|
143
|
+
# Never fall back to cleaning the staging root or another directory.
|
|
144
|
+
nil
|
|
145
|
+
ensure
|
|
146
|
+
if quarantine
|
|
147
|
+
begin
|
|
148
|
+
# Never recursively remove a mismatched captured object.
|
|
149
|
+
Dir.rmdir(quarantine)
|
|
150
|
+
rescue SystemCallError
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def projection(data)
|
|
157
|
+
data.slice("request_id", "state", "dispatch_phase", "claim", "generation", "journal_commit")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def uncertain(id)
|
|
161
|
+
{"request_id" => id, "state" => "uncertain", "required_action" => "inspect_canonical_service_status_and_record_exact_outcome"}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def immutable(value)
|
|
165
|
+
copy = JSON.parse(JSON.generate(value))
|
|
166
|
+
freeze_value(copy)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def freeze_value(value)
|
|
170
|
+
value.each_value { |child| freeze_value(child) } if value.is_a?(Hash)
|
|
171
|
+
value.each { |child| freeze_value(child) } if value.is_a?(Array)
|
|
172
|
+
value.freeze
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -69,7 +69,8 @@ module Ace
|
|
|
69
69
|
return route.envelope unless route.ok?
|
|
70
70
|
service_id = route.data.fetch("entry").fetch("id")
|
|
71
71
|
trusted = Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path) unless @policy
|
|
72
|
-
policy = @policy || Molecules::ServicePolicy.new(trusted
|
|
72
|
+
policy = @policy || Molecules::ServicePolicy.new(trusted,
|
|
73
|
+
->(reference, exact) { @coordinator.proposal_authorization(reference, exact) })
|
|
73
74
|
head = current_head
|
|
74
75
|
binding = {"request_id" => request_id, "assignment_id" => assignment, "attempt_id" => attempt,
|
|
75
76
|
"project_id" => project, "operation" => operation,
|
|
@@ -212,7 +213,8 @@ module Ace
|
|
|
212
213
|
if @policy
|
|
213
214
|
-> { @policy }
|
|
214
215
|
else
|
|
215
|
-
-> { Molecules::ServicePolicy.new(Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path)
|
|
216
|
+
-> { Molecules::ServicePolicy.new(Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path),
|
|
217
|
+
->(reference, exact) { @coordinator.proposal_authorization(reference, exact) }) }
|
|
216
218
|
end
|
|
217
219
|
end
|
|
218
220
|
|
data/lib/ace/lab/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-lab
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-05 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ace-runtime
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.2'
|
|
12
26
|
- !ruby/object:Gem::Dependency
|
|
13
27
|
name: ace-support-cli
|
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -57,14 +71,14 @@ dependencies:
|
|
|
57
71
|
requirements:
|
|
58
72
|
- - "~>"
|
|
59
73
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '0.
|
|
74
|
+
version: '0.64'
|
|
61
75
|
type: :runtime
|
|
62
76
|
prerelease: false
|
|
63
77
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
78
|
requirements:
|
|
65
79
|
- - "~>"
|
|
66
80
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: '0.
|
|
81
|
+
version: '0.64'
|
|
68
82
|
- !ruby/object:Gem::Dependency
|
|
69
83
|
name: ace-support-test-helpers
|
|
70
84
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -189,9 +203,13 @@ files:
|
|
|
189
203
|
- lib/ace/lab/molecules/grant_resolver.rb
|
|
190
204
|
- lib/ace/lab/molecules/hitl_authorizer.rb
|
|
191
205
|
- lib/ace/lab/molecules/inventory_query.rb
|
|
206
|
+
- lib/ace/lab/molecules/protected_service_handler.rb
|
|
207
|
+
- lib/ace/lab/molecules/protected_service_policy.rb
|
|
192
208
|
- lib/ace/lab/molecules/service_executor.rb
|
|
193
209
|
- lib/ace/lab/molecules/service_policy.rb
|
|
194
210
|
- lib/ace/lab/molecules/topology_loader.rb
|
|
211
|
+
- lib/ace/lab/organisms/authority_composition.rb
|
|
212
|
+
- lib/ace/lab/organisms/protected_service_receiver.rb
|
|
195
213
|
- lib/ace/lab/organisms/service_request_service.rb
|
|
196
214
|
- lib/ace/lab/organisms/topology_service.rb
|
|
197
215
|
- lib/ace/lab/version.rb
|