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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/docs/usage.md +10 -0
- data/lib/ace/lab/atoms/protected_publication_input.rb +50 -0
- data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +114 -0
- data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +86 -0
- data/lib/ace/lab/atoms/service_input.rb +20 -8
- data/lib/ace/lab/authorization_path.rb +20 -0
- data/lib/ace/lab/cli/commands/service.rb +45 -0
- data/lib/ace/lab/errors.rb +11 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +5 -1
- data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +163 -0
- data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +165 -0
- data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +239 -0
- data/lib/ace/lab/molecules/protected_publication_publisher.rb +110 -0
- data/lib/ace/lab/molecules/protected_service_handler.rb +43 -0
- data/lib/ace/lab/molecules/protected_service_policy.rb +156 -0
- data/lib/ace/lab/molecules/service_policy.rb +36 -1
- data/lib/ace/lab/organisms/authority_composition.rb +87 -0
- data/lib/ace/lab/organisms/protected_cleanup_owner.rb +361 -0
- data/lib/ace/lab/organisms/protected_service_client.rb +129 -0
- data/lib/ace/lab/organisms/protected_service_listener.rb +181 -0
- data/lib/ace/lab/organisms/protected_service_publication.rb +268 -0
- data/lib/ace/lab/organisms/protected_service_receiver.rb +267 -0
- data/lib/ace/lab/organisms/protected_service_recovery.rb +185 -0
- data/lib/ace/lab/organisms/protected_service_request.rb +128 -0
- data/lib/ace/lab/organisms/protected_service_worker.rb +89 -0
- data/lib/ace/lab/organisms/service_request_service.rb +4 -2
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +2 -22
- metadata +52 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6e017c2a3431b82dc516951d8a3acc00eb8aac7965c037d921edd2c7b5f5a8a0
|
|
4
|
+
data.tar.gz: 53c907e67ab172535e09c7e046f939b1f59d8cf01cc2f18e0f6d0b1540cc8b4b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e3d6659be2c485710cca9d405e592c9783ab8cc07fd79aa23aa32e29a69d968f867889a540be6e5a7b3591b785853e9e1cff5f0ad19c75995e0f56950ffbf13a
|
|
7
|
+
data.tar.gz: c7e9961a4c48a8ae919ab3813286022897793ac1b52859bd8f634de7a2a4084531e521fc6039cdd9b2c81591a82621a81339dbdee94cffccacae06ad0a8c4fc1
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-10-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Deliver scoped RubyGems publication through the fixed receiver and canonical HITL authorization, preserving original request, OTP challenge and publication-result evidence.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Compose actual service/result owners without a read-proof completion handler; retain authenticated service settlement and cleanup boundaries.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Load protected policy/listener dependencies directly and keep cleanup inspection tied to the original receiver and immutable result evidence.
|
|
23
|
+
|
|
24
|
+
## [0.4.0] - 2026-10-05
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Protected service policy, receiver and authority composition; incomplete full-service startup remains refused.
|
|
29
|
+
- Resolve immutable second-commander proposals through confirmed-delivery sixteen-hour policy and canonical Assign authorization.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Declare the required direct dependencies and minimum producer versions for this coordinated release: `ace-assign ~> 0.64`, `ace-runtime ~> 0.2`.
|
|
34
|
+
|
|
10
35
|
## [0.3.1] - 2026-10-04
|
|
11
36
|
|
|
12
37
|
### Changed
|
data/docs/usage.md
CHANGED
|
@@ -282,3 +282,13 @@ reads; it never provisions.
|
|
|
282
282
|
|
|
283
283
|
`ace-assign`'s evidence journal. The Lab execution binary
|
|
284
284
|
(`/usr/local/bin/lab`) is never invoked or required.
|
|
285
|
+
|
|
286
|
+
### Protected handler cleanup boundary
|
|
287
|
+
|
|
288
|
+
Handlers retain the fixed closed environment and 30-second execution deadline, with 16KiB stdout and 8KiB stderr caps. Owned cleanup adds at most one second of confirmed reaping; unresolved cleanup remains uncertain and cannot return a service receipt. It cannot establish an arbitrary domain target or surviving writer is absent. The gad.b operation-specific fresh inspector remains required source work; installed verification is centralized in gad.2.
|
|
289
|
+
|
|
290
|
+
### Selected receiver no-effect recovery
|
|
291
|
+
|
|
292
|
+
The existing protected receiver exposes `recover_no_effect(binding:, input_bytes:, mutation_id:, expected_generation:)`. Supply the original assignment/attempt/candidate/head/request binding, original structured input, stable mutation ID and unchanged original positive authority generation. The receiver snapshots those inputs, authenticates canonical status and input before any settled return, and adopts an already accepted current challenge without claiming again. A needed claim uses the supplied generation unchanged. Lost acknowledgements remain uncertain; status retry never repeats the original effect.
|
|
293
|
+
|
|
294
|
+
Trusted operation configuration must provide a distinct fixed `no_effect_argv` for the original project/service/executor. Inspection may proceed after the effect lease expires, but it never renews effect permission or selects normal effect argv. The selected inspector must produce operation-specific original target/handler/writer observations and existing strict challenge-bound artifacts; only the authority's canonical import can establish `failed-settled`. This source API and controlled composition do not deliver the outstanding gad.b inspector or installed acceptance.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "service_input"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lab
|
|
7
|
+
module Atoms
|
|
8
|
+
# One accepted artifact, never a queue selector or a build instruction.
|
|
9
|
+
module ProtectedPublicationInput
|
|
10
|
+
REGISTRY = "https://rubygems.org"
|
|
11
|
+
PUBLICATION_FIELDS = %w[gem_name version head registry artifact_relative_path].freeze
|
|
12
|
+
NAME = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
13
|
+
VERSION = /\A[0-9]+(?:\.[0-9A-Za-z]+)*(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?\z/
|
|
14
|
+
|
|
15
|
+
def self.decode!(bytes)
|
|
16
|
+
unless bytes.is_a?(String) && bytes.bytesize.between?(1, ServiceInput::MAX_BYTES)
|
|
17
|
+
raise ArgumentError, "publication input exceeds its bound"
|
|
18
|
+
end
|
|
19
|
+
input = JSON.parse(bytes, allow_duplicate_key: false, allow_comments: false,
|
|
20
|
+
allow_nan: false, create_additions: false, max_nesting: 16)
|
|
21
|
+
validate!(input)
|
|
22
|
+
rescue JSON::ParserError
|
|
23
|
+
raise ArgumentError, "publication input must be valid JSON"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.validate!(input)
|
|
27
|
+
unless input.is_a?(Hash) && input.keys.sort == %w[publication target] &&
|
|
28
|
+
input["publication"].is_a?(Hash) && input["publication"].keys.sort == PUBLICATION_FIELDS.sort &&
|
|
29
|
+
input["target"].is_a?(Hash) && input["target"].keys.sort == %w[artifact_digest resource]
|
|
30
|
+
raise ArgumentError, "publication input fields differ"
|
|
31
|
+
end
|
|
32
|
+
value = input.fetch("publication")
|
|
33
|
+
name, version, head, registry, path = value.values_at(*PUBLICATION_FIELDS)
|
|
34
|
+
unless name.is_a?(String) && name.match?(NAME) && version.is_a?(String) &&
|
|
35
|
+
version.bytesize.between?(1, 128) && version.match?(VERSION) &&
|
|
36
|
+
head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/) && registry == REGISTRY &&
|
|
37
|
+
path.is_a?(String) && path.bytesize.between?(1, 4096) && path.end_with?(".gem") &&
|
|
38
|
+
path.split("/", -1).all? { |part| part.match?(/\A[a-zA-Z0-9_.-]+\z/) && !%w[. ..].include?(part) } &&
|
|
39
|
+
input.dig("target", "resource") == "rubygems:#{name}:#{version}" &&
|
|
40
|
+
input.dig("target", "artifact_digest").is_a?(String) &&
|
|
41
|
+
input.dig("target", "artifact_digest").match?(ServiceInput::SHA256)
|
|
42
|
+
raise ArgumentError, "publication artifact selection differs"
|
|
43
|
+
end
|
|
44
|
+
ServiceInput.validate!(input)
|
|
45
|
+
input
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lab
|
|
7
|
+
module Atoms
|
|
8
|
+
# Structural input validation only. Canonical ownership, preservation and
|
|
9
|
+
# current permission are independently verified by the operation owners.
|
|
10
|
+
module ProtectedWorkspacePruneInput
|
|
11
|
+
MAX_BYTES = 65_536
|
|
12
|
+
ID = /\A[A-Za-z0-9_.-]{1,200}\z/
|
|
13
|
+
SHA = /\A[0-9a-f]{64}\z/
|
|
14
|
+
COMMIT = /\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/
|
|
15
|
+
ASSOCIATION = %w[project_id mapping_id assignment_id attempt_id].freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def parse(bytes)
|
|
20
|
+
reject! unless bytes.is_a?(String) && bytes.bytesize.between?(1, MAX_BYTES)
|
|
21
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
22
|
+
reject! unless text.valid_encoding?
|
|
23
|
+
input = JSON.parse(text, create_additions: false, max_nesting: 16,
|
|
24
|
+
allow_nan: false, allow_comments: false, allow_duplicate_key: false)
|
|
25
|
+
object!(input, %w[schema maintenance target publication preservation])
|
|
26
|
+
reject! unless input.fetch("schema") == "ace.protected-workspace-prune/v1"
|
|
27
|
+
association!(input.fetch("maintenance"))
|
|
28
|
+
target = object!(input.fetch("target"), ASSOCIATION +
|
|
29
|
+
%w[resource artifact_digest descriptor_sha256 binding_event_digest release_event_digest journal_commit])
|
|
30
|
+
ASSOCIATION.each { |field| token!(target.fetch(field)) }
|
|
31
|
+
%w[artifact_digest descriptor_sha256 binding_event_digest release_event_digest].each { |key| digest!(target.fetch(key)) }
|
|
32
|
+
commit!(target.fetch("journal_commit"))
|
|
33
|
+
resource = "workspace:#{target.fetch('project_id')}:#{target.fetch('mapping_id')}:#{target.fetch('assignment_id')}"
|
|
34
|
+
reject! unless resource.bytesize <= 256 && target.fetch("resource") == resource
|
|
35
|
+
publication = object!(input.fetch("publication"), %w[descriptor_sha256 installation_ref])
|
|
36
|
+
digest!(publication.fetch("descriptor_sha256"))
|
|
37
|
+
reference!(publication.fetch("installation_ref"))
|
|
38
|
+
preservation!(input.fetch("preservation"))
|
|
39
|
+
freeze_value(input)
|
|
40
|
+
rescue JSON::ParserError, JSON::NestingError, EncodingError
|
|
41
|
+
reject!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def preservation!(value)
|
|
45
|
+
object!(value, %w[head branch destinations manifest_sha256])
|
|
46
|
+
commit!(value.fetch("head"))
|
|
47
|
+
branch = value.fetch("branch")
|
|
48
|
+
unless branch.nil?
|
|
49
|
+
ref!(branch)
|
|
50
|
+
reject! unless branch.start_with?("refs/heads/")
|
|
51
|
+
end
|
|
52
|
+
digest!(value.fetch("manifest_sha256"))
|
|
53
|
+
destinations = value.fetch("destinations")
|
|
54
|
+
reject! unless destinations.is_a?(Array) && destinations.length <= 64
|
|
55
|
+
identities = destinations.map do |entry|
|
|
56
|
+
object!(entry, %w[repository_id ref head])
|
|
57
|
+
token!(entry.fetch("repository_id"))
|
|
58
|
+
ref!(entry.fetch("ref"))
|
|
59
|
+
commit!(entry.fetch("head"))
|
|
60
|
+
entry.values_at("repository_id", "ref", "head")
|
|
61
|
+
end
|
|
62
|
+
reject! unless identities == identities.sort && identities.uniq == identities
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def association!(value)
|
|
66
|
+
object!(value, ASSOCIATION)
|
|
67
|
+
value.each_value { |item| token!(item) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def reference!(value)
|
|
71
|
+
object!(value, %w[path bytes sha256])
|
|
72
|
+
path = value.fetch("path")
|
|
73
|
+
reject! unless path.is_a?(String) && path.bytesize.between?(2, 4096) &&
|
|
74
|
+
path.start_with?("/") && !path.include?("\0") && !path.end_with?("/") &&
|
|
75
|
+
path.split("/", -1).drop(1).all? { |part| !part.empty? && !%w[. ..].include?(part) }
|
|
76
|
+
reject! unless value.fetch("bytes").is_a?(Integer) && value.fetch("bytes").between?(1, MAX_BYTES)
|
|
77
|
+
digest!(value.fetch("sha256"))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def object!(value, fields)
|
|
81
|
+
reject! unless value.is_a?(Hash) && value.keys.sort == fields.sort
|
|
82
|
+
value
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def token!(value)
|
|
86
|
+
reject! unless value.is_a?(String) && ID.match?(value)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def digest!(value)
|
|
90
|
+
reject! unless value.is_a?(String) && SHA.match?(value)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def commit!(value)
|
|
94
|
+
reject! unless value.is_a?(String) && COMMIT.match?(value)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def ref!(value)
|
|
98
|
+
reject! unless value.is_a?(String) && value.bytesize.between?(6, 256) &&
|
|
99
|
+
value.start_with?("refs/") && !value.match?(/[\x00-\x20\x7f~^:?*\[\\]/) &&
|
|
100
|
+
!value.include?("..") && !value.include?("@{") && !value.end_with?(".") &&
|
|
101
|
+
value.split("/", -1).all? { |part| !part.empty? && !part.start_with?(".") && !part.end_with?(".lock") }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def freeze_value(value)
|
|
105
|
+
value.each { |key, item| key.freeze; freeze_value(item) } if value.is_a?(Hash)
|
|
106
|
+
value.each { |item| freeze_value(item) } if value.is_a?(Array)
|
|
107
|
+
value.freeze
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def reject! = raise(ArgumentError, "invalid protected prune input")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "protected_workspace_prune_input"
|
|
4
|
+
require "ace/assign/molecules/execution_scope_lineage"
|
|
5
|
+
require "ace/assign/atoms/evidence_digest"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Lab
|
|
9
|
+
module Atoms
|
|
10
|
+
# Closed transport data only; canonical and physical owners must
|
|
11
|
+
# independently authenticate these selectors before any effect.
|
|
12
|
+
module ProtectedWorkspacePrunePreview
|
|
13
|
+
Input = ProtectedWorkspacePruneInput
|
|
14
|
+
CONTEXT = %w[journal_commit maintenance head candidate_generation worker_process_binding caller_process_binding].freeze
|
|
15
|
+
RESULT = %w[schema kind intent_digest publication maintenance maintenance_context target preservation inventory_sha256 file_count total_bytes].freeze
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def intent!(value)
|
|
19
|
+
Input.object!(value, %w[schema maintenance target publication destinations])
|
|
20
|
+
Input.reject! unless value["schema"] == "ace.protected-workspace-prune-preview/v1"
|
|
21
|
+
Input.association!(value.fetch("maintenance"))
|
|
22
|
+
target = value.fetch("target")
|
|
23
|
+
Input.object!(target, Input::ASSOCIATION + %w[resource descriptor_sha256 binding_event_digest release_event_digest journal_commit])
|
|
24
|
+
Input::ASSOCIATION.each { |key| Input.token!(target.fetch(key)) }
|
|
25
|
+
%w[descriptor_sha256 binding_event_digest release_event_digest].each { |key| Input.digest!(target.fetch(key)) }
|
|
26
|
+
Input.commit!(target.fetch("journal_commit"))
|
|
27
|
+
expected = "workspace:#{target.fetch('project_id')}:#{target.fetch('mapping_id')}:#{target.fetch('assignment_id')}"
|
|
28
|
+
Input.reject! unless expected.bytesize <= 256 && target["resource"] == expected
|
|
29
|
+
publication = Input.object!(value.fetch("publication"), %w[descriptor_sha256 installation_ref])
|
|
30
|
+
Input.digest!(publication.fetch("descriptor_sha256"))
|
|
31
|
+
Input.reference!(publication.fetch("installation_ref"))
|
|
32
|
+
# Reuse the existing exact destination/ref owner, without accepting
|
|
33
|
+
# or manufacturing a preservation authority assertion.
|
|
34
|
+
destinations!(value.fetch("destinations"))
|
|
35
|
+
Input.reject! if JSON.generate(value).bytesize > Input::MAX_BYTES
|
|
36
|
+
Input.freeze_value(value)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def destinations!(destinations)
|
|
40
|
+
Input.reject! unless destinations.is_a?(Array) && destinations.length <= 64
|
|
41
|
+
ids = destinations.map do |entry|
|
|
42
|
+
Input.object!(entry, %w[repository_id ref head])
|
|
43
|
+
Input.token!(entry.fetch("repository_id")); Input.ref!(entry.fetch("ref")); Input.commit!(entry.fetch("head"))
|
|
44
|
+
entry.values_at("repository_id", "ref", "head")
|
|
45
|
+
end
|
|
46
|
+
Input.reject! unless ids == ids.sort && ids == ids.uniq
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def context!(value)
|
|
50
|
+
Input.object!(value, CONTEXT)
|
|
51
|
+
Input.commit!(value.fetch("journal_commit"))
|
|
52
|
+
Input.association!(value.fetch("maintenance"))
|
|
53
|
+
Input.commit!(value.fetch("head"))
|
|
54
|
+
Input.reject! unless value["candidate_generation"].is_a?(Integer) && value["candidate_generation"].positive?
|
|
55
|
+
original = value.fetch("worker_process_binding")
|
|
56
|
+
birth = original.is_a?(Hash) && original["started_at"]
|
|
57
|
+
boot = birth.is_a?(String) && birth.match(/\Alinux:([0-9a-f-]{36}):[0-9]+\z/)&.captures&.first
|
|
58
|
+
%w[worker_process_binding caller_process_binding].each do |key|
|
|
59
|
+
Ace::Assign::Molecules::ExecutionScopeLineage.validate_process_identity!(value.fetch(key), boot_id: boot)
|
|
60
|
+
end
|
|
61
|
+
Input.reject! if JSON.generate(value).bytesize > 16_384
|
|
62
|
+
Input.freeze_value(value)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def result!(value, intent:, context:)
|
|
66
|
+
Input.object!(value, RESULT)
|
|
67
|
+
Input.reject! unless value.values_at("schema", "kind") == ["ace.protected-workspace-prune-preview-result/v1", "preview"]
|
|
68
|
+
Input.reject! unless value["intent_digest"] == Ace::Assign::Atoms::EvidenceDigest.digest(intent) &&
|
|
69
|
+
value["maintenance_context"] == context && value["maintenance"] == intent["maintenance"] &&
|
|
70
|
+
value["publication"] == intent["publication"] && value.fetch("target").except("artifact_digest") == intent["target"]
|
|
71
|
+
input = {"schema" => "ace.protected-workspace-prune/v1"}.merge(value.slice("maintenance", "target", "publication", "preservation"))
|
|
72
|
+
Input.parse(JSON.generate(input))
|
|
73
|
+
Input.digest!(value.fetch("inventory_sha256"))
|
|
74
|
+
Input.reject! unless value["inventory_sha256"] == value.dig("preservation", "manifest_sha256")
|
|
75
|
+
Input.reject! unless value["file_count"].is_a?(Integer) && value["file_count"].between?(0, 4096) &&
|
|
76
|
+
value["total_bytes"].is_a?(Integer) && value["total_bytes"].between?(0, 256 * 1024 * 1024)
|
|
77
|
+
Input.reject! unless value.dig("preservation", "destinations") == intent["destinations"] &&
|
|
78
|
+
value.dig("target", "artifact_digest") == Ace::Assign::Atoms::EvidenceDigest.digest(
|
|
79
|
+
"target" => intent.fetch("target"), "preservation" => value.fetch("preservation"))
|
|
80
|
+
Input.reject! if JSON.generate(value).bytesize > 16_384
|
|
81
|
+
Input.freeze_value(value)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -15,16 +15,22 @@ module Ace
|
|
|
15
15
|
RESOURCE = /\A[a-zA-Z0-9_.:\/-]{1,256}\z/
|
|
16
16
|
|
|
17
17
|
def self.load(path)
|
|
18
|
-
raise ArgumentError, "input file is missing" unless File.file?(path)
|
|
19
|
-
# One handle bounds the read: a file replaced or grown between a
|
|
20
|
-
# size check and a separate read cannot bypass the limit.
|
|
21
18
|
begin
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
flags = File::RDONLY | File::NOFOLLOW | File::NONBLOCK
|
|
20
|
+
data = File.open(path, flags) do |file|
|
|
21
|
+
before = file.stat
|
|
22
|
+
unless before.file? && before.size <= MAX_BYTES && same_file?(before, File.lstat(path))
|
|
23
|
+
raise ArgumentError, "input must be a bounded regular file"
|
|
24
|
+
end
|
|
25
|
+
content = file.read(MAX_BYTES + 1).to_s
|
|
26
|
+
unless content.bytesize <= MAX_BYTES && same_file?(before, file.stat) &&
|
|
27
|
+
same_file?(before, File.lstat(path))
|
|
28
|
+
raise ArgumentError, "input file changed while reading"
|
|
29
|
+
end
|
|
30
|
+
JSON.parse(content, allow_duplicate_key: false, allow_comments: false,
|
|
31
|
+
allow_nan: false, create_additions: false, max_nesting: 16)
|
|
26
32
|
end
|
|
27
|
-
rescue
|
|
33
|
+
rescue SystemCallError, IOError
|
|
28
34
|
raise ArgumentError, "input file is unreadable"
|
|
29
35
|
end
|
|
30
36
|
raise ArgumentError, "input must be a JSON object" unless data.is_a?(Hash)
|
|
@@ -34,6 +40,12 @@ module Ace
|
|
|
34
40
|
raise ArgumentError, "input must be valid JSON"
|
|
35
41
|
end
|
|
36
42
|
|
|
43
|
+
def self.same_file?(left, right)
|
|
44
|
+
[left.dev, left.ino, left.mode, left.uid, left.gid, left.size, left.mtime, left.ctime] ==
|
|
45
|
+
[right.dev, right.ino, right.mode, right.uid, right.gid, right.size, right.mtime, right.ctime]
|
|
46
|
+
end
|
|
47
|
+
private_class_method :same_file?
|
|
48
|
+
|
|
37
49
|
def self.digest(data)
|
|
38
50
|
Digest::SHA256.hexdigest(JSON.generate(canonical(data)))
|
|
39
51
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Lab
|
|
5
|
+
# Authorization grants live in a single deployment-controlled file at a
|
|
6
|
+
# FIXED path — never in the caller-writable configuration cascade and
|
|
7
|
+
# never at a caller-selected location (review rounds 4-5, F3/F1). The
|
|
8
|
+
# file must be root-owned and not group/world-writable, including every
|
|
9
|
+
# directory on its real path; GrantResolver verifies and fails closed.
|
|
10
|
+
AUTHORIZATION_PATH = "/etc/lab/ace-lab/authorization.yml"
|
|
11
|
+
|
|
12
|
+
# Path of the trusted authorization grants document. A method (not a
|
|
13
|
+
# bare constant reference) so tests can stub the seam without any
|
|
14
|
+
# caller-controllable production override.
|
|
15
|
+
# @return [String]
|
|
16
|
+
def self.authorization_path
|
|
17
|
+
AUTHORIZATION_PATH
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "ace/support/cli"
|
|
5
5
|
require_relative "support"
|
|
6
|
+
require_relative "../../organisms/protected_service_request"
|
|
6
7
|
|
|
7
8
|
module Ace
|
|
8
9
|
module Lab
|
|
@@ -14,6 +15,16 @@ module Ace
|
|
|
14
15
|
@service ||= Organisms::ServiceRequestService.new
|
|
15
16
|
end
|
|
16
17
|
|
|
18
|
+
def protected_service
|
|
19
|
+
@protected_service ||= Organisms::ProtectedServiceRequest.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def protected_refusal(error)
|
|
23
|
+
code = error.is_a?(ArgumentError) || error.is_a?(SecurityError) ? "protected_service_refused" : "protected_service_unavailable"
|
|
24
|
+
message = error.is_a?(JSON::ParserError) ? "protected installed selection unavailable" : error.message.to_s.scrub[0, 512]
|
|
25
|
+
emit({"status" => "error", "error" => {"code" => code, "message" => message}})
|
|
26
|
+
end
|
|
27
|
+
|
|
17
28
|
def emit(envelope)
|
|
18
29
|
puts JSON.generate(envelope)
|
|
19
30
|
return if envelope["status"] == "ok"
|
|
@@ -39,11 +50,26 @@ module Ace
|
|
|
39
50
|
option :request_id, type: :string, required: true, desc: "Idempotency key"
|
|
40
51
|
option :dry_run, type: :boolean, desc: "Validate without claiming or executing"
|
|
41
52
|
|
|
53
|
+
option :mapping, type: :string, desc: "Original protected mapping ID"
|
|
54
|
+
option :scope, type: :string, desc: "Original hierarchical prepared scope"
|
|
55
|
+
option :service, type: :string, desc: "Installed receiver ID"
|
|
56
|
+
option :candidate_head, type: :string, desc: "Exact approved candidate head"
|
|
57
|
+
option :candidate_generation, type: :integer, desc: "Original candidate generation"
|
|
58
|
+
option :expected_generation, type: :integer, desc: "Original authority generation"
|
|
59
|
+
|
|
42
60
|
def call(project:, assignment:, attempt:, operation:, input:, authorization:, request_id:, **options)
|
|
43
61
|
reject_identity_flags!(options)
|
|
62
|
+
selected = protected_service.selected?(options)
|
|
63
|
+
if selected
|
|
64
|
+
return emit(protected_service.request(project: project, assignment: assignment, attempt: attempt,
|
|
65
|
+
operation: operation, input_path: input, authorization: authorization, request_id: request_id, **options))
|
|
66
|
+
end
|
|
44
67
|
emit(service.request(project: project, assignment: assignment, attempt: attempt,
|
|
45
68
|
operation: operation, input_path: input, authorization: authorization,
|
|
46
69
|
request_id: request_id, dry_run: options[:dry_run]))
|
|
70
|
+
rescue ArgumentError, Ace::Assign::Error, Ace::Git::Error, Ace::Runtime::RuntimeUnavailableError, JSON::ParserError, SecurityError, KeyError, SystemCallError => error
|
|
71
|
+
raise if selected == false
|
|
72
|
+
protected_refusal(error)
|
|
47
73
|
end
|
|
48
74
|
end
|
|
49
75
|
|
|
@@ -56,9 +82,28 @@ module Ace
|
|
|
56
82
|
option :request, type: :string, required: true, desc: "Request ID"
|
|
57
83
|
option :format, type: :string, desc: "Output format (json only)"
|
|
58
84
|
|
|
85
|
+
option :project, type: :string, desc: "Original project ID"
|
|
86
|
+
option :assignment, type: :string, desc: "Original assignment ID"
|
|
87
|
+
option :attempt, type: :string, desc: "Original attempt ID"
|
|
88
|
+
option :mapping, type: :string, desc: "Original protected mapping ID"
|
|
89
|
+
option :scope, type: :string, desc: "Original hierarchical prepared scope"
|
|
90
|
+
option :candidate_head, type: :string, desc: "Exact approved candidate head"
|
|
91
|
+
option :candidate_generation, type: :integer, desc: "Original candidate generation"
|
|
92
|
+
option :input_digest, type: :string, desc: "Original normalized input SHA-256"
|
|
93
|
+
option :target, type: :string, desc: "Original target resource ID"
|
|
94
|
+
option :artifact_digest, type: :string, desc: "Original artifact SHA-256 (omit for null)"
|
|
95
|
+
|
|
59
96
|
def call(request:, **options)
|
|
97
|
+
reject_identity_flags!(options)
|
|
60
98
|
ensure_json_format!(options)
|
|
99
|
+
selected = protected_service.selected?(options)
|
|
100
|
+
if selected
|
|
101
|
+
return emit(protected_service.status(request_id: request, **options))
|
|
102
|
+
end
|
|
61
103
|
emit(service.status(request_id: request))
|
|
104
|
+
rescue ArgumentError, Ace::Assign::Error, Ace::Git::Error, Ace::Runtime::RuntimeUnavailableError, JSON::ParserError, SecurityError, KeyError, SystemCallError => error
|
|
105
|
+
raise if selected == false
|
|
106
|
+
protected_refusal(error)
|
|
62
107
|
end
|
|
63
108
|
end
|
|
64
109
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../errors"
|
|
3
4
|
require "yaml"
|
|
5
|
+
require "date"
|
|
4
6
|
|
|
5
7
|
module Ace
|
|
6
8
|
module Lab
|
|
@@ -33,7 +35,9 @@ module Ace
|
|
|
33
35
|
raise Ace::Lab::InvalidConfigurationError,
|
|
34
36
|
"invalid lab configuration: trusted authorization file must contain a mapping"
|
|
35
37
|
end
|
|
36
|
-
|
|
38
|
+
# Use the same principal schema and the same verified bytes for
|
|
39
|
+
# policy composition, rather than reading a second grants snapshot.
|
|
40
|
+
document.merge("principals" => parse_grants(content, path).fetch("principals"))
|
|
37
41
|
rescue Psych::Exception
|
|
38
42
|
raise Ace::Lab::InvalidConfigurationError,
|
|
39
43
|
"invalid lab configuration: trusted authorization file could not be parsed"
|