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.
@@ -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