ace-lab 0.13.0 → 0.14.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,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Lab
5
+ module Auth
6
+ # Pure validation/normalization of the trusted auth configuration
7
+ # (task 8xs.t.pu0.7): the host-owned `auth:` section of the same
8
+ # root-owned authorization document install converges
9
+ # (`/etc/lab/ace-lab/authorization.yml`). The existing `principals`
10
+ # map stays a VISIBILITY map — it never grants token issuance; only
11
+ # an explicit auth profile does, and a project/coworker config can
12
+ # only NAME a profile, never widen one.
13
+ #
14
+ # A profile declares: provider (forgejo|github|env), the roles and
15
+ # principals allowed to request it, repos/scopes, ttl (default 30m,
16
+ # max 60m unless the host sets an explicit profile limit), the
17
+ # expiry mode (provider | host-revoke | process), and REFERENCES
18
+ # to issuer credentials (paths — values stay in the host's
19
+ # protected store). The credential handoff is always the
20
+ # ephemeral per-lease file (CredentialHandoff) — there is no
21
+ # configurable handoff style.
22
+ class ProfileSchema
23
+ # The bundled providers; an explicitly registered plugin (tests,
24
+ # host extensions) resolves through the registry at request
25
+ # time — the schema only demands a concrete provider name.
26
+ BUNDLED_PROVIDERS = %w[forgejo github env].freeze
27
+ EXPIRY_MODES = %w[provider host-revoke process].freeze
28
+ DEFAULT_TTL_MINUTES = 30
29
+ DEFAULT_MAX_TTL_MINUTES = 60
30
+ PROFILE_KEYS = %w[provider roles principals repos scopes ttl_minutes max_ttl_minutes
31
+ expiry_mode issuer env_names].freeze
32
+
33
+ class << self
34
+ # @param document [Hash] parsed authorization document
35
+ # @return [Hash] {"leases_dir"=>, "credentials_dir"=>,
36
+ # "profiles" => {name => normalized profile}}
37
+ # @raise [Ace::Lab::InvalidConfigurationError]
38
+ def normalize(document)
39
+ auth = document["auth"]
40
+ if auth.nil?
41
+ return {"leases_dir" => nil, "credentials_dir" => nil, "profiles" => {}}
42
+ end
43
+ unless auth.is_a?(Hash)
44
+ raise Ace::Lab::InvalidConfigurationError, "auth section must be a mapping"
45
+ end
46
+
47
+ profiles = auth["profiles"] || {}
48
+ unless profiles.is_a?(Hash) && profiles.values.all? { |profile| profile.is_a?(Hash) }
49
+ raise Ace::Lab::InvalidConfigurationError, "auth profiles must be a mapping of profile names to settings"
50
+ end
51
+
52
+ normalized = profiles.to_h { |name, raw| [name, profile(name, raw)] }
53
+ {"leases_dir" => optional_path(auth["leases_dir"]),
54
+ "credentials_dir" => optional_path(auth["credentials_dir"]),
55
+ "profiles" => normalized}
56
+ end
57
+
58
+ private
59
+
60
+ def profile(name, raw)
61
+ unknown = raw.keys - PROFILE_KEYS
62
+ unless unknown.empty?
63
+ raise Ace::Lab::InvalidConfigurationError,
64
+ "auth profile '#{name}' has unknown key '#{unknown.sort.first}'"
65
+ end
66
+
67
+ provider = required_string(raw, name, "provider")
68
+
69
+ roles = string_list(raw["roles"], name, "roles")
70
+ principals = string_list(raw["principals"], name, "principals")
71
+ repos = string_list(raw["repos"], name, "repos")
72
+ ttl = positive_minutes(raw["ttl_minutes"], name, DEFAULT_TTL_MINUTES)
73
+ max_ttl = positive_minutes(raw["max_ttl_minutes"], name, DEFAULT_MAX_TTL_MINUTES)
74
+ raise_conflict(name, "ttl_minutes exceeds max_ttl_minutes") if ttl > max_ttl
75
+
76
+ expiry_mode = raw["expiry_mode"] || default_expiry_mode(provider)
77
+ unless EXPIRY_MODES.include?(expiry_mode)
78
+ raise Ace::Lab::InvalidConfigurationError,
79
+ "auth profile '#{name}' expiry_mode must be one of: #{EXPIRY_MODES.join(', ')}"
80
+ end
81
+ if provider == "env" && expiry_mode != "process" && raw["env_names"] &&
82
+ raw["issuer"].nil?
83
+ # A standalone env profile only exposes process-scoped
84
+ # values; it cannot guarantee remote expiry or revoke.
85
+ raise Ace::Lab::InvalidConfigurationError,
86
+ "auth profile '#{name}': a standalone env profile cannot declare " \
87
+ "remote expiry/revoke (expiry_mode process only)"
88
+ end
89
+
90
+ {"provider" => provider, "roles" => roles, "principals" => principals,
91
+ "repos" => repos, "scopes" => string_list(raw["scopes"], name, "scopes"),
92
+ "ttl_minutes" => ttl, "max_ttl_minutes" => max_ttl,
93
+ "expiry_mode" => expiry_mode,
94
+ "issuer" => raw["issuer"].is_a?(Hash) ? raw["issuer"] : {},
95
+ "env_names" => string_list(raw["env_names"], name, "env_names")}
96
+ end
97
+
98
+ def default_expiry_mode(provider)
99
+ case provider
100
+ when "github" then "provider" # installation tokens carry a native expires_at
101
+ when "forgejo" then "host-revoke" # no native TTL proven across server versions
102
+ else "process"
103
+ end
104
+ end
105
+
106
+ def required_string(raw, name, key)
107
+ value = raw[key]
108
+ unless value.is_a?(String) && !value.strip.empty?
109
+ raise Ace::Lab::InvalidConfigurationError,
110
+ "auth profile '#{name}' key '#{key}' is required"
111
+ end
112
+
113
+ value.strip
114
+ end
115
+
116
+ def string_list(raw, name, key)
117
+ list = raw.nil? ? [] : raw
118
+ unless list.is_a?(Array) && list.all? { |entry| entry.is_a?(String) && !entry.strip.empty? }
119
+ raise Ace::Lab::InvalidConfigurationError,
120
+ "auth profile '#{name}' key '#{key}' must be a list of non-empty strings"
121
+ end
122
+
123
+ list.map(&:strip).uniq
124
+ end
125
+
126
+ def positive_minutes(raw, name, default)
127
+ value = raw.nil? ? default : raw
128
+ unless value.is_a?(Integer) && value.positive?
129
+ raise Ace::Lab::InvalidConfigurationError,
130
+ "auth profile '#{name}' ttl must be a positive number of minutes"
131
+ end
132
+
133
+ value
134
+ end
135
+
136
+ def optional_path(raw)
137
+ return nil if raw.nil?
138
+ unless raw.is_a?(String) && !raw.strip.empty? && raw.start_with?("/")
139
+ raise Ace::Lab::InvalidConfigurationError, "auth paths must be absolute"
140
+ end
141
+
142
+ raw.strip
143
+ end
144
+
145
+ def raise_conflict(name, reason)
146
+ raise Ace::Lab::InvalidConfigurationError, "auth profile '#{name}': #{reason}"
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Auth
8
+ # Explicit provider registration (task 8xs.t.pu0.7) — the same
9
+ # precedented pattern as Ace::Git::Providers and the ace-hitl
10
+ # adapter registry. A plugin owns its protocol; the auth core owns
11
+ # the contract and the lease lifecycle. No marketplace, no
12
+ # generic workflow engine.
13
+ #
14
+ # Plugin contract (duck type):
15
+ # capabilities -> {expiry_mode:} which the profile must accept
16
+ # token_ref_for(lease_id:) -> String | nil — the provider's
17
+ # DETERMINISTIC remote identity for the lease, written to the
18
+ # record at creation; nil when the identity is only knowable
19
+ # from the creation response (an unresolved issue then waits
20
+ # for reconcile/native expiry instead of deterministic
21
+ # settlement)
22
+ # issue(profile:, lease:) ->
23
+ # {"token" => secret (never serialized), "token_ref" => provider id,
24
+ # "provider_expiry" => time-ish | nil, "via" => handoff descriptor}
25
+ # or raises RefusedError / TransientError / OutcomeUnknownError
26
+ # revoke(profile:, token_ref:, token:) -> :revoked | :already_invalid |
27
+ # raises TransientError / OutcomeUnknownError
28
+ # (`token` is the issued secret from the handoff — providers
29
+ # that revoke BY the token value, e.g. GitHub's installation
30
+ # token endpoint, require it; providers revoking by a server
31
+ # id ignore it; a 404-shaped unknown id settles as
32
+ # :already_invalid so interrupted issues recover
33
+ # deterministically)
34
+ class Providers
35
+ class RefusedError < StandardError; end
36
+ class TransientError < StandardError; end
37
+ class OutcomeUnknownError < StandardError; end
38
+ class UnsupportedError < StandardError; end
39
+
40
+ @registry = {}
41
+ @mutex = Mutex.new
42
+
43
+ class << self
44
+ def register(name, plugin)
45
+ @mutex.synchronize { @registry[name.to_s] = plugin }
46
+ end
47
+
48
+ def resolve(name)
49
+ plugin = @mutex.synchronize { @registry[name.to_s] }
50
+ raise UnsupportedError, "no auth provider registered for '#{name}'" unless plugin
51
+
52
+ plugin
53
+ end
54
+
55
+ def reset!
56
+ @mutex.synchronize { @registry.clear }
57
+ end
58
+
59
+ def registered
60
+ @mutex.synchronize { @registry.keys.sort }
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -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