ace-lab 0.13.1 → 0.14.1
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 +104 -0
- data/docs/usage.md +130 -0
- data/lib/ace/lab/atoms/install_schema.rb +72 -11
- data/lib/ace/lab/auth/credential_handoff.rb +47 -0
- data/lib/ace/lab/auth/lease_store.rb +212 -0
- data/lib/ace/lab/auth/plugins/env_provider.rb +60 -0
- data/lib/ace/lab/auth/plugins/forgejo_provider.rb +189 -0
- data/lib/ace/lab/auth/plugins/github_provider.rb +207 -0
- data/lib/ace/lab/auth/profile_schema.rb +152 -0
- data/lib/ace/lab/auth/providers.rb +66 -0
- data/lib/ace/lab/auth/service.rb +457 -0
- data/lib/ace/lab/auth.rb +25 -0
- data/lib/ace/lab/cli/commands/auth.rb +99 -0
- data/lib/ace/lab/cli.rb +4 -1
- data/lib/ace/lab/molecules/extension_projection.rb +91 -0
- data/lib/ace/lab/molecules/extension_provisioner.rb +226 -0
- data/lib/ace/lab/molecules/gem_payload_permissions.rb +174 -0
- data/lib/ace/lab/molecules/gemset_provisioner.rb +229 -4
- data/lib/ace/lab/molecules/project_locations.rb +62 -0
- data/lib/ace/lab/organisms/prepare.rb +28 -2
- data/lib/ace/lab/organisms/verify.rb +139 -9
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +1 -0
- metadata +16 -2
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Lab
|
|
9
|
+
module Auth
|
|
10
|
+
# Explicit error base for the auth surface (the CLI rescues it).
|
|
11
|
+
class Error < ::Ace::Lab::Error; end
|
|
12
|
+
|
|
13
|
+
# The auth service core (task 8xs.t.pu0.7): request / status /
|
|
14
|
+
# exec / revoke / reconcile over the trusted host configuration
|
|
15
|
+
# and the plugin registry. It owns the lease lifecycle and the
|
|
16
|
+
# minimal lease record — nothing else: no task progress, no
|
|
17
|
+
# queues, no admission service. The calling ACCOUNT is verified
|
|
18
|
+
# (the profile's principals/roles gate issuance — naming a role
|
|
19
|
+
# never grants a right), TTLs refuse instead of silently
|
|
20
|
+
# clipping, and the secret exists only in the ephemeral handoff.
|
|
21
|
+
class Service
|
|
22
|
+
DEFAULT_LEASES_DIR = "/var/lib/lab/ace-lab/auth/leases"
|
|
23
|
+
DEFAULT_CREDENTIALS_DIR = "/run/lab/credentials"
|
|
24
|
+
# The child environment is an ALLOWLIST: only non-secret runtime
|
|
25
|
+
# variables the child needs to execute, plus the leased
|
|
26
|
+
# credential reference — the issuer's whole environment (and
|
|
27
|
+
# any ambient credential override like GH_TOKEN in it) is never
|
|
28
|
+
# inherited past the lease's own scope.
|
|
29
|
+
CHILD_ENV_ALLOWLIST = %w[PATH HOME TMPDIR LANG LC_ALL TZ USER LOGNAME SHELL].freeze
|
|
30
|
+
|
|
31
|
+
attr_reader :config, :store
|
|
32
|
+
|
|
33
|
+
# @param authorization_path [String] trusted document location
|
|
34
|
+
# @param runner [Proc] argv runner for the exec'd child
|
|
35
|
+
def initialize(authorization_path: nil, runner: nil, principal: nil)
|
|
36
|
+
@authorization_path = authorization_path || Ace::Lab.authorization_path
|
|
37
|
+
# The default runner spawns the child through `env -i` so the
|
|
38
|
+
# allowlisted child environment is EXACTLY what the child
|
|
39
|
+
# sees — Ruby's system(env, ...) MERGES the hash into the
|
|
40
|
+
# inherited environment, which would leak every ambient
|
|
41
|
+
# credential override past the profile's scope. The real exit
|
|
42
|
+
# status is preserved (nil-status spawn failure degrades to
|
|
43
|
+
# 127, signals to 128+signum).
|
|
44
|
+
@runner = runner || lambda { |argv, env|
|
|
45
|
+
spawned = ["env", "-i", *env.map { |key, value| "#{key}=#{value}" }, *argv]
|
|
46
|
+
system(*spawned)
|
|
47
|
+
status = $?
|
|
48
|
+
if status.nil?
|
|
49
|
+
127
|
|
50
|
+
elsif status.success?
|
|
51
|
+
0
|
|
52
|
+
else
|
|
53
|
+
status.exitstatus || 128 + status.termsig.to_i
|
|
54
|
+
end
|
|
55
|
+
}
|
|
56
|
+
@principal = principal || -> { Etc.getpwuid(Process.uid).name }
|
|
57
|
+
@config = load_config
|
|
58
|
+
@store = LeaseStore.new(@config["leases_dir"] || DEFAULT_LEASES_DIR)
|
|
59
|
+
@credentials_dir = @config["credentials_dir"] || DEFAULT_CREDENTIALS_DIR
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# --- request -------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
# Idempotent only per the IDENTICAL live request: the
|
|
65
|
+
# task/assignment/profile/role correlation AND the requested
|
|
66
|
+
# scope/principal must match; a mismatched retry (e.g. a
|
|
67
|
+
# narrower repo list) is refused, never silently answered with
|
|
68
|
+
# the wider existing grant. The granted scope is the
|
|
69
|
+
# INTERSECTION of the host profile, the request and the task's
|
|
70
|
+
# repo — a request may narrow, never widen; editing the task or
|
|
71
|
+
# project config grants nothing.
|
|
72
|
+
def request(profile_name:, role:, task:, assignment:, repos: [],
|
|
73
|
+
ttl_minutes: nil)
|
|
74
|
+
profile = profile_for(profile_name)
|
|
75
|
+
unless profile["roles"].include?(role)
|
|
76
|
+
raise Refusal.new("role '#{role}' is not allowed by profile '#{profile_name}'")
|
|
77
|
+
end
|
|
78
|
+
principal = @principal.call
|
|
79
|
+
unless profile["principals"].empty? || profile["principals"].include?(principal)
|
|
80
|
+
raise Refusal.new("principal '#{principal}' is not allowed by profile '#{profile_name}'; " \
|
|
81
|
+
"escalate to the host owner")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
ttl = ttl_minutes || profile["ttl_minutes"]
|
|
85
|
+
unless ttl.is_a?(Integer) && ttl.positive?
|
|
86
|
+
raise Refusal.new("ttl must be a positive number of minutes, got #{ttl.inspect}; refused")
|
|
87
|
+
end
|
|
88
|
+
if ttl > profile["max_ttl_minutes"]
|
|
89
|
+
raise Refusal.new("ttl #{ttl}m exceeds the profile limit #{profile['max_ttl_minutes']}m; " \
|
|
90
|
+
"refused, not clipped")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The granted scope is computed BEFORE the live-lease lookup
|
|
94
|
+
# so a retry is judged against what THIS request asked for.
|
|
95
|
+
requested = Array(repos).map(&:to_s).sort
|
|
96
|
+
repos = (profile["repos"].empty? ? requested : profile["repos"] & requested)
|
|
97
|
+
if repos.empty?
|
|
98
|
+
raise Refusal.new("the granted repository scope would be empty (profile " \
|
|
99
|
+
"'#{profile_name}' repos: [#{profile["repos"].join(", ")}]; requested: " \
|
|
100
|
+
"[#{requested.join(", ")}]); an empty repository grant means ALL " \
|
|
101
|
+
"repositories provider-side — refused before issuing")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The provider is resolved and capability-verified BEFORE any
|
|
105
|
+
# record exists: a failed resolve or an expiry the provider
|
|
106
|
+
# cannot back must not orphan a lease record.
|
|
107
|
+
issue = Providers.resolve(profile["provider"])
|
|
108
|
+
# A provider unable to back the profile's NATIVE expiry must
|
|
109
|
+
# be visible BEFORE handoff, never hidden behind use_until.
|
|
110
|
+
if profile["expiry_mode"] == "provider" &&
|
|
111
|
+
issue.capabilities.fetch("expiry_mode") != "provider"
|
|
112
|
+
raise Refusal.new("provider '#{profile["provider"]}' cannot guarantee the " \
|
|
113
|
+
"profile's native expiry")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# One request flow at a time per correlation: find_live +
|
|
117
|
+
# create + issue are atomic against a concurrent identical
|
|
118
|
+
# request — the second waits, then returns the first's LIVE
|
|
119
|
+
# lease instead of minting a second token.
|
|
120
|
+
@store.with_correlation_lock(task: task, assignment: assignment,
|
|
121
|
+
profile: profile_name, role: role) do
|
|
122
|
+
existing = @store.find_live(task: task, assignment: assignment,
|
|
123
|
+
profile: profile_name, role: role)
|
|
124
|
+
if existing && @store.live?(existing)
|
|
125
|
+
# Idempotency holds only for the IDENTICAL request: a live
|
|
126
|
+
# lease with a different granted scope or principal is a
|
|
127
|
+
# mismatched retry — refusing it never silently reuses a
|
|
128
|
+
# wider grant for a narrower ask.
|
|
129
|
+
unless existing["repos"] == repos && existing["principal"] == principal
|
|
130
|
+
raise Refusal.new("a live lease for task '#{task}' assignment '#{assignment}' " \
|
|
131
|
+
"profile '#{profile_name}' role '#{role}' already exists with scope " \
|
|
132
|
+
"[#{existing["repos"].join(", ")}] for principal '#{existing["principal"]}'; " \
|
|
133
|
+
"revoke it first or repeat the identical request")
|
|
134
|
+
end
|
|
135
|
+
next existing.merge("state_detail" => "live")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# An unresolved predecessor (issue lost to a crash while
|
|
139
|
+
# PREPARING, or a lost response after possible remote
|
|
140
|
+
# issuance) is settled DETERMINISTICALLY before any re-mint
|
|
141
|
+
# — the remote token, if it exists, is revoked through the
|
|
142
|
+
# record's deterministic reference. A settlement that
|
|
143
|
+
# cannot be confirmed refuses the retry: a local pending
|
|
144
|
+
# record is never proof the remote token is gone.
|
|
145
|
+
settle_unresolved(task: task, assignment: assignment,
|
|
146
|
+
profile: profile_name, role: role, principal: principal)
|
|
147
|
+
|
|
148
|
+
lease_id = "lease-#{SecureRandom.hex(8)}"
|
|
149
|
+
# Duck-typed capability: bundled providers always implement
|
|
150
|
+
# token_ref_for; an external plugin without one simply has no
|
|
151
|
+
# deterministic identity (unsettled issues then wait for
|
|
152
|
+
# reconcile/native expiry instead).
|
|
153
|
+
token_ref = issue.token_ref_for(lease_id: lease_id) if issue.respond_to?(:token_ref_for)
|
|
154
|
+
lease = @store.create(id: lease_id,
|
|
155
|
+
profile: profile_name, role: role, principal: principal,
|
|
156
|
+
task: task, assignment: assignment, repos: repos,
|
|
157
|
+
use_until: Time.at(Time.now.utc + ttl * 60).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
158
|
+
provider_expiry: nil,
|
|
159
|
+
token_ref: token_ref)
|
|
160
|
+
|
|
161
|
+
begin
|
|
162
|
+
granted = issue.issue(profile: profile, lease: lease)
|
|
163
|
+
rescue Providers::RefusedError => e
|
|
164
|
+
@store.update(lease["id"]) { |record| record["state"] = "revoked" }
|
|
165
|
+
raise Refusal.new("provider refused: #{e.message}")
|
|
166
|
+
rescue Providers::TransientError, Providers::OutcomeUnknownError => e
|
|
167
|
+
@store.update(lease["id"]) { |record| record["state"] = "revoke-pending" }
|
|
168
|
+
raise Error.new("issuance failed with unknown outcome: #{e.message}; " \
|
|
169
|
+
"reconcile resolves the pending record")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
@store.update(lease["id"]) do |record|
|
|
173
|
+
record["token_ref"] = granted["token_ref"] || record["token_ref"]
|
|
174
|
+
record["provider_expiry"] = granted["provider_expiry"]&.to_s
|
|
175
|
+
record["via"] = granted["via"]
|
|
176
|
+
end
|
|
177
|
+
# The secret goes ONLY to the ephemeral handoff; the returned
|
|
178
|
+
# lease record carries references, never values. A failed
|
|
179
|
+
# delivery is never a live grant: the token may exist
|
|
180
|
+
# provider-side, so the record becomes revoke-pending for
|
|
181
|
+
# reconcile to finish — an interrupted handoff must not be
|
|
182
|
+
# reusable by a retry.
|
|
183
|
+
handoff = CredentialHandoff.new(@credentials_dir, recipient: principal)
|
|
184
|
+
begin
|
|
185
|
+
handoff.deliver(lease["id"], granted["token"])
|
|
186
|
+
rescue StandardError => e
|
|
187
|
+
@store.update(lease["id"]) { |record| record["state"] = "revoke-pending" }
|
|
188
|
+
raise Error.new("credential handoff failed: #{e.message}; " \
|
|
189
|
+
"reconcile resolves the pending record")
|
|
190
|
+
end
|
|
191
|
+
# ISSUED means a COMPLETE, usable grant: credential
|
|
192
|
+
# delivered, deadlines recorded, cleanup due. Until this
|
|
193
|
+
# write the record is PREPARING — never delivered, never
|
|
194
|
+
# returned as live, and recoverable after a hard death.
|
|
195
|
+
@store.update(lease["id"]) do |record|
|
|
196
|
+
record["state"] = "issued"
|
|
197
|
+
record["cleanup_due_at"] = record["use_until"]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The REQUEST return keeps token_ref (the holder just minted
|
|
201
|
+
# the lease and may need the reference for reconcile), while
|
|
202
|
+
# status() strips it to the public view — intentional
|
|
203
|
+
# reference-vs-value distinction, never the token itself.
|
|
204
|
+
@store.load(lease["id"]).merge("state_detail" => "issued")
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# --- status --------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def status(lease_id: nil)
|
|
211
|
+
return public_view(@store.load(lease_id)) if lease_id
|
|
212
|
+
|
|
213
|
+
@store.all.map { |lease| public_view(lease) }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# --- exec ----------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
# Runs the child under the lease: the child receives the
|
|
219
|
+
# credential REFERENCE (ACE_LAB_CREDENTIAL_FILE / the profile's
|
|
220
|
+
# env names), never a value in argv. The process exit code is
|
|
221
|
+
# preserved; on process success a cleanup failure is itself an
|
|
222
|
+
# error; the report carries both outcomes. One exec at a time
|
|
223
|
+
# per lease.
|
|
224
|
+
def exec(lease_id:, argv:, env: {})
|
|
225
|
+
lease = @store.load(lease_id)
|
|
226
|
+
raise Refusal.new("lease '#{lease_id}' is not live") unless @store.live?(lease)
|
|
227
|
+
|
|
228
|
+
@store.with_exec_lock(lease_id) do
|
|
229
|
+
profile = profile_for(lease["profile"])
|
|
230
|
+
handoff = CredentialHandoff.new(@credentials_dir, recipient: lease["principal"])
|
|
231
|
+
credential = handoff.path(lease_id)
|
|
232
|
+
# A live lease without its delivered credential is not
|
|
233
|
+
# executable — degrade with a clear reason instead of an
|
|
234
|
+
# unrescued ENOENT from the child env merge.
|
|
235
|
+
unless File.file?(credential)
|
|
236
|
+
raise Refusal.new("lease '#{lease_id}' has no delivered credential at " \
|
|
237
|
+
"'#{credential}'; run reconcile or request a fresh lease")
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# ALLOWLISTED child environment: required non-secret
|
|
241
|
+
# runtime variables plus the leased credential reference —
|
|
242
|
+
# nothing else is inherited, so an ambient credential
|
|
243
|
+
# override (GH_TOKEN, GIT_* auth, ...) can never bypass the
|
|
244
|
+
# profile's scope.
|
|
245
|
+
child_env = CHILD_ENV_ALLOWLIST.each_with_object({}) do |key, allowlist|
|
|
246
|
+
allowlist[key] = ENV[key] if ENV[key]
|
|
247
|
+
end
|
|
248
|
+
child_env.merge!(env,
|
|
249
|
+
"ACE_LAB_LEASE" => lease_id,
|
|
250
|
+
"ACE_LAB_CREDENTIAL_FILE" => credential)
|
|
251
|
+
if profile["provider"] == "env"
|
|
252
|
+
begin
|
|
253
|
+
child_env.merge!(JSON.parse(File.read(credential)))
|
|
254
|
+
rescue JSON::ParserError, Errno::ENOENT => e
|
|
255
|
+
raise Refusal.new("lease '#{lease_id}' credential could not be read as " \
|
|
256
|
+
"an env handoff (#{e.class.name}); reconcile or request a fresh lease")
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
exit_code = @runner.call(argv, child_env)
|
|
261
|
+
cleanup = cleanup(lease, profile, handoff)
|
|
262
|
+
if exit_code.zero? && cleanup != :revoked && cleanup != :cleared
|
|
263
|
+
raise Error.new("process succeeded (exit 0) but cleanup failed: #{cleanup}")
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
{"lease" => lease_id, "exit" => exit_code, "cleanup" => cleanup.to_s}
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# --- revoke --------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
# Idempotent; an unknown/foreign lease id is refused; a network
|
|
273
|
+
# outcome stays visible as revoke-pending — a revocation is
|
|
274
|
+
# never CLAIMED before the provider confirms it.
|
|
275
|
+
#
|
|
276
|
+
# `force:` is the explicit OPERATOR decision shape for a lease
|
|
277
|
+
# stuck revoke-pending with its credential already gone (the
|
|
278
|
+
# token cannot be presented to the provider anymore): it closes
|
|
279
|
+
# the record as forced-terminal with the unconfirmed outcome
|
|
280
|
+
# recorded — never a claimed revocation.
|
|
281
|
+
def revoke(lease_id:, force: false)
|
|
282
|
+
lease = @store.load(lease_id)
|
|
283
|
+
handoff = CredentialHandoff.new(@credentials_dir, recipient: lease["principal"])
|
|
284
|
+
|
|
285
|
+
# An already-revoked lease never re-dials the provider: the
|
|
286
|
+
# idempotent result stands and only the handoff is re-cleared.
|
|
287
|
+
if lease["state"] == "revoked"
|
|
288
|
+
handoff.clear(lease_id)
|
|
289
|
+
return :revoked
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
if force && lease["state"] == "revoke-pending" &&
|
|
293
|
+
!File.file?(handoff.path(lease_id))
|
|
294
|
+
@store.update(lease_id) do |record|
|
|
295
|
+
record["state"] = "revoked"
|
|
296
|
+
record["cleanup"] = {"result" => "forced-terminal",
|
|
297
|
+
"note" => "credential absent; provider outcome unconfirmed — operator-forced close",
|
|
298
|
+
"at" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}
|
|
299
|
+
end
|
|
300
|
+
return :forced_terminal
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# A removed profile cannot resolve its provider: the lease
|
|
304
|
+
# stays a visible revoke-pending open item instead of
|
|
305
|
+
# crashing the caller.
|
|
306
|
+
begin
|
|
307
|
+
profile = profile_for(lease["profile"])
|
|
308
|
+
rescue Refusal
|
|
309
|
+
@store.update(lease_id) { |record| record["state"] = "revoke-pending" }
|
|
310
|
+
return :"revoke-pending (profile '#{lease["profile"]}' removed from the authorization document)"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
cleanup(lease, profile, handoff)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# --- reconcile -----------------------------------------------------
|
|
317
|
+
|
|
318
|
+
# Resolves interrupted issue/revoke cycles and expired
|
|
319
|
+
# assignments in the SAME store: preparing (dead mid-issue),
|
|
320
|
+
# revoke-pending and past-deadline leases are
|
|
321
|
+
# revocation-retried, leftover handoff files are cleared, a
|
|
322
|
+
# corrupt record is a VISIBLE recovery error, and the report is
|
|
323
|
+
# explicit about what stays unknown. Assignments themselves are
|
|
324
|
+
# not reconstructed here.
|
|
325
|
+
def reconcile
|
|
326
|
+
results = []
|
|
327
|
+
@store.all.each do |lease|
|
|
328
|
+
handoff = CredentialHandoff.new(@credentials_dir, recipient: lease["principal"])
|
|
329
|
+
outcome =
|
|
330
|
+
if lease["corrupt"]
|
|
331
|
+
# An unparseable record is a visible recovery problem —
|
|
332
|
+
# the remote token may still be live; it must never
|
|
333
|
+
# silently vanish from the inventory.
|
|
334
|
+
record = File.basename(lease["_path"].to_s)
|
|
335
|
+
:"corrupt (unparseable lease record #{record}; manual recovery required — the remote token may still be live)"
|
|
336
|
+
elsif lease["state"] == "revoked"
|
|
337
|
+
handoff.clear(lease["id"])
|
|
338
|
+
:already_revoked
|
|
339
|
+
else
|
|
340
|
+
begin
|
|
341
|
+
profile = profile_for(lease["profile"])
|
|
342
|
+
rescue Refusal
|
|
343
|
+
# A removed profile cannot resolve its provider: keep
|
|
344
|
+
# the lease a visible revoke-pending open item (the
|
|
345
|
+
# operator closes it with `revoke --force` when the
|
|
346
|
+
# credential is gone) instead of crashing reconcile.
|
|
347
|
+
@store.update(lease["id"]) { |record| record["state"] = "revoke-pending" }
|
|
348
|
+
:"revoke-pending (profile '#{lease["profile"]}' removed)"
|
|
349
|
+
else
|
|
350
|
+
if lease["state"] == "preparing" || lease["state"] == "revoke-pending" ||
|
|
351
|
+
!@store.live?(lease)
|
|
352
|
+
cleanup(lease, profile, handoff)
|
|
353
|
+
else
|
|
354
|
+
:live
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
results << {"lease" => lease["id"], "state" => lease["state"], "reconciled" => outcome.to_s}
|
|
359
|
+
end
|
|
360
|
+
results
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
private
|
|
364
|
+
|
|
365
|
+
# R5: a previous issue with an UNRESOLVED remote outcome (lost
|
|
366
|
+
# response after possible issuance, or a hard death while
|
|
367
|
+
# PREPARING) is settled before any re-mint. Settlement runs the
|
|
368
|
+
# normal cleanup — the record's DETERMINISTIC token_ref (written
|
|
369
|
+
# at creation) identifies the remote token, the handoff file
|
|
370
|
+
# supplies the secret when it exists — and only a confirmed
|
|
371
|
+
# :revoked/:already_invalid/:cleared lets the retry proceed. A
|
|
372
|
+
# pending/unknown settlement REFUSES the retry: never a blind
|
|
373
|
+
# second mint, never treating a local pending record as a
|
|
374
|
+
# remote revocation.
|
|
375
|
+
def settle_unresolved(task:, assignment:, profile:, role:, principal:)
|
|
376
|
+
unresolved = @store.all.select do |record|
|
|
377
|
+
record["task"] == task && record["assignment"] == assignment &&
|
|
378
|
+
record["profile"] == profile && record["role"] == role &&
|
|
379
|
+
%w[preparing revoke-pending].include?(record["state"])
|
|
380
|
+
end
|
|
381
|
+
return if unresolved.empty?
|
|
382
|
+
|
|
383
|
+
record_profile = profile_for(profile)
|
|
384
|
+
unresolved.each do |record|
|
|
385
|
+
# An unknown ISSUE outcome (revoke-pending after a lost
|
|
386
|
+
# response) without a deterministic remote identity can
|
|
387
|
+
# NEVER be confirmed settled — the retry is refused until
|
|
388
|
+
# reconcile or the provider's native expiry settles it. A
|
|
389
|
+
# PREPARING record (issuer died mid-flight, nothing ever
|
|
390
|
+
# delivered) is settled best-effort through the provider
|
|
391
|
+
# below; only a confirmed outcome lets the retry proceed.
|
|
392
|
+
if record["state"] == "revoke-pending" && record["token_ref"].to_s.empty?
|
|
393
|
+
raise Refusal.new("a previous issue for task '#{task}' assignment '#{assignment}' " \
|
|
394
|
+
"profile '#{profile}' has an unresolved remote outcome (lease '#{record["id"]}', " \
|
|
395
|
+
"no deterministic remote identity); it was NOT re-minted — run reconcile, wait " \
|
|
396
|
+
"for the provider's native expiry, or close it explicitly before requesting again")
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
handoff = CredentialHandoff.new(@credentials_dir, recipient: record["principal"] || principal)
|
|
400
|
+
outcome = cleanup(record, record_profile, handoff)
|
|
401
|
+
next if %i[revoked already_invalid cleared].include?(outcome)
|
|
402
|
+
|
|
403
|
+
raise Refusal.new("a previous issue for task '#{task}' assignment '#{assignment}' " \
|
|
404
|
+
"profile '#{profile}' has an unresolved remote outcome (lease '#{record["id"]}', " \
|
|
405
|
+
"state #{record["state"]}); it was NOT re-minted — run reconcile, wait for the " \
|
|
406
|
+
"provider's native expiry, or close it explicitly before requesting again")
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Revocation hands the provider the ISSUED secret from the
|
|
411
|
+
# ephemeral handoff when the file still exists — GitHub's
|
|
412
|
+
# installation-token endpoint revokes the token that
|
|
413
|
+
# authenticates the request, so the token VALUE is required
|
|
414
|
+
# there; id-revoking providers ignore it.
|
|
415
|
+
def cleanup(lease, profile, handoff)
|
|
416
|
+
provider = Providers.resolve(profile["provider"])
|
|
417
|
+
credential = handoff.path(lease["id"])
|
|
418
|
+
token = File.file?(credential) ? File.read(credential) : nil
|
|
419
|
+
begin
|
|
420
|
+
outcome = provider.revoke(profile: profile, token_ref: lease["token_ref"],
|
|
421
|
+
token: token)
|
|
422
|
+
rescue Providers::TransientError, Providers::OutcomeUnknownError => e
|
|
423
|
+
@store.update(lease["id"]) { |record| record["state"] = "revoke-pending" }
|
|
424
|
+
return :"revoke-pending (#{e.class})"
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
handoff.clear(lease["id"])
|
|
428
|
+
@store.update(lease["id"]) do |record|
|
|
429
|
+
record["state"] = "revoked"
|
|
430
|
+
record["cleanup"] = {"result" => outcome.to_s, "at" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}
|
|
431
|
+
end
|
|
432
|
+
outcome
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def profile_for(name)
|
|
436
|
+
profile = @config.dig("profiles", name)
|
|
437
|
+
raise Refusal.new("unknown auth profile '#{name}'") if profile.nil?
|
|
438
|
+
|
|
439
|
+
profile
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def public_view(lease)
|
|
443
|
+
lease.reject { |key, _| key == "token_ref" }
|
|
444
|
+
.merge("live" => @store.live?(lease))
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def load_config
|
|
448
|
+
content = Molecules::GrantResolver.read_verified(@authorization_path)
|
|
449
|
+
document = content ? YAML.safe_load(content, permitted_classes: [Date], aliases: true) : {}
|
|
450
|
+
ProfileSchema.normalize(document.is_a?(Hash) ? document : {})
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
class Refusal < Error; end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
data/lib/ace/lab/auth.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "auth/profile_schema"
|
|
4
|
+
require_relative "auth/lease_store"
|
|
5
|
+
require_relative "auth/providers"
|
|
6
|
+
require_relative "auth/credential_handoff"
|
|
7
|
+
require_relative "auth/plugins/env_provider"
|
|
8
|
+
require_relative "auth/plugins/forgejo_provider"
|
|
9
|
+
require_relative "auth/plugins/github_provider"
|
|
10
|
+
require_relative "auth/service"
|
|
11
|
+
|
|
12
|
+
module Ace
|
|
13
|
+
module Lab
|
|
14
|
+
# ace-lab auth (task 8xs.t.pu0.7): short-lived, role-scoped access
|
|
15
|
+
# for delegated work. The public contract and lease lifecycle live
|
|
16
|
+
# here; provider protocols live in plugins. Explicit registration
|
|
17
|
+
# of the bundled plugins — the same precedented pattern as the
|
|
18
|
+
# ace-git provider registry.
|
|
19
|
+
module Auth
|
|
20
|
+
Providers.register("env", Plugins::EnvProvider.new)
|
|
21
|
+
Providers.register("forgejo", Plugins::ForgejoProvider.new)
|
|
22
|
+
Providers.register("github", Plugins::GithubProvider.new)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lab
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# `ace-lab auth request|status|exec|revoke|reconcile` (task
|
|
10
|
+
# 8xs.t.pu0.7): the public short-lived-access interface. A
|
|
11
|
+
# single structured JSON report; refusals and errors are
|
|
12
|
+
# non-zero with a reason and the escalation addressee; NO
|
|
13
|
+
# command ever prints a token or exports one to shell history.
|
|
14
|
+
# status is the read-only readiness view (alongside ace-lab
|
|
15
|
+
# verify).
|
|
16
|
+
class Auth < Ace::Support::Cli::Command
|
|
17
|
+
include Ace::Support::Cli::Base
|
|
18
|
+
|
|
19
|
+
desc "Short-lived role access: request, status, exec, revoke, reconcile"
|
|
20
|
+
|
|
21
|
+
argument :action, required: true, desc: "request | status | exec | revoke | reconcile"
|
|
22
|
+
argument :target, required: false, desc: "Lease id (status/exec/revoke)"
|
|
23
|
+
|
|
24
|
+
option :profile, type: :string, desc: "Auth profile name (request)"
|
|
25
|
+
option :role, type: :string, desc: "Delegation role (request)"
|
|
26
|
+
option :task, type: :string, desc: "Task reference (request)"
|
|
27
|
+
option :assignment, type: :string, desc: "Assignment reference (request)"
|
|
28
|
+
option :repo, type: :string, repeat: true, desc: "Repository (request, repeatable)"
|
|
29
|
+
option :ttl, type: :integer, desc: "Requested TTL minutes (request)"
|
|
30
|
+
option :cmd, type: :string, repeat: true,
|
|
31
|
+
desc: "Exec command, one argument per repeat, in order (exec)"
|
|
32
|
+
option :force, type: :boolean, default: false,
|
|
33
|
+
desc: "Close a revoke-pending lease whose credential is gone (operator-forced terminal; revoke)"
|
|
34
|
+
option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
|
|
35
|
+
|
|
36
|
+
def call(action:, target: nil, **options)
|
|
37
|
+
require_relative "../../auth"
|
|
38
|
+
service = ::Ace::Lab::Auth::Service.new
|
|
39
|
+
|
|
40
|
+
result = case action
|
|
41
|
+
when "request"
|
|
42
|
+
require_options!(options, :profile, :role, :task, :assignment)
|
|
43
|
+
service.request(profile_name: options[:profile], role: options[:role],
|
|
44
|
+
task: options[:task], assignment: options[:assignment],
|
|
45
|
+
repos: Array(options[:repo]), ttl_minutes: options[:ttl])
|
|
46
|
+
when "status"
|
|
47
|
+
service.status(lease_id: target)
|
|
48
|
+
when "exec"
|
|
49
|
+
raise Ace::Support::Cli::Error.new("exec requires a lease id and a command") if target.nil?
|
|
50
|
+
|
|
51
|
+
command = Array(options[:cmd])
|
|
52
|
+
raise Ace::Support::Cli::Error.new("exec requires a command (--cmd, one argument per repeat)") if command.empty?
|
|
53
|
+
|
|
54
|
+
service.exec(lease_id: target, argv: command)
|
|
55
|
+
when "revoke"
|
|
56
|
+
raise Ace::Support::Cli::Error, "revoke requires a lease id" if target.nil?
|
|
57
|
+
|
|
58
|
+
{"lease" => target, "cleanup" => service.revoke(lease_id: target,
|
|
59
|
+
force: options[:force]).to_s}
|
|
60
|
+
when "reconcile"
|
|
61
|
+
{"reconciled" => service.reconcile}
|
|
62
|
+
else
|
|
63
|
+
raise Ace::Support::Cli::Error,
|
|
64
|
+
"unknown auth action '#{action}' (request, status, exec, revoke, reconcile)"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
render(result)
|
|
68
|
+
# The child's REAL exit code is the exec exit code — raised
|
|
69
|
+
# AFTER the single report render, so both outcomes stay
|
|
70
|
+
# visible without duplicating the report.
|
|
71
|
+
if action == "exec" && result["exit"] && !result["exit"].zero?
|
|
72
|
+
raise Ace::Support::Cli::Error.new("exec exited #{result["exit"]}",
|
|
73
|
+
exit_code: result["exit"])
|
|
74
|
+
end
|
|
75
|
+
rescue ::Ace::Lab::Auth::Refusal => e
|
|
76
|
+
raise Ace::Support::Cli::Error.new("refused: #{e.message}")
|
|
77
|
+
rescue ::Ace::Lab::Auth::Error => e
|
|
78
|
+
raise Ace::Support::Cli::Error.new(e.message)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def require_options!(options, *keys)
|
|
84
|
+
missing = keys.reject { |key| options[key] && !options[key].to_s.strip.empty? }
|
|
85
|
+
unless missing.empty?
|
|
86
|
+
raise Ace::Support::Cli::Error.new("missing required options: #{missing.map { |k| "--#{k}" }.join(' ')}")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def render(result)
|
|
91
|
+
# A single structured JSON report: the data is the interface
|
|
92
|
+
# agents consume (no table form exists for auth reports).
|
|
93
|
+
puts JSON.pretty_generate(result)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
data/lib/ace/lab/cli.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "cli/commands/prepare"
|
|
|
13
13
|
require_relative "cli/commands/project_add"
|
|
14
14
|
require_relative "cli/commands/install"
|
|
15
15
|
require_relative "cli/commands/verify"
|
|
16
|
+
require_relative "cli/commands/auth"
|
|
16
17
|
|
|
17
18
|
module Ace
|
|
18
19
|
module Lab
|
|
@@ -33,7 +34,8 @@ module Ace
|
|
|
33
34
|
["prepare", "Prepare accounts, directories, permissions, keys and roles from the trusted install manifest"],
|
|
34
35
|
["project add", "Register a project and make it ready for team work (repository, _overseer, lab config)"],
|
|
35
36
|
["install", "Perform the full new-lab installation from the trusted install manifest"],
|
|
36
|
-
["verify", "Verify the installed lab's readiness under the target accounts (read-only)"]
|
|
37
|
+
["verify", "Verify the installed lab's readiness under the target accounts (read-only)"],
|
|
38
|
+
["auth", "Short-lived role access: request, status, exec, revoke, reconcile"]
|
|
37
39
|
].freeze
|
|
38
40
|
|
|
39
41
|
HELP_EXAMPLES = [
|
|
@@ -67,6 +69,7 @@ module Ace
|
|
|
67
69
|
register "project add", CLI::Commands::ProjectAdd.new
|
|
68
70
|
register "install", CLI::Commands::Install.new
|
|
69
71
|
register "verify", CLI::Commands::Verify.new
|
|
72
|
+
register "auth", CLI::Commands::Auth.new
|
|
70
73
|
|
|
71
74
|
# Register version command
|
|
72
75
|
version_cmd = Ace::Support::Cli::VersionCommand.build(
|