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