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,158 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "yaml"
|
|
5
|
-
require "digest"
|
|
6
|
-
require "ace/assign/atoms/evidence_digest"
|
|
7
|
-
require_relative "grant_resolver"
|
|
8
|
-
|
|
9
|
-
module Ace
|
|
10
|
-
module Lab
|
|
11
|
-
module Molecules
|
|
12
|
-
# One deployment-owned policy, independent of repository preferences.
|
|
13
|
-
# The selected grant covers the WHOLE service combination and exact argv.
|
|
14
|
-
class CommandPolicy
|
|
15
|
-
PATH = "/etc/lab/ace-lab/project-commands.yml"
|
|
16
|
-
ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}\z/
|
|
17
|
-
ENV_KEY = /\A[A-Z][A-Z0-9_]{0,63}\z/
|
|
18
|
-
RESERVED = %w[HOME USER LOGNAME SHELL PATH RUBYOPT RUBYLIB BUNDLE_GEMFILE GEM_HOME GEM_PATH BASH_ENV ENV PYTHONPATH PYTHONHOME PERL5LIB LD_PRELOAD LD_LIBRARY_PATH].freeze
|
|
19
|
-
attr_reader :project, :launcher
|
|
20
|
-
|
|
21
|
-
def self.load(project:)
|
|
22
|
-
bytes = GrantResolver.read_verified(PATH)
|
|
23
|
-
raise InvalidConfigurationError, "project commands are not installed" unless bytes && bytes.bytesize <= 65_536
|
|
24
|
-
new(YAML.safe_load(bytes, aliases: false), project: project)
|
|
25
|
-
rescue Psych::Exception
|
|
26
|
-
raise InvalidConfigurationError, "invalid project command policy"
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def initialize(document, project:)
|
|
30
|
-
strict!(document, %w[version projects])
|
|
31
|
-
invalid! unless document["version"] == 1 && document["projects"].is_a?(Hash) &&
|
|
32
|
-
document["projects"].keys.all? { |id| id.is_a?(String) && ID.match?(id) } && project.is_a?(String) && ID.match?(project)
|
|
33
|
-
@project = project
|
|
34
|
-
@document = JSON.parse(JSON.generate(document))
|
|
35
|
-
@entry = @document.fetch("projects").fetch(project) { invalid! }
|
|
36
|
-
strict!(@entry, %w[launcher default_executor default_services executors services commands grants])
|
|
37
|
-
@launcher = @entry.fetch("launcher")
|
|
38
|
-
strict!(@launcher, %w[socket_path identity_path])
|
|
39
|
-
invalid! unless @launcher.values.all? { |value| absolute?(value) }
|
|
40
|
-
%w[executors services commands].each do |key|
|
|
41
|
-
value = @entry.fetch(key)
|
|
42
|
-
invalid! unless value.is_a?(Hash) && value.size <= 64 && value.keys.all? { |id| id.is_a?(String) && ID.match?(id) }
|
|
43
|
-
end
|
|
44
|
-
@entry.fetch("executors").each_value do |value|
|
|
45
|
-
strict!(value, %w[uid gid groups home cwd path environment])
|
|
46
|
-
invalid! unless %w[uid gid].all? { |key| uid?(value[key]) } &&
|
|
47
|
-
value["groups"].is_a?(Array) && value["groups"].all? { |gid| uid?(gid) } &&
|
|
48
|
-
value["groups"].uniq == value["groups"] &&
|
|
49
|
-
%w[home cwd].all? { |key| absolute?(value[key]) } &&
|
|
50
|
-
value["path"].is_a?(Array) && !value["path"].empty? && value["path"].all? { |item| absolute?(item) }
|
|
51
|
-
environment!(value.fetch("environment"))
|
|
52
|
-
end
|
|
53
|
-
@entry.fetch("services").each_value do |value|
|
|
54
|
-
strict!(value, %w[environment secret_references])
|
|
55
|
-
environment!(value.fetch("environment"))
|
|
56
|
-
references = value.fetch("secret_references")
|
|
57
|
-
invalid! unless references.is_a?(Hash) && references.all? { |key, path| env_key?(key) && absolute?(path) }
|
|
58
|
-
end
|
|
59
|
-
@entry.fetch("commands").each_value do |value|
|
|
60
|
-
strict!(value, %w[argv executable_sha256 source purpose])
|
|
61
|
-
invalid! unless %w[command test].include?(value["purpose"])
|
|
62
|
-
argv!(value.fetch("argv"))
|
|
63
|
-
invalid! unless sha?(value.fetch("executable_sha256"))
|
|
64
|
-
source = value.fetch("source")
|
|
65
|
-
strict!(source, %w[head tree])
|
|
66
|
-
invalid! unless source.values.all? { |sha| sha.nil? || (sha.is_a?(String) && sha.match?(/\A[0-9a-f]{40}\z/)) } &&
|
|
67
|
-
source.values.all?(&:nil?) == source.values.any?(&:nil?)
|
|
68
|
-
invalid! if value["purpose"] == "test" && source.values.any?(&:nil?)
|
|
69
|
-
end
|
|
70
|
-
invalid! unless @entry.fetch("executors").key?(@entry["default_executor"])
|
|
71
|
-
services!(@entry.fetch("default_services"))
|
|
72
|
-
invalid! unless @entry["grants"].is_a?(Array) && @entry["grants"].size <= 256
|
|
73
|
-
@entry["grants"].each do |grant|
|
|
74
|
-
strict!(grant, %w[kind uid command executor services])
|
|
75
|
-
invalid! unless %w[operator agent].include?(grant["kind"]) && uid?(grant["uid"]) &&
|
|
76
|
-
@entry.fetch("commands").key?(grant["command"]) && @entry.fetch("executors").key?(grant["executor"])
|
|
77
|
-
services!(grant.fetch("services"))
|
|
78
|
-
end
|
|
79
|
-
@fingerprint = Ace::Assign::Atoms::EvidenceDigest.digest(@entry)
|
|
80
|
-
freeze_value(@document)
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def reader!(uid)
|
|
84
|
-
deny! unless @entry.fetch("grants").any? { |grant| grant.fetch("uid") == uid }
|
|
85
|
-
true
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def select(selection:, peer:)
|
|
89
|
-
strict!(selection, %w[argv executor services pane])
|
|
90
|
-
argv!(selection.fetch("argv"))
|
|
91
|
-
executor_id = selection["executor"] || @entry.fetch("default_executor")
|
|
92
|
-
executor = @entry.fetch("executors")[executor_id]
|
|
93
|
-
deny! unless executor
|
|
94
|
-
requested = selection["services"].nil? ? @entry.fetch("default_services") : selection["services"]
|
|
95
|
-
services!(requested)
|
|
96
|
-
selected = requested.uniq.sort
|
|
97
|
-
matches = @entry.fetch("commands").select { |_name, value| value.fetch("argv") == selection["argv"] }
|
|
98
|
-
deny! unless matches.size == 1
|
|
99
|
-
command_id, command = matches.first
|
|
100
|
-
kind = selection["pane"].nil? ? "operator" : "agent"
|
|
101
|
-
deny! unless @entry.fetch("grants").any? { |grant|
|
|
102
|
-
grant.values_at("kind", "uid", "command", "executor") == [kind, peer.fetch("uid"), command_id, executor_id] &&
|
|
103
|
-
grant.fetch("services").sort == selected }
|
|
104
|
-
environment = executor.fetch("environment").dup
|
|
105
|
-
references = {}
|
|
106
|
-
selected.each do |id|
|
|
107
|
-
service = @entry.fetch("services").fetch(id)
|
|
108
|
-
additions = service.fetch("environment")
|
|
109
|
-
secrets = service.fetch("secret_references")
|
|
110
|
-
deny! unless (environment.keys & (additions.keys + secrets.keys)).empty? &&
|
|
111
|
-
(references.keys & (additions.keys + secrets.keys)).empty? && (additions.keys & secrets.keys).empty?
|
|
112
|
-
environment.merge!(additions)
|
|
113
|
-
references.merge!(secrets)
|
|
114
|
-
end
|
|
115
|
-
definition = {"policy_sha256" => @fingerprint, "project_id" => @project, "command_id" => command_id,
|
|
116
|
-
"argv" => command.fetch("argv"), "executable_sha256" => command.fetch("executable_sha256"),
|
|
117
|
-
"source" => command.fetch("source"), "purpose" => command.fetch("purpose"), "executor_id" => executor_id,
|
|
118
|
-
"executor" => executor.merge("environment" => environment, "groups" => executor.fetch("groups").sort), "services" => selected,
|
|
119
|
-
"secret_references" => references, "requester" => peer, "requester_kind" => kind,
|
|
120
|
-
"selection" => selection}
|
|
121
|
-
invalid! if JSON.generate(definition).bytesize > 8192
|
|
122
|
-
definition
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
private
|
|
126
|
-
|
|
127
|
-
def strict!(value, keys)
|
|
128
|
-
invalid! unless value.is_a?(Hash) && value.keys.sort == keys.sort
|
|
129
|
-
end
|
|
130
|
-
def argv!(value)
|
|
131
|
-
invalid! unless value.is_a?(Array) && value.size.between?(1, 128) &&
|
|
132
|
-
value.all? { |item| item.is_a?(String) && item.bytesize <= 4096 && !item.include?("\0") } &&
|
|
133
|
-
!value.first.empty? && value.sum(&:bytesize) <= 8192
|
|
134
|
-
end
|
|
135
|
-
def services!(value)
|
|
136
|
-
invalid! unless value.is_a?(Array) && value.size <= 64 && value.all? { |id| @entry.fetch("services").key?(id) }
|
|
137
|
-
end
|
|
138
|
-
def environment!(value)
|
|
139
|
-
invalid! unless value.is_a?(Hash) && value.size <= 64 && value.all? { |key, item|
|
|
140
|
-
env_key?(key) && item.is_a?(String) && item.bytesize <= 4096 && !item.include?("\0") }
|
|
141
|
-
end
|
|
142
|
-
def env_key?(key) = key.is_a?(String) && ENV_KEY.match?(key) && !RESERVED.include?(key) && !key.start_with?("GIT_CONFIG_", "BUNDLE_", "LD_", "DYLD_")
|
|
143
|
-
def absolute?(value) = value.is_a?(String) && value.start_with?("/") && !value.include?("\0") && File.expand_path(value) == value
|
|
144
|
-
def uid?(value) = value.is_a?(Integer) && value.between?(1, 2**31 - 1)
|
|
145
|
-
def sha?(value) = value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
|
|
146
|
-
def invalid! = raise(InvalidConfigurationError, "invalid project command policy")
|
|
147
|
-
def deny! = raise(SecurityError, "project command selection is not authorized")
|
|
148
|
-
def freeze_value(value)
|
|
149
|
-
case value
|
|
150
|
-
when Hash then value.each { |key, child| key.freeze; freeze_value(child) }
|
|
151
|
-
when Array then value.each { |child| freeze_value(child) }
|
|
152
|
-
end
|
|
153
|
-
value.freeze
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
end
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "etc"
|
|
4
|
-
|
|
5
|
-
module Ace
|
|
6
|
-
module Lab
|
|
7
|
-
module Molecules
|
|
8
|
-
# The lab-side HITL delivery authorization (spec 8wq.t.34i): which
|
|
9
|
-
# authenticated transport may submit correlated answers for a
|
|
10
|
-
# Captain, derived from the SAME trusted deployment document that
|
|
11
|
-
# carries the service principals — read through the grant
|
|
12
|
-
# resolver's verified traversal, never from the cascade. The peer
|
|
13
|
-
# uid is a KERNEL fact resolved by the HITL boundary; this policy
|
|
14
|
-
# never trusts a claimed name.
|
|
15
|
-
class HitlAuthorizer
|
|
16
|
-
# @param principals [Hash] authorization.principals from the
|
|
17
|
-
# trusted document (uid-string or username keys, per-project)
|
|
18
|
-
# @param transport_uids [Array<Integer>] uids authorized to act
|
|
19
|
-
# as the configured transport
|
|
20
|
-
# @param service_uid [Integer, nil] the trusted HITL service uid
|
|
21
|
-
def initialize(principals: {}, transport_uids: [], service_uid: nil)
|
|
22
|
-
@principals = principals || {}
|
|
23
|
-
@transport_uids = Array(transport_uids)
|
|
24
|
-
@service_uid = service_uid
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# Build from the trusted deployment document at a fixed path
|
|
28
|
-
# (same file the service policy resolves principals from).
|
|
29
|
-
# @return [HitlAuthorizer]
|
|
30
|
-
def self.from_trusted_document(path, loader = GrantResolver)
|
|
31
|
-
document = loader.trusted_document(path)
|
|
32
|
-
hitl = document["hitl"].is_a?(Hash) ? document["hitl"] : {}
|
|
33
|
-
principals = document.dig("authorization", "principals")
|
|
34
|
-
new(
|
|
35
|
-
principals: principals.is_a?(Hash) ? principals : {},
|
|
36
|
-
transport_uids: Array(hitl["transport_uids"]),
|
|
37
|
-
service_uid: hitl["service_uid"]
|
|
38
|
-
)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# @return [Integer, nil] the trusted HITL service identity
|
|
42
|
-
def service_uid
|
|
43
|
-
@service_uid.is_a?(Integer) ? @service_uid : nil
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# The transport may submit a correlated answer from an authorized
|
|
47
|
-
# Captain when its KERNEL-VERIFIED uid is a configured transport
|
|
48
|
-
# AND the transport's principal is visible for the request's
|
|
49
|
-
# project. Unknown identity is an error-shaped refusal, never
|
|
50
|
-
# permission.
|
|
51
|
-
def delivery_authorized?(uid:, project_id:)
|
|
52
|
-
return false unless @transport_uids.include?(uid)
|
|
53
|
-
|
|
54
|
-
policy = @principals[uid.to_s]
|
|
55
|
-
return false unless policy.is_a?(Hash)
|
|
56
|
-
|
|
57
|
-
projects = Array(policy["projects"]).map(&:to_s)
|
|
58
|
-
projects.empty? || projects.include?(project_id.to_s)
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
end
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "time"
|
|
4
|
-
require_relative "service_policy"
|
|
5
|
-
|
|
6
|
-
module Ace
|
|
7
|
-
module Lab
|
|
8
|
-
module Molecules
|
|
9
|
-
# The scoped ordinary-merge decision/admission resolver (spec 8x3.t.xz9.4,
|
|
10
|
-
# execution clauses). The deployment-owned YAML authorization file remains
|
|
11
|
-
# the sole source of truth; this resolver only makes the exact root
|
|
12
|
-
# authorizations natively executable inside the receiver: one per-request
|
|
13
|
-
# authorization selector resolves to a typed decision within the closed
|
|
14
|
-
# refusal-code family — or it refuses. Scoping rules:
|
|
15
|
-
#
|
|
16
|
-
# - only `operation: merge` decisions of the ordinary shape resolve;
|
|
17
|
-
# anything else (missing reference, shape mismatch, non-merge
|
|
18
|
-
# operation, any binding mismatch, expiry) is a typed
|
|
19
|
-
# `ordinary_merge_authorization_invalid` decision;
|
|
20
|
-
# - exact binding match on every closed key; there are NO wildcards,
|
|
21
|
-
# defaults, prefix grants or broad grants of any kind — a decision
|
|
22
|
-
# authorizes exactly one bound request and nothing else;
|
|
23
|
-
# - expiry bounds the decision itself.
|
|
24
|
-
#
|
|
25
|
-
# Per-request refresh (spec 8x3.t.xz9.5 integration handoff): a
|
|
26
|
-
# long-lived receiver would otherwise decide every sequential candidate
|
|
27
|
-
# against its boot-time snapshot of the authorization document. When a
|
|
28
|
-
# trusted path is installed, every decision re-reads the document
|
|
29
|
-
# through the trusted loader and resolves against the FRESH grants —
|
|
30
|
-
# a root-revoked or expired decision fails closed at the next request
|
|
31
|
-
# without any receiver restart. The refresh is scoped: it reloads and
|
|
32
|
-
# re-validates the exact-request grant only; it never broadens
|
|
33
|
-
# authorization beyond the resolved reference.
|
|
34
|
-
class OrdinaryAdmissionResolver
|
|
35
|
-
BINDING_KEYS = ServicePolicy::ORDINARY_BINDING_KEYS.freeze
|
|
36
|
-
DECISION_KEYS = ServicePolicy::ORDINARY_AUTHORIZATION_KEYS.freeze
|
|
37
|
-
OPERATION = "merge"
|
|
38
|
-
|
|
39
|
-
Decision = Struct.new(:authorized?, :decision, :code, :message, keyword_init: true)
|
|
40
|
-
|
|
41
|
-
# @param policy_document [Hash] boot-time trusted document (the
|
|
42
|
-
# configuration digest is derived from it once, upstream)
|
|
43
|
-
# @param path [String, nil] trusted policy path to re-read per decision
|
|
44
|
-
# @param loader [Proc, nil] trusted reader (default: GrantResolver);
|
|
45
|
-
# the only override point, for non-privileged test documents
|
|
46
|
-
def initialize(policy_document, path: nil, loader: nil, proposal_resolver: nil, caller_uid: nil)
|
|
47
|
-
unless policy_document.is_a?(Hash) && policy_document["authorizations"].is_a?(Hash)
|
|
48
|
-
raise Ace::Lab::InvalidConfigurationError,
|
|
49
|
-
"trusted policy document must contain an authorizations mapping"
|
|
50
|
-
end
|
|
51
|
-
@authorizations = policy_document.fetch("authorizations")
|
|
52
|
-
@configuration_digest = ServicePolicy.configuration_digest(policy_document)
|
|
53
|
-
@path = path
|
|
54
|
-
@proposal_resolver = proposal_resolver
|
|
55
|
-
@caller_uid = caller_uid
|
|
56
|
-
@loader = loader || ->(trusted_path) { GrantResolver.trusted_document(trusted_path) }
|
|
57
|
-
freeze
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Resolve one authorization selector against the exact ordinary merge
|
|
61
|
-
# request binding. Returns a typed Decision; `authorized?` is true only
|
|
62
|
-
# for an exact, unexpired, ordinary-merge-shaped match.
|
|
63
|
-
def decide(reference, binding)
|
|
64
|
-
authorizations = current_authorizations
|
|
65
|
-
unless reference.is_a?(String) && reference.match?(ServicePolicy::ID)
|
|
66
|
-
return refuse("invalid authorization reference")
|
|
67
|
-
end
|
|
68
|
-
unless binding.is_a?(Hash) && binding.fetch("operation", nil) == OPERATION
|
|
69
|
-
return refuse("only the ordinary merge operation is resolvable")
|
|
70
|
-
end
|
|
71
|
-
if reference.start_with?("proposal-")
|
|
72
|
-
return refuse("canonical proposal authority is unavailable") unless @proposal_resolver && @caller_uid.is_a?(Integer)
|
|
73
|
-
begin
|
|
74
|
-
decision = @proposal_resolver.call(reference, binding.slice(
|
|
75
|
-
"operation", "project_id", "assignment_id", "attempt_id", "input_digest", "candidate_head").merge(
|
|
76
|
-
"target" => {"resource" => binding.fetch("pr_url"), "artifact_digest" => nil},
|
|
77
|
-
"caller_uid" => @caller_uid))
|
|
78
|
-
return Decision.new(authorized?: true, decision: decision, code: nil, message: nil)
|
|
79
|
-
rescue Ace::Assign::Error
|
|
80
|
-
return refuse("canonical proposal does not authorize this exact ordinary merge")
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
decision = authorizations[reference]
|
|
84
|
-
return refuse("authorization reference is unresolved") unless decision.is_a?(Hash)
|
|
85
|
-
unless DECISION_KEYS.all? { |key| decision.key?(key) }
|
|
86
|
-
return refuse("authorization reference is not an ordinary merge decision")
|
|
87
|
-
end
|
|
88
|
-
unless decision.fetch("operation") == OPERATION
|
|
89
|
-
return refuse("authorization does not cover the ordinary merge operation")
|
|
90
|
-
end
|
|
91
|
-
mismatch = BINDING_KEYS.find { |key| decision.fetch(key) != binding.fetch(key, nil) }
|
|
92
|
-
return refuse("authorization does not match the exact ordinary merge request") if mismatch
|
|
93
|
-
begin
|
|
94
|
-
expired = Time.iso8601(decision.fetch("expires_at").to_s) <= Time.now.utc
|
|
95
|
-
rescue ArgumentError
|
|
96
|
-
return refuse("authorization decision has an invalid expiry")
|
|
97
|
-
end
|
|
98
|
-
return refuse("authorization has expired") if expired
|
|
99
|
-
Decision.new(authorized?: true, decision: decision, code: nil, message: nil)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
private
|
|
103
|
-
|
|
104
|
-
# The authorizations mapping the CURRENT decision resolves against:
|
|
105
|
-
# the boot snapshot when no trusted path is installed, otherwise the
|
|
106
|
-
# freshly re-read document. A failed trusted re-read fails closed
|
|
107
|
-
# (InvalidConfigurationError — the unavailable family), never falls
|
|
108
|
-
# back to the stale snapshot.
|
|
109
|
-
def current_authorizations
|
|
110
|
-
return @authorizations unless @path
|
|
111
|
-
document = @loader.call(@path)
|
|
112
|
-
unless document.is_a?(Hash) && document["authorizations"].is_a?(Hash)
|
|
113
|
-
raise Ace::Lab::InvalidConfigurationError,
|
|
114
|
-
"invalid lab configuration: trusted authorization refresh at #{@path} " \
|
|
115
|
-
"did not yield an authorizations mapping"
|
|
116
|
-
end
|
|
117
|
-
unless ServicePolicy.configuration_digest(document) == @configuration_digest
|
|
118
|
-
raise Ace::Lab::InvalidConfigurationError,
|
|
119
|
-
"ordinary receiver configuration changed; restart with the installed configuration"
|
|
120
|
-
end
|
|
121
|
-
document.fetch("authorizations")
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def refuse(message)
|
|
125
|
-
Decision.new(authorized?: false, decision: nil,
|
|
126
|
-
code: "ordinary_merge_authorization_invalid", message: message)
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
end
|
|
@@ -1,262 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "socket"
|
|
5
|
-
require_relative "../errors"
|
|
6
|
-
require_relative "ordinary_socket"
|
|
7
|
-
|
|
8
|
-
module Ace
|
|
9
|
-
module Lab
|
|
10
|
-
module Molecules
|
|
11
|
-
# The ordinary receiver's serve-side transport (spec 8x3.t.xz9.4 serve
|
|
12
|
-
# entrypoint). Binds the configured unix socket and answers the exact
|
|
13
|
-
# framed protocol the OrdinarySocket client speaks: one bounded JSON
|
|
14
|
-
# document each way behind the shared handshake line. Authentication is
|
|
15
|
-
# MUTUAL through OS peer credentials: every connection's peer UID must equal
|
|
16
|
-
# the configured producer principal UID, and the listener itself runs
|
|
17
|
-
# as the configured receiver UID — the identity the client verifies on
|
|
18
|
-
# its side. Every identity, framing, deadline or parse failure is
|
|
19
|
-
# fail-closed: the peer receives the closed refusal envelope (or the
|
|
20
|
-
# connection closes) and nothing is ever dispatched. This is not an
|
|
21
|
-
# HTTP transport and there is no cross-VM fallback.
|
|
22
|
-
class OrdinaryListener
|
|
23
|
-
HANDSHAKE = OrdinarySocket::HANDSHAKE
|
|
24
|
-
LIMIT = OrdinarySocket::LIMIT
|
|
25
|
-
DEADLINE_SECONDS = 30
|
|
26
|
-
|
|
27
|
-
def initialize(socket_path:, peer_uid:, handler:, socket_gid: nil, lock_path: nil, logger: nil)
|
|
28
|
-
unless socket_path.is_a?(String) && socket_path.start_with?("/") &&
|
|
29
|
-
peer_uid.is_a?(Integer) && peer_uid >= 0 && handler.respond_to?(:call)
|
|
30
|
-
raise ArgumentError, "ordinary listener needs an absolute socket path, a peer uid and a handler"
|
|
31
|
-
end
|
|
32
|
-
unless socket_gid.nil? || (socket_gid.is_a?(Integer) && socket_gid >= 0)
|
|
33
|
-
raise ArgumentError, "ordinary listener socket_gid must be a non-negative integer"
|
|
34
|
-
end
|
|
35
|
-
@socket_gid = socket_gid
|
|
36
|
-
@socket_path = socket_path
|
|
37
|
-
@peer_uid = peer_uid
|
|
38
|
-
@handler = handler
|
|
39
|
-
@lock_path = lock_path || "#{@socket_path}.lock"
|
|
40
|
-
@logger = logger
|
|
41
|
-
@stopping = false
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# Serve until #stop. Excludes a second listener through one owned
|
|
45
|
-
# lock file, removes only its own stale endpoint, and always removes
|
|
46
|
-
# the endpoint it created.
|
|
47
|
-
def serve(on_ready: nil)
|
|
48
|
-
acquire_lock!
|
|
49
|
-
remove_stale_endpoint!
|
|
50
|
-
@listener = UNIXServer.new(@socket_path)
|
|
51
|
-
@endpoint_identity = File.stat(@socket_path)
|
|
52
|
-
File.chown(nil, @socket_gid, @socket_path) if @socket_gid
|
|
53
|
-
File.chmod(@socket_gid ? 0o660 : 0o600, @socket_path)
|
|
54
|
-
on_ready.call if on_ready
|
|
55
|
-
@handler.recover_pending! if @handler.respond_to?(:recover_pending!)
|
|
56
|
-
until @stopping
|
|
57
|
-
socket = @listener.accept
|
|
58
|
-
serve_connection(socket)
|
|
59
|
-
end
|
|
60
|
-
rescue IOError, Errno::EBADF
|
|
61
|
-
raise unless @stopping
|
|
62
|
-
ensure
|
|
63
|
-
@listener&.close
|
|
64
|
-
remove_owned_endpoint!
|
|
65
|
-
@lock&.flock(File::LOCK_UN)
|
|
66
|
-
@lock&.close
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# The existing listener participates in the Assign owner's lifetime.
|
|
70
|
-
def run(on_ready:)
|
|
71
|
-
serve(on_ready: on_ready)
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def stop
|
|
75
|
-
@stopping = true
|
|
76
|
-
@listener&.close
|
|
77
|
-
rescue IOError
|
|
78
|
-
nil
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# One bounded connection: verify the peer identity, read one framed
|
|
82
|
-
# request, answer one framed envelope, close. Fail-closed on every
|
|
83
|
-
# violation; a dispatch never happens from an unverified frame.
|
|
84
|
-
def serve_connection(socket)
|
|
85
|
-
deadline = Time.now.utc + DEADLINE_SECONDS
|
|
86
|
-
begin
|
|
87
|
-
verify_peer!(socket)
|
|
88
|
-
submission = read_request!(socket, deadline)
|
|
89
|
-
response = @handler.call(submission)
|
|
90
|
-
write_response_safely(socket, response, deadline)
|
|
91
|
-
if response.is_a?(Hash) && response["status"] == "ok" && @handler.respond_to?(:execute)
|
|
92
|
-
begin
|
|
93
|
-
# Admission is durable even if the acknowledgment was lost; the
|
|
94
|
-
# execution lifetime belongs to the canonical record, not
|
|
95
|
-
# the short connection. A lost lifecycle stays pending or
|
|
96
|
-
# uncertain and reconciles by the original reference.
|
|
97
|
-
@handler.execute(response.dig("data", "request_id"))
|
|
98
|
-
rescue StandardError => error
|
|
99
|
-
log_failure("effect", error)
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
rescue OrdinaryRefused => e
|
|
103
|
-
write_response_safely(socket, e.envelope, deadline)
|
|
104
|
-
rescue OrdinaryListenerError, IOError, SystemCallError => error
|
|
105
|
-
# Fail closed without a fabricated envelope; the client classifies
|
|
106
|
-
# a lost/unreadable response as ordinary_merge_unavailable.
|
|
107
|
-
log_failure("connection", error)
|
|
108
|
-
rescue StandardError => error
|
|
109
|
-
log_failure("request", error)
|
|
110
|
-
write_response_safely(socket, refusal_envelope("ordinary_merge_unavailable",
|
|
111
|
-
"receiver failed to handle the request"), deadline)
|
|
112
|
-
ensure
|
|
113
|
-
begin
|
|
114
|
-
socket.close
|
|
115
|
-
rescue IOError
|
|
116
|
-
nil
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
class OrdinaryListenerError < ::StandardError; end
|
|
122
|
-
|
|
123
|
-
# A verified-connection refusal that still carries a closed envelope.
|
|
124
|
-
class OrdinaryRefused < OrdinaryListenerError
|
|
125
|
-
attr_reader :envelope
|
|
126
|
-
|
|
127
|
-
def initialize(envelope)
|
|
128
|
-
@envelope = envelope
|
|
129
|
-
super(envelope.dig("error", "message").to_s)
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
private
|
|
134
|
-
|
|
135
|
-
def write_response_safely(socket, response, deadline)
|
|
136
|
-
write_response!(socket, response, deadline)
|
|
137
|
-
rescue OrdinaryListenerError, IOError, SystemCallError => error
|
|
138
|
-
log_failure("response", error)
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def log_failure(phase, error)
|
|
142
|
-
# Arbitrary exception messages may contain provider credentials or
|
|
143
|
-
# request bodies. Journal references remain the source of effect state.
|
|
144
|
-
@logger&.call("ordinary receiver #{phase} failed (#{error.class.name})")
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
# Mutual half of OS peer authentication: the connecting process must run as the
|
|
148
|
-
# configured producer principal UID. Anything else is refused before
|
|
149
|
-
# one byte of request is read.
|
|
150
|
-
def verify_peer!(socket)
|
|
151
|
-
peer_uid, = socket.getpeereid
|
|
152
|
-
return if peer_uid == @peer_uid
|
|
153
|
-
raise OrdinaryRefused, refusal_envelope("ordinary_merge_unauthenticated",
|
|
154
|
-
"connecting principal is not the configured ordinary overseer")
|
|
155
|
-
rescue SystemCallError, IOError, SocketError, NotImplementedError
|
|
156
|
-
raise OrdinaryListenerError, "peer credentials are unavailable"
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def read_request!(socket, deadline)
|
|
160
|
-
header = read_line!(socket, deadline)
|
|
161
|
-
match = /\A#{Regexp.escape(HANDSHAKE)} (\d{1,5})\n\z/o.match(header)
|
|
162
|
-
raise OrdinaryListenerError, "request header is invalid" unless match
|
|
163
|
-
length = Integer(match[1])
|
|
164
|
-
raise OrdinaryListenerError, "request exceeds the wire bound" if length > LIMIT
|
|
165
|
-
payload = read_exact!(socket, length, deadline)
|
|
166
|
-
JSON.parse(payload, allow_duplicate_key: false, create_additions: false, max_nesting: 16)
|
|
167
|
-
rescue JSON::ParserError
|
|
168
|
-
raise OrdinaryRefused, refusal_envelope("ordinary_merge_invalid_request",
|
|
169
|
-
"request document is not valid JSON")
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def write_response!(socket, response, deadline)
|
|
173
|
-
payload = JSON.generate(response)
|
|
174
|
-
raise OrdinaryListenerError, "response exceeds the wire bound" if payload.bytesize > LIMIT
|
|
175
|
-
frame = "#{HANDSHAKE} #{payload.bytesize}\n#{payload}"
|
|
176
|
-
written = 0
|
|
177
|
-
while written < frame.bytesize
|
|
178
|
-
raise OrdinaryListenerError, "response write deadline expired" if Time.now.utc >= deadline
|
|
179
|
-
begin
|
|
180
|
-
written += socket.write_nonblock(frame.byteslice(written..))
|
|
181
|
-
rescue IO::WaitWritable
|
|
182
|
-
socket.wait_writable(remaining!(deadline)) or
|
|
183
|
-
raise OrdinaryListenerError, "response write deadline expired"
|
|
184
|
-
end
|
|
185
|
-
end
|
|
186
|
-
rescue JSON::GeneratorError
|
|
187
|
-
raise OrdinaryListenerError, "response is not serializable"
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def refusal_envelope(code, message)
|
|
191
|
-
{"status" => "error", "error" => {
|
|
192
|
-
"schema" => "ace.lab.service.ordinary-merge-refusal/v1",
|
|
193
|
-
"code" => code,
|
|
194
|
-
"message" => message.to_s.scrub.gsub(/\s+/, " ").strip[0, 512],
|
|
195
|
-
"request_id" => "unknown",
|
|
196
|
-
"input_digest" => nil
|
|
197
|
-
}}
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
def read_line!(socket, deadline)
|
|
201
|
-
line = +""
|
|
202
|
-
until line.end_with?("\n")
|
|
203
|
-
raise OrdinaryListenerError, "request header is unbounded" if line.bytesize > 128
|
|
204
|
-
line << read_exact!(socket, 1, deadline)
|
|
205
|
-
end
|
|
206
|
-
line
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def read_exact!(socket, length, deadline)
|
|
210
|
-
buffer = +""
|
|
211
|
-
while buffer.bytesize < length
|
|
212
|
-
raise OrdinaryListenerError, "request read deadline expired" if Time.now.utc >= deadline
|
|
213
|
-
begin
|
|
214
|
-
chunk = socket.read_nonblock(length - buffer.bytesize)
|
|
215
|
-
raise OrdinaryListenerError, "peer closed the connection" if chunk.nil?
|
|
216
|
-
buffer << chunk
|
|
217
|
-
rescue IO::WaitReadable
|
|
218
|
-
socket.wait_readable(remaining!(deadline)) or
|
|
219
|
-
raise OrdinaryListenerError, "request read deadline expired"
|
|
220
|
-
end
|
|
221
|
-
end
|
|
222
|
-
buffer
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
def remaining!(deadline)
|
|
226
|
-
left = deadline - Time.now.utc
|
|
227
|
-
raise OrdinaryListenerError, "connection deadline expired" if left <= 0
|
|
228
|
-
left
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
def acquire_lock!
|
|
232
|
-
@lock = File.open(@lock_path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
|
|
233
|
-
stat = @lock.stat
|
|
234
|
-
unless stat.file? && stat.uid == Process.uid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
|
|
235
|
-
raise Ace::Lab::InvalidConfigurationError, "receiver listener lock differs"
|
|
236
|
-
end
|
|
237
|
-
unless @lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
238
|
-
raise Ace::Lab::InvalidConfigurationError, "receiver listener already active"
|
|
239
|
-
end
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
def remove_stale_endpoint!
|
|
243
|
-
stat = File.lstat(@socket_path)
|
|
244
|
-
unless stat.socket? && stat.uid == Process.uid
|
|
245
|
-
raise Ace::Lab::InvalidConfigurationError, "receiver endpoint differs"
|
|
246
|
-
end
|
|
247
|
-
File.unlink(@socket_path)
|
|
248
|
-
rescue Errno::ENOENT
|
|
249
|
-
nil
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
def remove_owned_endpoint!
|
|
253
|
-
return unless @endpoint_identity
|
|
254
|
-
stat = File.stat(@socket_path)
|
|
255
|
-
File.unlink(@socket_path) if stat.socket? && stat.ino == @endpoint_identity.ino
|
|
256
|
-
rescue Errno::ENOENT
|
|
257
|
-
nil
|
|
258
|
-
end
|
|
259
|
-
end
|
|
260
|
-
end
|
|
261
|
-
end
|
|
262
|
-
end
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "ordinary_worktree_read"
|
|
4
|
-
|
|
5
|
-
module Ace
|
|
6
|
-
module Lab
|
|
7
|
-
module Molecules
|
|
8
|
-
# Local checks are bounded read-only observations. Only the forge provider
|
|
9
|
-
# executes merge; moving a local ref can never prove a delivered merge.
|
|
10
|
-
class OrdinaryMergeRead
|
|
11
|
-
STRATEGY = "ff"
|
|
12
|
-
|
|
13
|
-
def initialize(repo_path:, base_ref:, root: repo_path, common_dir: File.join(repo_path, ".git"),
|
|
14
|
-
deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30)
|
|
15
|
-
unless repo_path.is_a?(String) && repo_path.start_with?("/") &&
|
|
16
|
-
base_ref.is_a?(String) && base_ref.start_with?("refs/")
|
|
17
|
-
raise ArgumentError, "ordinary merge read needs an absolute repository and full base ref"
|
|
18
|
-
end
|
|
19
|
-
@repo_path, @base_ref = repo_path, base_ref
|
|
20
|
-
@reader = OrdinaryWorktreeRead.new(root: root, common_dir: common_dir, deadline: deadline)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def base_head
|
|
24
|
-
@reader.ref_head(@repo_path, @base_ref)
|
|
25
|
-
rescue Ace::Assign::Error => error
|
|
26
|
-
raise GitUnavailable, error.message
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def candidate_commit?(head)
|
|
30
|
-
@reader.commit?(@repo_path, head)
|
|
31
|
-
rescue Ace::Assign::Error => error
|
|
32
|
-
raise GitUnavailable, error.message
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def fast_forwardable?(head)
|
|
36
|
-
@reader.ancestor?(@repo_path, base_head, head)
|
|
37
|
-
rescue Ace::Assign::Error => error
|
|
38
|
-
raise GitUnavailable, error.message
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
class GitUnavailable < StandardError; end
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|