ace-lab 0.3.1 → 0.5.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/docs/usage.md +10 -0
  4. data/lib/ace/lab/atoms/protected_publication_input.rb +50 -0
  5. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +114 -0
  6. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +86 -0
  7. data/lib/ace/lab/atoms/service_input.rb +20 -8
  8. data/lib/ace/lab/authorization_path.rb +20 -0
  9. data/lib/ace/lab/cli/commands/service.rb +45 -0
  10. data/lib/ace/lab/errors.rb +11 -0
  11. data/lib/ace/lab/molecules/grant_resolver.rb +5 -1
  12. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +163 -0
  13. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +165 -0
  14. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +239 -0
  15. data/lib/ace/lab/molecules/protected_publication_publisher.rb +110 -0
  16. data/lib/ace/lab/molecules/protected_service_handler.rb +43 -0
  17. data/lib/ace/lab/molecules/protected_service_policy.rb +156 -0
  18. data/lib/ace/lab/molecules/service_policy.rb +36 -1
  19. data/lib/ace/lab/organisms/authority_composition.rb +87 -0
  20. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +361 -0
  21. data/lib/ace/lab/organisms/protected_service_client.rb +129 -0
  22. data/lib/ace/lab/organisms/protected_service_listener.rb +181 -0
  23. data/lib/ace/lab/organisms/protected_service_publication.rb +268 -0
  24. data/lib/ace/lab/organisms/protected_service_receiver.rb +267 -0
  25. data/lib/ace/lab/organisms/protected_service_recovery.rb +185 -0
  26. data/lib/ace/lab/organisms/protected_service_request.rb +128 -0
  27. data/lib/ace/lab/organisms/protected_service_worker.rb +89 -0
  28. data/lib/ace/lab/organisms/service_request_service.rb +4 -2
  29. data/lib/ace/lab/version.rb +1 -1
  30. data/lib/ace/lab.rb +2 -22
  31. metadata +52 -4
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "digest"
4
+ require "net/http"
5
+ require "timeout"
6
+ require "rubygems/package"
7
+ require "etc"
8
+ require "ace/herdr/molecules/bounded_process"
9
+ require "ace/assign/authority/private_directory"
10
+
11
+ module Ace
12
+ module Lab
13
+ module Molecules
14
+ # The admitted receiver owns permission. This boundary only invokes the
15
+ # existing single-artifact publisher and independently checks registry bytes.
16
+ class ProtectedPublicationPublisher
17
+ def initialize(process: Ace::Herdr::Molecules::BoundedProcess, http: Net::HTTP)
18
+ @process, @http = process, http
19
+ end
20
+
21
+ def verify!(publication:, artifact_digest:, platform:, deadline:)
22
+ remaining = remaining!(deadline)
23
+ path = "/api/v2/rubygems/#{publication.fetch('gem_name')}/versions/#{publication.fetch('version')}.json?platform=#{URI.encode_www_form_component(platform)}"
24
+ connection = @http.new("rubygems.org", 443, nil)
25
+ connection.use_ssl = true
26
+ connection.open_timeout = remaining
27
+ connection.read_timeout = remaining
28
+ connection.write_timeout = remaining
29
+ connection.max_retries = 0
30
+ response = nil
31
+ body = +""
32
+ Timeout.timeout(remaining, IOError) do
33
+ connection.start do |session|
34
+ connection.read_timeout = remaining!(deadline)
35
+ session.request(Net::HTTP::Get.new(path)) do |received|
36
+ response = received
37
+ received.read_body do |chunk|
38
+ remaining!(deadline)
39
+ raise IOError, "registry response exceeds bound" if body.bytesize + chunk.bytesize > 65_536
40
+ body << chunk
41
+ connection.read_timeout = remaining!(deadline)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ remaining!(deadline)
47
+ return result("absent", "registry_version_absent") if response.is_a?(Net::HTTPNotFound)
48
+ return result("uncertain", "registry_verification_unavailable") unless response.is_a?(Net::HTTPSuccess)
49
+ value = JSON.parse(body, object_class: UniqueObject, max_nesting: 16)
50
+ unless value.is_a?(Hash) && value["name"] == publication.fetch("gem_name") &&
51
+ value["version"] == publication.fetch("version") && value["platform"] == platform &&
52
+ value["sha"].is_a?(String) && value["sha"].match?(/\A[0-9a-f]{64}\z/) && value["yanked"] == false
53
+ return result("uncertain", "registry_verification_invalid")
54
+ end
55
+ value["sha"] == artifact_digest ? result("succeeded", "registry_exact_artifact") : result("failed", "registry_artifact_conflict")
56
+ rescue StandardError
57
+ result("uncertain", "registry_verification_unavailable")
58
+ end
59
+
60
+ def push!(operation:, publication:, artifact:, artifact_digest:, otp:, deadline:)
61
+ unless Process.uid == operation.fetch("executor_uid") && Process.euid == Process.uid &&
62
+ operation.fetch("argv").last == "--scoped-publication" &&
63
+ (otp.nil? || otp.is_a?(String) && otp.match?(/\A[0-9]{6}\z/))
64
+ raise SecurityError, "fixed publication executor differs"
65
+ end
66
+ environment = {"PATH" => "/usr/bin:/bin", "LANG" => "C.UTF-8", "HOME" => Etc.getpwuid(Process.uid).dir}
67
+ environment["GEM_HOST_OTP_CODE"] = otp if otp
68
+ input = {"artifact" => artifact, "artifact_digest" => artifact_digest,
69
+ "gem_name" => publication.fetch("gem_name"), "version" => publication.fetch("version")}
70
+ process = @process.call(operation.fetch("argv"), stdin_data: JSON.generate(input),
71
+ timeout_s: [remaining!(deadline), 30].min, output_limit: 1024, stderr_limit: 1024,
72
+ cleanup_group: true, environment: environment, chdir: File.dirname(artifact))
73
+ remaining!(deadline)
74
+ return result("uncertain", "publisher_reply_unavailable") if process.oversized || !process.status.success?
75
+ value = JSON.parse(process.stdout, object_class: UniqueObject, max_nesting: 4)
76
+ allowed = {"otp-required" => "provider_rejected_before_acceptance", "otp-rejected" => "provider_rejected_before_acceptance",
77
+ "otp-expired" => "provider_rejected_before_acceptance", "failed" => %w[provider_rejected_non_otp executor_credentials_unavailable invalid_artifact_selection artifact_identity_mismatch artifact_metadata_mismatch invalid_scoped_input],
78
+ "uncertain" => %w[registry_verification_required provider_result_ambiguous unclassified_push_result publisher_interrupted artifact_changed_during_push]}
79
+ unless value.is_a?(Hash) && value.keys.sort == %w[classification code] && Array(allowed[value["classification"]]).include?(value["code"])
80
+ return result("uncertain", "publisher_reply_invalid")
81
+ end
82
+ value.freeze
83
+ rescue StandardError
84
+ result("uncertain", "publisher_reply_unavailable")
85
+ ensure
86
+ environment&.delete("GEM_HOST_OTP_CODE")
87
+ end
88
+
89
+ private
90
+
91
+ class UniqueObject < Hash
92
+ def []=(key, value)
93
+ raise JSON::ParserError, "duplicate registry field" if key?(key)
94
+ super
95
+ end
96
+ end
97
+
98
+ def remaining!(deadline)
99
+ value = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
100
+ raise IOError, "publication deadline expired" unless value.positive?
101
+ value
102
+ end
103
+
104
+ def result(classification, code)
105
+ {"classification" => classification, "code" => code}.freeze
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/herdr/molecules/bounded_process"
5
+ require "timeout"
6
+ require "ace/assign/authority/private_directory"
7
+
8
+ module Ace
9
+ module Lab
10
+ module Molecules
11
+ # Source-owned receiver handler. Permission admission is a separate
12
+ # authority operation; this runner never grants or reconstructs it.
13
+ class ProtectedServiceHandler
14
+ MAX_STDOUT = 16 * 1024
15
+ MAX_STDERR = 8 * 1024
16
+
17
+ def execute(operation:, envelope:, candidate_root:)
18
+ unless Process.uid == operation.fetch("executor_uid") && Process.euid == operation.fetch("executor_uid")
19
+ raise SecurityError, "handler executor differs"
20
+ end
21
+ root = Ace::Assign::Authority::PrivateDirectory.verify!(candidate_root)
22
+ result = Ace::Herdr::Molecules::BoundedProcess.call(operation.fetch("argv"),
23
+ stdin_data: JSON.generate(envelope), timeout_s: 30,
24
+ output_limit: MAX_STDOUT, stderr_limit: MAX_STDERR, cleanup_group: true,
25
+ environment: {"PATH" => "/usr/bin:/bin", "LANG" => "C.UTF-8", "HOME" => root}, chdir: root)
26
+ return nil if result.oversized || !result.status.success?
27
+ out = result.stdout
28
+ response = JSON.parse(out)
29
+ request = envelope.fetch("request")
30
+ unless response.is_a?(Hash) && response.keys.sort == %w[evidence input_digest outcome request_id] &&
31
+ response["request_id"] == request.fetch("request_id") && response["input_digest"] == request.fetch("input_digest") &&
32
+ %w[succeeded failed].include?(response["outcome"]) && response["evidence"].is_a?(Array)
33
+ return nil
34
+ end
35
+ response
36
+ rescue JSON::ParserError, Timeout::Error, Ace::Herdr::Molecules::BoundedProcess::PostLaunchError,
37
+ IOError, SystemCallError
38
+ nil
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,156 @@
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,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../errors"
4
+ require "ace/assign/errors"
3
5
  require "pathname"
