ace-lab 0.1.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 +7 -0
- data/.ace-defaults/lab/config.yml +41 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +110 -0
- data/Rakefile +12 -0
- data/docs/usage.md +178 -0
- data/exe/ace-lab +19 -0
- data/lib/ace/lab/atoms/binding_freshness.rb +33 -0
- data/lib/ace/lab/atoms/public_projection.rb +98 -0
- data/lib/ace/lab/atoms/topology_schema.rb +280 -0
- data/lib/ace/lab/cli/commands/agents.rb +43 -0
- data/lib/ace/lab/cli/commands/projects.rb +43 -0
- data/lib/ace/lab/cli/commands/resolve.rb +44 -0
- data/lib/ace/lab/cli/commands/route.rb +48 -0
- data/lib/ace/lab/cli/commands/services.rb +43 -0
- data/lib/ace/lab/cli/commands/support.rb +57 -0
- data/lib/ace/lab/cli.rb +71 -0
- data/lib/ace/lab/models/query_result.rb +55 -0
- data/lib/ace/lab/models/runtime_binding.rb +44 -0
- data/lib/ace/lab/models/topology_entry.rb +74 -0
- data/lib/ace/lab/molecules/caller_authorizer.rb +63 -0
- data/lib/ace/lab/molecules/capability_router.rb +69 -0
- data/lib/ace/lab/molecules/exact_resolver.rb +56 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +180 -0
- data/lib/ace/lab/molecules/inventory_query.rb +78 -0
- data/lib/ace/lab/molecules/topology_loader.rb +49 -0
- data/lib/ace/lab/organisms/topology_service.rb +105 -0
- data/lib/ace/lab/version.rb +7 -0
- data/lib/ace/lab.rb +136 -0
- metadata +203 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Lab
|
|
5
|
+
module Models
|
|
6
|
+
# Immutable runtime binding of an agent or service (spec 8wq.t.1w4).
|
|
7
|
+
# Carries configured identity facts only — never probes a runtime.
|
|
8
|
+
# Freshness (active state + exact instance/attestation identity match)
|
|
9
|
+
# is classified by Atoms::BindingFreshness.
|
|
10
|
+
class RuntimeBinding
|
|
11
|
+
KINDS = %w[runtime service].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :kind, :state, :instance_id, :attested_instance_id
|
|
14
|
+
|
|
15
|
+
def initialize(kind:, state: nil, instance_id: nil, attested_instance_id: nil)
|
|
16
|
+
# Defensive frozen copies: these strings feed the freshness facts,
|
|
17
|
+
# and a mutated shared string could turn a stale binding fresh
|
|
18
|
+
# despite the frozen model (review round 15, F1)
|
|
19
|
+
@kind = kind.dup.freeze
|
|
20
|
+
@state = state && state.dup.freeze
|
|
21
|
+
@instance_id = instance_id && instance_id.dup.freeze
|
|
22
|
+
@attested_instance_id = attested_instance_id && attested_instance_id.dup.freeze
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.from_h(hash)
|
|
27
|
+
new(
|
|
28
|
+
kind: hash["kind"],
|
|
29
|
+
state: hash["state"],
|
|
30
|
+
instance_id: hash["instance_id"],
|
|
31
|
+
attested_instance_id: hash["attested_instance_id"]
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_h
|
|
36
|
+
{
|
|
37
|
+
"kind" => kind, "state" => state,
|
|
38
|
+
"instance_id" => instance_id, "attested_instance_id" => attested_instance_id
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "runtime_binding"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lab
|
|
7
|
+
module Models
|
|
8
|
+
# Immutable stable-ID registry entry (spec 8wq.t.1w4). One of:
|
|
9
|
+
# project, agent, or service. Stable IDs are canonical; labels are
|
|
10
|
+
# display values only. Built from schema-normalized configuration.
|
|
11
|
+
class TopologyEntry
|
|
12
|
+
KINDS = %w[project agent service].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :kind, :id, :project, :label, :role, :capabilities,
|
|
15
|
+
:default_for, :endpoint, :binding
|
|
16
|
+
|
|
17
|
+
def initialize(kind:, id:, project: nil, label: nil, role: nil,
|
|
18
|
+
capabilities: [], default_for: [], endpoint: nil, binding: nil)
|
|
19
|
+
unless KINDS.include?(kind)
|
|
20
|
+
raise ArgumentError, "unknown entry kind: #{kind}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@kind = kind
|
|
24
|
+
# Defensive copies: public projections expose these strings, and a
|
|
25
|
+
# mutated shared string would corrupt the stable-ID/index invariant
|
|
26
|
+
# or routing facts — element-wise freezing included, since
|
|
27
|
+
# containers alone leave their string values mutable
|
|
28
|
+
# (review rounds 15-16, F1)
|
|
29
|
+
@id = id.dup.freeze
|
|
30
|
+
@project = project && project.dup.freeze
|
|
31
|
+
@label = label && label.dup.freeze
|
|
32
|
+
@role = role && role.dup.freeze
|
|
33
|
+
@capabilities = capabilities.map { |value| value.dup.freeze }.freeze
|
|
34
|
+
@default_for = default_for.map { |value| value.dup.freeze }.freeze
|
|
35
|
+
@endpoint = endpoint && endpoint.transform_values { |value| value.dup.freeze }.freeze
|
|
36
|
+
@binding = binding
|
|
37
|
+
freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.project(hash)
|
|
41
|
+
new(kind: "project", id: hash["id"], label: hash["label"])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.agent(hash)
|
|
45
|
+
new(
|
|
46
|
+
kind: "agent", id: hash["id"], project: hash["project"],
|
|
47
|
+
label: hash["label"], role: hash["role"],
|
|
48
|
+
capabilities: hash["capabilities"],
|
|
49
|
+
binding: Models::RuntimeBinding.from_h(hash["binding"] || {})
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.service(hash)
|
|
54
|
+
new(
|
|
55
|
+
kind: "service", id: hash["id"], project: hash["project"],
|
|
56
|
+
label: hash["label"], capabilities: hash["capabilities"],
|
|
57
|
+
default_for: hash["default_for"], endpoint: hash["endpoint"],
|
|
58
|
+
binding: Models::RuntimeBinding.from_h(hash["binding"] || {})
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def project?
|
|
63
|
+
kind == "project"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Capability check for routing; capabilities are normalized
|
|
67
|
+
# (stripped, lowercased) at schema validation
|
|
68
|
+
def capable_of?(capability)
|
|
69
|
+
capabilities.include?(capability)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lab
|
|
7
|
+
module Molecules
|
|
8
|
+
# Derives the verified local caller identity and enforces the
|
|
9
|
+
# project-visibility policy from configured authorization principals
|
|
10
|
+
# (spec 8wq.t.1w4). Caller identity comes from the local process owner
|
|
11
|
+
# (username or numeric uid) — never from a CLI flag. An identity with no
|
|
12
|
+
# matching principal is authorized for nothing.
|
|
13
|
+
class CallerAuthorizer
|
|
14
|
+
# Identity strings of the verified local process owner: the passwd
|
|
15
|
+
# username and the numeric uid, either of which may be configured.
|
|
16
|
+
def self.local_identity
|
|
17
|
+
username = begin
|
|
18
|
+
Etc.getpwuid(Process.uid)&.name
|
|
19
|
+
rescue
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
matchable_identities(username, Process.uid)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Usernames and numeric-uid grant keys share one namespace in the
|
|
26
|
+
# grants file. An all-digit username is matchable only via its uid so
|
|
27
|
+
# it can never collide with a different user's numeric-uid grant
|
|
28
|
+
# (subject review: separate caller identity principal namespaces).
|
|
29
|
+
def self.matchable_identities(username, uid)
|
|
30
|
+
identities = []
|
|
31
|
+
identities << username if username && username !~ /\A\d+\z/
|
|
32
|
+
identities << uid.to_s
|
|
33
|
+
identities.uniq
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param principals [Hash] authorization.principals from normalized config
|
|
37
|
+
# @param identity [Array<String>, nil] verified caller identities
|
|
38
|
+
# (defaults to the local process owner)
|
|
39
|
+
def initialize(principals: {}, identity: nil)
|
|
40
|
+
@principals = principals || {}
|
|
41
|
+
@identity = identity || self.class.local_identity
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Union of project IDs visible to the verified caller
|
|
45
|
+
# @return [Array<String>]
|
|
46
|
+
def authorized_project_ids
|
|
47
|
+
@authorized_project_ids ||= @identity.each_with_object([]) do |name, projects|
|
|
48
|
+
policy = @principals[name]
|
|
49
|
+
projects.concat(policy["projects"]) if policy.is_a?(Hash)
|
|
50
|
+
end.uniq
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Boolean] true when the caller may see the project; without
|
|
54
|
+
# an argument, true when the caller may see anything at all
|
|
55
|
+
def authorized?(project_id = nil)
|
|
56
|
+
return authorized_project_ids.any? if project_id.nil?
|
|
57
|
+
|
|
58
|
+
authorized_project_ids.include?(project_id)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Lab
|
|
5
|
+
module Molecules
|
|
6
|
+
# Project-local capability routing (spec 8wq.t.1w4). Candidates are
|
|
7
|
+
# configured capable services of the requested project with fresh
|
|
8
|
+
# bindings — never services from another project, never stale entries.
|
|
9
|
+
# Exactly one candidate routes directly; several require exactly one
|
|
10
|
+
# configured default; otherwise the result is classified ambiguous.
|
|
11
|
+
class CapabilityRouter
|
|
12
|
+
# @param index [Molecules::TopologyIndex]
|
|
13
|
+
# @param authorizer [Molecules::CallerAuthorizer]
|
|
14
|
+
def initialize(index:, authorizer:)
|
|
15
|
+
@index = index
|
|
16
|
+
@authorizer = authorizer
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def route(project:, capability:)
|
|
20
|
+
unless @authorizer.authorized?(project)
|
|
21
|
+
return Models::QueryResult.failure(
|
|
22
|
+
"unauthorized", "caller is not authorized for project #{project.inspect}", project: project
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
capability = normalize_capability(capability)
|
|
27
|
+
candidates = @index.services.select do |service|
|
|
28
|
+
service.project == project &&
|
|
29
|
+
service.capable_of?(capability) &&
|
|
30
|
+
Atoms::BindingFreshness.fresh?(service.binding)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if candidates.empty?
|
|
34
|
+
return Models::QueryResult.failure(
|
|
35
|
+
"missing",
|
|
36
|
+
"no available service with capability #{capability.inspect} in project #{project.inspect}",
|
|
37
|
+
project: project, capability: capability
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if candidates.length == 1
|
|
42
|
+
return Models::QueryResult.ok("entry" => Atoms::PublicProjection.service(candidates.first))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Conflicting defaults are not a preference: exactly one configured
|
|
46
|
+
# default may win, several classify ambiguous (review R1)
|
|
47
|
+
defaults = candidates.select { |service| service.default_for.include?(capability) }
|
|
48
|
+
if defaults.length == 1
|
|
49
|
+
Models::QueryResult.ok("entry" => Atoms::PublicProjection.service(defaults.first))
|
|
50
|
+
else
|
|
51
|
+
Models::QueryResult.failure(
|
|
52
|
+
"ambiguous",
|
|
53
|
+
"multiple capable services in project #{project.inspect} for capability #{capability.inspect}: " \
|
|
54
|
+
"#{candidates.map(&:id).join(", ")}; configure default_for on exactly one to disambiguate",
|
|
55
|
+
project: project, capability: capability
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Requested capabilities normalize the same way as configured ones
|
|
63
|
+
def normalize_capability(capability)
|
|
64
|
+
capability.to_s.strip.downcase
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Lab
|
|
5
|
+
module Molecules
|
|
6
|
+
# Exact stable-ID resolution (spec 8wq.t.1w4). Labels never resolve or
|
|
7
|
+
# disambiguate; only an exact configured ID matches. An entry outside
|
|
8
|
+
# the caller's authorization resolves exactly like a nonexistent one,
|
|
9
|
+
# so callers cannot probe private topology by comparing classified
|
|
10
|
+
# errors (review round 3, F3). A matched, authorized entry with an
|
|
11
|
+
# unfresh binding is an explicit stale result, never a routeable answer.
|
|
12
|
+
class ExactResolver
|
|
13
|
+
# @param index [Molecules::TopologyIndex]
|
|
14
|
+
# @param authorizer [Molecules::CallerAuthorizer]
|
|
15
|
+
def initialize(index:, authorizer:)
|
|
16
|
+
@index = index
|
|
17
|
+
@authorizer = authorizer
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def resolve(id)
|
|
21
|
+
entry = visible_entry(id)
|
|
22
|
+
return missing(id) if entry.nil?
|
|
23
|
+
|
|
24
|
+
unless entry.project? || Atoms::BindingFreshness.fresh?(entry.binding)
|
|
25
|
+
return Models::QueryResult.failure(
|
|
26
|
+
"stale",
|
|
27
|
+
"stable ID #{id.inspect} has a stale runtime binding; a replaced process must re-attest before routing",
|
|
28
|
+
id: id, project: entry.project
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Models::QueryResult.ok("entry" => Atoms::PublicProjection.entry(entry))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def visible_entry(id)
|
|
38
|
+
entry = @index.lookup(id)
|
|
39
|
+
return nil if entry.nil?
|
|
40
|
+
|
|
41
|
+
authorized = if entry.project?
|
|
42
|
+
@authorizer.authorized?(entry.id)
|
|
43
|
+
else
|
|
44
|
+
@authorizer.authorized?(entry.project)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
authorized ? entry : nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def missing(id)
|
|
51
|
+
Models::QueryResult.failure("missing", "no topology entry with stable ID #{id.inspect}", id: id)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lab
|
|
7
|
+
module Molecules
|
|
8
|
+
# Resolves authorization grants from the single deployment-controlled
|
|
9
|
+
# document at a fixed path — never from the configuration cascade and
|
|
10
|
+
# never from caller-selected locations (review rounds 4-5).
|
|
11
|
+
#
|
|
12
|
+
# Trust is established per traversal hop along the ORIGINAL path: every
|
|
13
|
+
# element — directories, any symlinks, and the file itself — must be
|
|
14
|
+
# root-owned and not group/world-writable, so a caller-writable
|
|
15
|
+
# directory cannot redirect the path to attacker-chosen content
|
|
16
|
+
# (review round 8, F2). The final component is opened O_NOFOLLOW and
|
|
17
|
+
# re-verified via fstat. Grants are validated structurally only and
|
|
18
|
+
# intersect with the local topology at query time: the grants file is
|
|
19
|
+
# machine-global while topology is per-directory (review round 8, F1).
|
|
20
|
+
# Any failed verification fails closed; no trusted document means
|
|
21
|
+
# nobody is authorized.
|
|
22
|
+
class GrantResolver
|
|
23
|
+
MAX_SYMLINK_HOPS = 8
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# @param documents [Array<Hash>] {path:, document:, defaults:}
|
|
27
|
+
# from Ace::Lab.cascade_documents
|
|
28
|
+
# @param topology [Hash] normalized topology (project IDs)
|
|
29
|
+
# @param trusted_path [String] fixed deployment grants path
|
|
30
|
+
# @return [Hash] principals mapping for CallerAuthorizer
|
|
31
|
+
# @raise [Ace::Lab::InvalidConfigurationError]
|
|
32
|
+
def resolve(documents:, topology:, trusted_path:)
|
|
33
|
+
offending = documents.reject { |document| document[:defaults] }
|
|
34
|
+
.find { |document| document[:document].key?("authorization") }
|
|
35
|
+
if offending
|
|
36
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
37
|
+
"invalid lab configuration: authorization grants come from the trusted file " \
|
|
38
|
+
"#{trusted_path}, not the cascade; remove the authorization section from #{offending[:path]}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
content = read_verified(trusted_path)
|
|
42
|
+
return {"principals" => {}} if content.nil?
|
|
43
|
+
|
|
44
|
+
parse_grants(content, trusted_path)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def verify_error(path)
|
|
50
|
+
"invalid lab configuration: trusted authorization file #{path} failed the deployment " \
|
|
51
|
+
"ownership verification or could not be read; failing closed " \
|
|
52
|
+
"(required: every traversed element root-owned and not group/world-writable)"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Read the grants document only after proving the deployment owns
|
|
56
|
+
# the storage along the original path. Genuine absence is nil
|
|
57
|
+
# (nobody authorized); every other filesystem failure — including
|
|
58
|
+
# races between lstat and open — classifies fail-closed
|
|
59
|
+
# (review rounds 6-7, F2).
|
|
60
|
+
# @return [String, nil] file content, or nil when absent
|
|
61
|
+
def read_verified(path)
|
|
62
|
+
candidate = begin
|
|
63
|
+
verified_resolve(path)
|
|
64
|
+
rescue Ace::Lab::InvalidConfigurationError
|
|
65
|
+
raise
|
|
66
|
+
rescue
|
|
67
|
+
raise Ace::Lab::InvalidConfigurationError, verify_error(path)
|
|
68
|
+
end
|
|
69
|
+
return nil if candidate.nil?
|
|
70
|
+
|
|
71
|
+
begin
|
|
72
|
+
io = File.open(candidate, File::RDONLY | File::NOFOLLOW)
|
|
73
|
+
begin
|
|
74
|
+
raise Ace::Lab::InvalidConfigurationError, verify_error(path) unless secure_file_stat?(io.stat)
|
|
75
|
+
|
|
76
|
+
io.read
|
|
77
|
+
ensure
|
|
78
|
+
io.close
|
|
79
|
+
end
|
|
80
|
+
rescue Ace::Lab::InvalidConfigurationError
|
|
81
|
+
raise
|
|
82
|
+
rescue
|
|
83
|
+
raise Ace::Lab::InvalidConfigurationError, verify_error(path)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Walk the ORIGINAL path component by component. Symlinks are
|
|
88
|
+
# followed only when the link itself is root-owned (a caller-writable
|
|
89
|
+
# redirect is rejected); symlink permission bits are ignored because
|
|
90
|
+
# Linux symlinks always report 0777 and cannot be changed
|
|
91
|
+
# (review round 9, F1). Intermediate directories must be real,
|
|
92
|
+
# root-owned directories; the opened file is re-verified via fstat.
|
|
93
|
+
# @return [String, nil] verified file path, or nil when absent
|
|
94
|
+
def verified_resolve(path)
|
|
95
|
+
remaining = path.split(File::SEPARATOR).reject(&:empty?)
|
|
96
|
+
current = File::SEPARATOR
|
|
97
|
+
hops = 0
|
|
98
|
+
|
|
99
|
+
until remaining.empty?
|
|
100
|
+
component = remaining.shift
|
|
101
|
+
candidate = (current == File::SEPARATOR) ? "/#{component}" : File.join(current, component)
|
|
102
|
+
stat = begin
|
|
103
|
+
File.lstat(candidate)
|
|
104
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
105
|
+
return nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if stat.symlink?
|
|
109
|
+
hops += 1
|
|
110
|
+
raise Ace::Lab::InvalidConfigurationError, verify_error(path) if hops > MAX_SYMLINK_HOPS
|
|
111
|
+
raise Ace::Lab::InvalidConfigurationError, verify_error(path) unless stat.uid.zero?
|
|
112
|
+
|
|
113
|
+
target = File.readlink(candidate)
|
|
114
|
+
target_components = target.split(File::SEPARATOR).reject(&:empty?)
|
|
115
|
+
remaining = target_components + remaining
|
|
116
|
+
current = target.start_with?(File::SEPARATOR) ? File::SEPARATOR : current
|
|
117
|
+
next
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
if remaining.empty?
|
|
121
|
+
return candidate
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
unless stat.directory? && secure_file_stat?(stat)
|
|
125
|
+
raise Ace::Lab::InvalidConfigurationError, verify_error(path)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
current = candidate
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
raise Ace::Lab::InvalidConfigurationError, verify_error(path)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def secure_file_stat?(stat)
|
|
135
|
+
stat.uid.zero? && (stat.mode & 0o022).zero?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Structural validation only: the grants file is machine-global, so
|
|
139
|
+
# referenced projects may legitimately not exist in the local
|
|
140
|
+
# directory's topology; such grants simply never match at query
|
|
141
|
+
# time (review round 8, F1). Messages are value-free.
|
|
142
|
+
def parse_grants(content, path)
|
|
143
|
+
document = YAML.safe_load(content, permitted_classes: [Date], aliases: true)
|
|
144
|
+
unless document.is_a?(Hash)
|
|
145
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
146
|
+
"invalid lab configuration: trusted authorization file #{path} must contain a YAML mapping"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
principals = document["principals"] || {}
|
|
150
|
+
unless principals.is_a?(Hash)
|
|
151
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
152
|
+
"invalid lab configuration: trusted authorization file #{path} principals must be a mapping"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
validated = principals.map do |identity, policy|
|
|
156
|
+
unless identity.is_a?(String) && !identity.strip.empty?
|
|
157
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
158
|
+
"invalid lab configuration: trusted authorization file #{path} principal name " \
|
|
159
|
+
"must be a non-empty string"
|
|
160
|
+
end
|
|
161
|
+
unless policy.is_a?(Hash) && policy["projects"].is_a?(Array) &&
|
|
162
|
+
policy["projects"].all? { |project| project.is_a?(String) && !project.strip.empty? }
|
|
163
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
164
|
+
"invalid lab configuration: trusted authorization file #{path} principal projects " \
|
|
165
|
+
"must be an array of non-empty strings"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
[identity.strip, {"projects" => policy["projects"].map(&:strip).uniq}]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
{"principals" => validated.to_h}
|
|
172
|
+
rescue Psych::Exception
|
|
173
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
174
|
+
"invalid lab configuration: trusted authorization file #{path} could not be parsed as YAML"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Lab
|
|
5
|
+
module Molecules
|
|
6
|
+
# Project-scoped inventory queries over a validated TopologyIndex
|
|
7
|
+
# (spec 8wq.t.1w4). Every result is authorization-filtered: a caller
|
|
8
|
+
# sees only projects covered by a configured principal, and an
|
|
9
|
+
# unauthorized project is indistinguishable from any other
|
|
10
|
+
# unauthorized project (classified error, no metadata).
|
|
11
|
+
class InventoryQuery
|
|
12
|
+
# @param index [Molecules::TopologyIndex]
|
|
13
|
+
# @param authorizer [Molecules::CallerAuthorizer]
|
|
14
|
+
def initialize(index:, authorizer:)
|
|
15
|
+
@index = index
|
|
16
|
+
@authorizer = authorizer
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Authorized projects only; a caller with no principals sees nothing
|
|
20
|
+
def projects
|
|
21
|
+
return unauthorized_inventory unless @authorizer.authorized?
|
|
22
|
+
|
|
23
|
+
visible = @index.projects.select { |project| @authorizer.authorized?(project.id) }
|
|
24
|
+
Models::QueryResult.ok("projects" => visible.map { |p| Atoms::PublicProjection.project(p) })
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def agents(project:)
|
|
28
|
+
return unauthorized_project(project) unless @authorizer.authorized?(project)
|
|
29
|
+
return missing_project(project) unless project_exists?(project)
|
|
30
|
+
|
|
31
|
+
scoped = @index.agents.select { |agent| agent.project == project }
|
|
32
|
+
Models::QueryResult.ok("agents" => scoped.map { |a| Atoms::PublicProjection.agent(a) })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def services(project:)
|
|
36
|
+
return unauthorized_project(project) unless @authorizer.authorized?(project)
|
|
37
|
+
return missing_project(project) unless project_exists?(project)
|
|
38
|
+
|
|
39
|
+
scoped = @index.services.select { |service| service.project == project }
|
|
40
|
+
Models::QueryResult.ok("services" => scoped.map { |s| Atoms::PublicProjection.service(s) })
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Machine-wide grants may name projects absent from the local
|
|
46
|
+
# topology; an unknown project is an error, never a valid empty
|
|
47
|
+
# inventory (review round 17, F1)
|
|
48
|
+
def project_exists?(project)
|
|
49
|
+
entry = @index.lookup(project)
|
|
50
|
+
!entry.nil? && entry.project?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def missing_project(project)
|
|
54
|
+
Models::QueryResult.failure(
|
|
55
|
+
"missing",
|
|
56
|
+
"no project #{project.inspect} in the configured topology",
|
|
57
|
+
project: project
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def unauthorized_inventory
|
|
62
|
+
Models::QueryResult.failure(
|
|
63
|
+
"unauthorized",
|
|
64
|
+
"caller has no configured authorization for any lab project"
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def unauthorized_project(project)
|
|
69
|
+
Models::QueryResult.failure(
|
|
70
|
+
"unauthorized",
|
|
71
|
+
"caller is not authorized for project #{project.inspect}",
|
|
72
|
+
project: project
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Lab
|
|
5
|
+
module Molecules
|
|
6
|
+
# Indexed view of validated lab topology (spec 8wq.t.1w4): entries by
|
|
7
|
+
# globally-unique stable ID plus per-kind collections. Immutable.
|
|
8
|
+
class TopologyIndex
|
|
9
|
+
attr_reader :projects, :agents, :services, :by_id
|
|
10
|
+
|
|
11
|
+
def initialize(projects:, agents:, services:, by_id:)
|
|
12
|
+
@projects = projects.freeze
|
|
13
|
+
@agents = agents.freeze
|
|
14
|
+
@services = services.freeze
|
|
15
|
+
@by_id = by_id.freeze
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def lookup(id)
|
|
20
|
+
by_id[id]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Loads ADR-022-resolved lab configuration, validates it through
|
|
25
|
+
# Atoms::TopologySchema, and builds the immutable TopologyIndex.
|
|
26
|
+
class TopologyLoader
|
|
27
|
+
def initialize(config = nil)
|
|
28
|
+
@config = config
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [TopologyIndex]
|
|
32
|
+
# @raise [Ace::Lab::InvalidConfigurationError]
|
|
33
|
+
def load
|
|
34
|
+
normalized = Atoms::TopologySchema.normalize!(@config || Ace::Lab.config)
|
|
35
|
+
|
|
36
|
+
projects = normalized["topology"]["projects"].map { |h| Models::TopologyEntry.project(h) }
|
|
37
|
+
agents = normalized["topology"]["agents"].map { |h| Models::TopologyEntry.agent(h) }
|
|
38
|
+
services = normalized["topology"]["services"].map { |h| Models::TopologyEntry.service(h) }
|
|
39
|
+
|
|
40
|
+
by_id = (projects + agents + services).each_with_object({}) do |entry, index|
|
|
41
|
+
index[entry.id] = entry
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
TopologyIndex.new(projects: projects, agents: agents, services: services, by_id: by_id)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|