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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +363 -104
  3. data/README.md +66 -100
  4. data/docs/usage.md +529 -592
  5. data/lib/ace/lab/atoms/install_schema.rb +373 -0
  6. data/lib/ace/lab/cli/commands/install.rb +161 -0
  7. data/lib/ace/lab/cli/commands/prepare.rb +118 -0
  8. data/lib/ace/lab/cli/commands/project_add.rb +101 -0
  9. data/lib/ace/lab/cli/commands/status.rb +109 -0
  10. data/lib/ace/lab/cli/commands/verify.rb +115 -0
  11. data/lib/ace/lab/cli.rb +21 -23
  12. data/lib/ace/lab/install_manifest_path.rb +23 -0
  13. data/lib/ace/lab/models/status_report.rb +38 -0
  14. data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
  15. data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
  16. data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
  17. data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
  18. data/lib/ace/lab/molecules/grant_resolver.rb +34 -23
  19. data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
  20. data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
  21. data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
  22. data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
  23. data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
  24. data/lib/ace/lab/molecules/project_registry.rb +251 -0
  25. data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
  26. data/lib/ace/lab/molecules/role_verifier.rb +223 -0
  27. data/lib/ace/lab/organisms/install.rb +213 -0
  28. data/lib/ace/lab/organisms/prepare.rb +156 -0
  29. data/lib/ace/lab/organisms/project_add.rb +170 -0
  30. data/lib/ace/lab/organisms/status_service.rb +70 -0
  31. data/lib/ace/lab/organisms/verify.rb +768 -0
  32. data/lib/ace/lab/version.rb +1 -1
  33. data/lib/ace/lab.rb +34 -6
  34. metadata +34 -108
  35. data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
  36. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
  37. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
  38. data/lib/ace/lab/atoms/service_input.rb +0 -92
  39. data/lib/ace/lab/cli/commands/assignment.rb +0 -92
  40. data/lib/ace/lab/cli/commands/command_execute.rb +0 -28
  41. data/lib/ace/lab/cli/commands/run.rb +0 -65
  42. data/lib/ace/lab/cli/commands/service.rb +0 -240
  43. data/lib/ace/lab/models/service_receipt.rb +0 -18
  44. data/lib/ace/lab/molecules/command_owner_admission.rb +0 -183
  45. data/lib/ace/lab/molecules/command_policy.rb +0 -158
  46. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  47. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  48. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  49. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  50. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  51. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -152
  52. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  53. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  54. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  55. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  56. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  57. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  58. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  59. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  60. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  61. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  62. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  63. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  64. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  65. data/lib/ace/lab/organisms/command_executor.rb +0 -220
  66. data/lib/ace/lab/organisms/command_run_client.rb +0 -131
  67. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -120
  68. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -153
  69. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -679
  70. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  71. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  72. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  73. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  74. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  75. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  76. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  77. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  78. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  79. 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