4
6
  require "time"
5
7
 
@@ -12,9 +14,10 @@ module Ace
12
14
  NAME = /\A[a-z][a-z0-9-]{0,63}\z/
13
15
  ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
14
16
 
15
- def initialize(document)
17
+ def initialize(document, proposal_resolver = nil)
16
18
  @operations = document.fetch("operations", {})
17
19
  @authorizations = document.fetch("authorizations", {})
20
+ @proposal_resolver = proposal_resolver
18
21
  unless @operations.is_a?(Hash) && @authorizations.is_a?(Hash)
19
22
  raise Ace::Lab::InvalidConfigurationError, "trusted service policy must contain mappings"
20
23
  end
@@ -80,6 +83,14 @@ module Ace
80
83
 
81
84
  def authorize!(reference, binding)
82
85
  raise ArgumentError, "invalid authorization reference" unless reference.to_s.match?(ID)
86
+ if reference.start_with?("proposal-")
87
+ raise SecurityError, "canonical proposal authority is unavailable" unless @proposal_resolver
88
+ begin
89
+ return @proposal_resolver.call(reference, binding)
90
+ rescue Ace::Assign::Error
91
+ raise SecurityError, "canonical proposal does not authorize this exact effect"
92
+ end
93
+ end
83
94
  decision = @authorizations[reference]
