ace-lab 0.9.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 -104
- data/README.md +66 -100
- data/docs/usage.md +529 -592
- 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 -23
- 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 +34 -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 -108
- 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 -92
- data/lib/ace/lab/cli/commands/command_execute.rb +0 -28
- data/lib/ace/lab/cli/commands/run.rb +0 -65
- 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/command_owner_admission.rb +0 -183
- data/lib/ace/lab/molecules/command_policy.rb +0 -158
- 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 -152
- 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/command_executor.rb +0 -220
- data/lib/ace/lab/organisms/command_run_client.rb +0 -131
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -120
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -153
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -679
- 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,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
|
|
@@ -1,289 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../errors"
|
|
4
|
-
require_relative "../atoms/service_input"
|
|
5
|
-
require "ace/assign/errors"
|
|
6
|
-
require "pathname"
|
|
7
|
-
require "time"
|
|
8
|
-
|
|
9
|
-
module Ace
|
|
10
|
-
module Lab
|
|
11
|
-
module Molecules
|
|
12
|
-
# Deployment-owned operation and authorization facts. The caller may
|
|
13
|
-
# name a reference but cannot define its scope or executor binding.
|
|
14
|
-
class ServicePolicy
|
|
15
|
-
NAME = /\A[a-z][a-z0-9-]{0,63}\z/
|
|
16
|
-
ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
17
|
-
|
|
18
|
-
def initialize(document, proposal_resolver = nil)
|
|
19
|
-
@document = document
|
|
20
|
-
@operations = document.fetch("operations", {})
|
|
21
|
-
@authorizations = document.fetch("authorizations", {})
|
|
22
|
-
@proposal_resolver = proposal_resolver
|
|
23
|
-
unless @operations.is_a?(Hash) && @authorizations.is_a?(Hash)
|
|
24
|
-
raise Ace::Lab::InvalidConfigurationError, "trusted service policy must contain mappings"
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# The trusted installation document as loaded. Decisions are mutable;
|
|
29
|
-
# all other configuration remains bound for the receiver lifetime.
|
|
30
|
-
attr_reader :document
|
|
31
|
-
|
|
32
|
-
def self.configuration_digest(document)
|
|
33
|
-
Atoms::ServiceInput.digest(document.except("authorizations"))
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def operation!(name, project:, service_id:)
|
|
37
|
-
raise ArgumentError, "invalid operation name" unless name.to_s.match?(NAME)
|
|
38
|
-
operation = @operations[name]
|
|
39
|
-
raise SecurityError, "operation is not configured" unless operation.is_a?(Hash)
|
|
40
|
-
unless operation["project"] == project && operation["service_id"] == service_id
|
|
41
|
-
raise SecurityError, "operation does not authorize the selected service in this project"
|
|
42
|
-
end
|
|
43
|
-
transport = operation.fetch("transport", "local")
|
|
44
|
-
unless %w[local unix].include?(transport)
|
|
45
|
-
raise Ace::Lab::InvalidConfigurationError, "configured service transport is invalid"
|
|
46
|
-
end
|
|
47
|
-
if transport == "local"
|
|
48
|
-
argv = operation["argv"]
|
|
49
|
-
unless argv.is_a?(Array) && !argv.empty? && argv.all? { |v| v.is_a?(String) && !v.empty? } &&
|
|
50
|
-
argv.first.start_with?("/") && File.file?(argv.first) && File.executable?(argv.first)
|
|
51
|
-
raise Ace::Lab::InvalidConfigurationError, "configured operation needs a fixed executable argv"
|
|
52
|
-
end
|
|
53
|
-
elsif !operation["socket_path"].is_a?(String) || !operation["socket_path"].start_with?("/")
|
|
54
|
-
raise Ace::Lab::InvalidConfigurationError, "unix service needs an absolute socket path"
|
|
55
|
-
end
|
|
56
|
-
uid = operation["executor_uid"]
|
|
57
|
-
raise Ace::Lab::InvalidConfigurationError, "configured executor UID is invalid" unless uid.is_a?(Integer) && uid >= 0
|
|
58
|
-
if transport == "local"
|
|
59
|
-
# Every path component is validated (symlinks resolved, writable
|
|
60
|
-
# ancestors rejected) and the validated resolved file replaces
|
|
61
|
-
# the configured argv entry, so execution cannot be redirected
|
|
62
|
-
# between validation and dispatch.
|
|
63
|
-
resolved = trusted_executable!(operation["argv"].first, uid)
|
|
64
|
-
operation = operation.merge("argv" => [resolved] + operation["argv"][1..])
|
|
65
|
-
end
|
|
66
|
-
expires_at = parse_time(operation["lease_expires_at"])
|
|
67
|
-
raise SecurityError, "executor lease has expired" if expires_at <= Time.now.utc
|
|
68
|
-
if operation["host_maintenance"] == true
|
|
69
|
-
# Maintenance executes a fixed updater outside the replaced
|
|
70
|
-
# deployment; a socket-only operation has no executable to place
|
|
71
|
-
# and cannot prove the maintenance boundary.
|
|
72
|
-
unless transport == "local"
|
|
73
|
-
raise Ace::Lab::InvalidConfigurationError,
|
|
74
|
-
"host maintenance requires a local executable transport"
|
|
75
|
-
end
|
|
76
|
-
deployment_root = operation["deployment_root"].to_s
|
|
77
|
-
sink_path = operation["evidence_sink"].to_s
|
|
78
|
-
unless deployment_root.start_with?("/") && sink_path.start_with?("/")
|
|
79
|
-
raise Ace::Lab::InvalidConfigurationError,
|
|
80
|
-
"host maintenance needs absolute deployment and evidence sink paths"
|
|
81
|
-
end
|
|
82
|
-
deployment = resolved_path(deployment_root)
|
|
83
|
-
sink = resolved_path(sink_path)
|
|
84
|
-
executable = operation.dig("argv", 0).to_s
|
|
85
|
-
unless deployment.start_with?("/") && sink.start_with?("/") && executable.start_with?("/") &&
|
|
86
|
-
!inside?(executable, deployment) && !inside?(sink, deployment)
|
|
87
|
-
raise Ace::Lab::InvalidConfigurationError,
|
|
88
|
-
"host maintenance needs executable and evidence sink outside the replaced deployment"
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
operation
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def authorize!(reference, binding)
|
|
95
|
-
raise ArgumentError, "invalid authorization reference" unless reference.to_s.match?(ID)
|
|
96
|
-
if reference.start_with?("proposal-")
|
|
97
|
-
raise SecurityError, "canonical proposal authority is unavailable" unless @proposal_resolver
|
|
98
|
-
begin
|
|
99
|
-
return @proposal_resolver.call(reference, binding)
|
|
100
|
-
rescue Ace::Assign::Error
|
|
101
|
-
raise SecurityError, "canonical proposal does not authorize this exact effect"
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
decision = @authorizations[reference]
|
|
105
|
-
raise SecurityError, "authorization reference is unresolved" unless decision.is_a?(Hash)
|
|
106
|
-
required = %w[operation project_id assignment_id attempt_id input_digest target candidate_head caller_uid]
|
|
107
|
-
unless required.all? { |key| decision[key] == binding[key] }
|
|
108
|
-
raise SecurityError, "authorization does not match the exact service request"
|
|
109
|
-
end
|
|
110
|
-
raise SecurityError, "authorization has expired" if parse_time(decision["expires_at"]) <= Time.now.utc
|
|
111
|
-
decision
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
# ---- ordinary fixed merge admission policy (spec 8x3.t.xz9.4) ------
|
|
115
|
-
#
|
|
116
|
-
# The installed ordinary section is the separately configured
|
|
117
|
-
# ordinary receiver mapping and admission policy. It supplies
|
|
118
|
-
# identity and placement only: the caller selects no endpoint,
|
|
119
|
-
# credential or receiver. An absent project section or a disabled
|
|
120
|
-
# admission flag is rollback: new admission is unavailable while
|
|
121
|
-
# canonical records and read-only status stay preserved.
|
|
122
|
-
ORDINARY_SECTION_KEYS = %w[projects].freeze
|
|
123
|
-
ORDINARY_PROJECT_KEYS = %w[receiver_id socket_path receiver_uid principal admission journal_repository journal_ref].freeze
|
|
124
|
-
ORDINARY_AUTHORIZATION_KEYS = %w[operation project_id assignment_id attempt_id queue_entry
|
|
125
|
-
input_digest forge pr_url head_repository head_ref candidate_head base_repository
|
|
126
|
-
base_ref expected_base_head expires_at].freeze
|
|
127
|
-
# expires_at bounds the decision itself; it is never exact-matched
|
|
128
|
-
# against the request binding, which carries no expiry.
|
|
129
|
-
ORDINARY_BINDING_KEYS = ORDINARY_AUTHORIZATION_KEYS - %w[expires_at].freeze
|
|
130
|
-
|
|
131
|
-
def ordinary_receiver!(project)
|
|
132
|
-
section = @document["ordinary"]
|
|
133
|
-
return nil unless section.is_a?(Hash)
|
|
134
|
-
validate_ordinary_section!(section)
|
|
135
|
-
entry = section.fetch("projects")[project.to_s]
|
|
136
|
-
return nil unless entry.is_a?(Hash)
|
|
137
|
-
entry
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# The ordinary admission decision: exact-match on every closed
|
|
141
|
-
# binding key, like authorize!, but over the ordinary request
|
|
142
|
-
# identity (forge/PR/repository/ref) instead of execution selectors.
|
|
143
|
-
def authorize_ordinary!(reference, binding)
|
|
144
|
-
raise ArgumentError, "invalid authorization reference" unless reference.to_s.match?(ID)
|
|
145
|
-
decision = @authorizations[reference]
|
|
146
|
-
raise SecurityError, "authorization reference is unresolved" unless decision.is_a?(Hash)
|
|
147
|
-
unless ORDINARY_AUTHORIZATION_KEYS.all? { |key| decision.key?(key) }
|
|
148
|
-
raise SecurityError, "authorization reference is not an ordinary merge decision"
|
|
149
|
-
end
|
|
150
|
-
unless ORDINARY_BINDING_KEYS.all? { |key| decision[key] == binding[key] }
|
|
151
|
-
raise SecurityError, "authorization does not match the exact ordinary merge request"
|
|
152
|
-
end
|
|
153
|
-
raise SecurityError, "authorization has expired" if parse_time(decision["expires_at"]) <= Time.now.utc
|
|
154
|
-
decision
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
def validate_ordinary_section!(section)
|
|
158
|
-
unless (section.keys - ORDINARY_SECTION_KEYS).empty? && section["projects"].is_a?(Hash)
|
|
159
|
-
raise Ace::Lab::InvalidConfigurationError, "ordinary section must map projects"
|
|
160
|
-
end
|
|
161
|
-
section.fetch("projects").each do |project, entry|
|
|
162
|
-
unless project.is_a?(String) && project.match?(ID) && entry.is_a?(Hash) &&
|
|
163
|
-
(entry.keys - ORDINARY_PROJECT_KEYS).empty?
|
|
164
|
-
raise Ace::Lab::InvalidConfigurationError, "ordinary project section is invalid"
|
|
165
|
-
end
|
|
166
|
-
unless entry["receiver_id"].is_a?(String) && entry["receiver_id"].match?(ID) &&
|
|
167
|
-
entry["socket_path"].is_a?(String) && entry["socket_path"].start_with?("/") &&
|
|
168
|
-
entry["receiver_uid"].is_a?(Integer) && entry["receiver_uid"].positive? &&
|
|
169
|
-
entry["principal"].is_a?(String) && !entry["principal"].empty?
|
|
170
|
-
raise Ace::Lab::InvalidConfigurationError, "ordinary receiver mapping is incomplete"
|
|
171
|
-
end
|
|
172
|
-
admission = entry.fetch("admission", "enabled")
|
|
173
|
-
unless %w[enabled disabled].include?(admission)
|
|
174
|
-
raise Ace::Lab::InvalidConfigurationError, "ordinary admission flag is invalid"
|
|
175
|
-
end
|
|
176
|
-
repository, ref = entry.values_at("journal_repository", "journal_ref")
|
|
177
|
-
unless repository.is_a?(String) && repository.start_with?("/") && !repository.include?("\0") &&
|
|
178
|
-
File.expand_path(repository) == repository &&
|
|
179
|
-
ref.is_a?(String) && ref.match?(%r{\Arefs/[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)+\z})
|
|
180
|
-
raise Ace::Lab::InvalidConfigurationError, "ordinary journal selection is invalid"
|
|
181
|
-
end
|
|
182
|
-
end
|
|
183
|
-
true
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
# Inspection never renews effect permission or selects effect argv.
|
|
187
|
-
def inspection!(name, project:, service_id:, executor_uid:)
|
|
188
|
-
raise ArgumentError, "invalid inspection operation" unless name.is_a?(String) && name.match?(NAME)
|
|
189
|
-
operation = @operations[name]
|
|
190
|
-
unless operation.is_a?(Hash) && operation["project"] == project && operation["service_id"] == service_id &&
|
|
191
|
-
operation["executor_uid"].is_a?(Integer) && operation["executor_uid"] == executor_uid &&
|
|
192
|
-
operation.fetch("transport", "local") == "local"
|
|
193
|
-
raise SecurityError, "inspection does not match the selected original executor"
|
|
194
|
-
end
|
|
195
|
-
# This source-composed inspector uses the fixed original root SDK;
|
|
196
|
-
# configured handler argv cannot select or replace that owner.
|
|
197
|
-
if name == "prune-preserved-workspace"
|
|
198
|
-
return {"executor_uid" => executor_uid, "operation" => name}
|
|
199
|
-
end
|
|
200
|
-
argv = operation["no_effect_argv"]
|
|
201
|
-
unless argv.is_a?(Array) && argv.length.between?(1, 16) &&
|
|
202
|
-
argv.all? { |value| value.is_a?(String) && value.bytesize.between?(1, 1024) && !value.include?("\0") } &&
|
|
203
|
-
argv.first.start_with?("/")
|
|
204
|
-
raise Ace::Lab::InvalidConfigurationError, "operation has no fixed no-effect inspector"
|
|
205
|
-
end
|
|
206
|
-
executable = trusted_executable!(argv.first, executor_uid)
|
|
207
|
-
{"executor_uid" => executor_uid, "argv" => [executable] + argv.drop(1)}
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
private
|
|
211
|
-
|
|
212
|
-
# Validate the executable and every directory component on its
|
|
213
|
-
# resolved path: each must be owned by root or the executor and not
|
|
214
|
-
# group/other-writable, so no ancestor or symlink component can
|
|
215
|
-
# redirect the fixed argv. Returns the resolved file path.
|
|
216
|
-
def trusted_executable!(path, uid)
|
|
217
|
-
resolved = resolved_path(path.to_s)
|
|
218
|
-
unless resolved.start_with?("/") && File.file?(resolved) && File.executable?(resolved)
|
|
219
|
-
raise Ace::Lab::InvalidConfigurationError, "configured executable is unavailable"
|
|
220
|
-
end
|
|
221
|
-
Pathname.new(resolved).descend do |component|
|
|
222
|
-
stat = File.stat(component)
|
|
223
|
-
unless [0, uid].include?(stat.uid) && (stat.mode & 0o022).zero?
|
|
224
|
-
raise Ace::Lab::InvalidConfigurationError,
|
|
225
|
-
"configured executable path has a writable or foreign-owned component: #{component}"
|
|
226
|
-
end
|
|
227
|
-
end
|
|
228
|
-
resolved
|
|
229
|
-
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
230
|
-
raise Ace::Lab::InvalidConfigurationError, "configured executable is unavailable"
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
def inside?(path, root)
|
|
234
|
-
expanded = File.expand_path(path)
|
|
235
|
-
base = File.expand_path(root)
|
|
236
|
-
expanded == base || expanded.start_with?(base + File::SEPARATOR)
|
|
237
|
-
end
|
|
238
|
-
|
|
239
|
-
# Resolve a configured path through symlinks so placement checks see
|
|
240
|
-
# the real location. Paths may not exist yet (an evidence sink is
|
|
241
|
-
# created on first use), so the deepest ancestor — counting symlink
|
|
242
|
-
# components themselves via lstat — is resolved instead of failing;
|
|
243
|
-
# a dangling symlink resolves through its own target chain rather
|
|
244
|
-
# than hiding behind its parent.
|
|
245
|
-
MAX_SYMLINK_DEPTH = 16
|
|
246
|
-
|
|
247
|
-
def resolved_path(path, depth = 0)
|
|
248
|
-
raise Ace::Lab::InvalidConfigurationError, "trusted policy path has a symlink cycle" if depth > MAX_SYMLINK_DEPTH
|
|
249
|
-
expanded = File.expand_path(path)
|
|
250
|
-
candidate = expanded
|
|
251
|
-
candidate = File.dirname(candidate) until candidate == "/" || lstat?(candidate)
|
|
252
|
-
resolved = begin
|
|
253
|
-
File.realpath(candidate)
|
|
254
|
-
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
255
|
-
dangling_target(candidate, depth) || candidate
|
|
256
|
-
end
|
|
257
|
-
return resolved unless resolved == candidate && lstat?(candidate) && File.symlink?(candidate)
|
|
258
|
-
dangling_target(candidate, depth) || resolved
|
|
259
|
-
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
260
|
-
expanded
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
# Follow a dangling symlink's own target chain; nil when the link
|
|
264
|
-
# cannot be resolved to any concrete location.
|
|
265
|
-
def dangling_target(link, depth = 0)
|
|
266
|
-
raise Ace::Lab::InvalidConfigurationError, "trusted policy path has a symlink cycle" if depth > MAX_SYMLINK_DEPTH
|
|
267
|
-
target = File.readlink(link)
|
|
268
|
-
target = File.expand_path(target, File.dirname(link)) unless Pathname.new(target).absolute?
|
|
269
|
-
resolved_path(target, depth + 1)
|
|
270
|
-
rescue Errno::ENOENT, Errno::EACCES
|
|
271
|
-
nil
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
def lstat?(path)
|
|
275
|
-
File.lstat(path)
|
|
276
|
-
true
|
|
277
|
-
rescue Errno::ENOENT
|
|
278
|
-
false
|
|
279
|
-
end
|
|
280
|
-
|
|
281
|
-
def parse_time(value)
|
|
282
|
-
Time.iso8601(value.to_s)
|
|
283
|
-
rescue ArgumentError
|
|
284
|
-
raise Ace::Lab::InvalidConfigurationError, "trusted policy has invalid expiry"
|
|
285
|
-
end
|
|
286
|
-
end
|
|
287
|
-
end
|
|
288
|
-
end
|
|
289
|
-
end
|
|
@@ -1,87 +0,0 @@
|
|
|
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", services: [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
|