84
95
  raise SecurityError, "authorization reference is unresolved" unless decision.is_a?(Hash)
85
96
  required = %w[operation project_id assignment_id attempt_id input_digest target candidate_head caller_uid]
@@ -90,6 +101,30 @@ module Ace
90
101
  decision
91
102
  end
92
103
 
104
+ # Inspection never renews effect permission or selects effect argv.
105
+ def inspection!(name, project:, service_id:, executor_uid:)
106
+ raise ArgumentError, "invalid inspection operation" unless name.is_a?(String) && name.match?(NAME)
107
+ operation = @operations[name]
108
+ unless operation.is_a?(Hash) && operation["project"] == project && operation["service_id"] == service_id &&
109
+ operation["executor_uid"].is_a?(Integer) && operation["executor_uid"] == executor_uid &&
110
+ operation.fetch("transport", "local") == "local"
111
+ raise SecurityError, "inspection does not match the selected original executor"
112
+ end
113
+ # This source-composed inspector uses the fixed original root SDK;
114
+ # configured handler argv cannot select or replace that owner.
115
+ if name == "prune-preserved-workspace"
116
+ return {"executor_uid" => executor_uid, "operation" => name}
117
+ end
118
+ argv = operation["no_effect_argv"]
119
+ unless argv.is_a?(Array) && argv.length.between?(1, 16) &&
120
+ argv.all? { |value| value.is_a?(String) && value.bytesize.between?(1, 1024) && !value.include?("\0") } &&
121
+ argv.first.start_with?("/")
122
+ raise Ace::Lab::InvalidConfigurationError, "operation has no fixed no-effect inspector"
123
+ end
124
+ executable = trusted_executable!(argv.first, executor_uid)
125
+ {"executor_uid" => executor_uid, "argv" => [executable] + argv.drop(1)}
126
+ end
127
+
93
128
  private
94
129
 
95
130
  # Validate the executable and every directory component on its
@@ -0,0 +1,87 @@
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", hitl_service: publication_hitl(endcap, journals))
69
+ end
70
+
71
+ private
72
+
73
+ def publication_hitl(endcap, journals)
74
+ provider = Ace::Hitl::Providers::Lab
75
+ policy = provider.grants_policy(grants_path: provider::DEFAULT_GRANTS_PATH)
76
+ unless policy.service_uid == @deployment.authority(@authority_id).fetch("uid")
77
+ raise InvalidConfigurationError, "protected HITL service principal differs from installed authority"
78
+ end
79
+ ordinary = provider::ProtectedAssignmentBinding.new(authority: endcap, kernel: @kernel, journals: journals)
80
+ binding = Ace::Hitl::Lifecycle::ServicePublicationBinding.new(ordinary: ordinary, authority: endcap, kernel: @kernel)
81
+ Ace::Hitl::Lifecycle::Service.new(root: provider::DEFAULT_STORE_ROOT,
82
+ socket_path: provider::DEFAULT_SOCKET_PATH, binding: binding, policy: policy)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end