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,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+ require "fileutils"
6
+ require "digest"
7
+ require "time"
8
+
9
+ module Ace
10
+ module Lab
11
+ module Auth
12
+ # The ONE owner of the minimal lease record (task 8xs.t.pu0.7):
13
+ # one JSON file per lease under the trusted leases dir (default
14
+ # /var/lib/lab/ace-lab/auth/leases), private to the issuer. The
15
+ # record carries the correlation (task/assignment/profile/role/
16
+ # principal), the provider TOKEN REFERENCE (never the secret
17
+ # value), the deadlines and the cleanup outcome — never task
18
+ # progress, queues, or another tool's state.
19
+ #
20
+ # Lifecycle: a record is born PREPARING (issue in flight — a hard
21
+ # process death in that window leaves a recoverable record, never
22
+ # a phantom "live" grant); it becomes ISSUED only after a
23
+ # complete, usable credential handoff; an unresolved remote
24
+ # outcome (lost response, lost revoke) is REVOKE-PENDING until
25
+ # reconciled; REVOKED is terminal. Every write is atomic
26
+ # (temporary file + fsync + rename) under a STABLE sidecar write
27
+ # lock — a crash mid-write can never truncate or half-replace a
28
+ # record, and an unparseable record surfaces as a visible CORRUPT
29
+ # marker instead of silently vanishing from the inventory.
30
+ class LeaseStore
31
+ STATES = %w[preparing issued revoke-pending revoked].freeze
32
+
33
+ attr_reader :dir
34
+
35
+ def initialize(dir)
36
+ @dir = dir
37
+ end
38
+
39
+ # @param id [String, nil] caller-generated lease id (so the
40
+ # provider's deterministic token_ref can be computed from it
41
+ # and written in the SAME atomic creation write)
42
+ # @param token_ref [String, nil] the provider's DETERMINISTIC
43
+ # remote identity for this lease, written at creation so an
44
+ # interrupted issue can still be reconciled against the
45
+ # remote side (the response may never have arrived).
46
+ # @return [Hash] the created (PREPARING) lease record
47
+ def create(profile:, role:, principal:, task:, assignment:, repos:,
48
+ use_until:, provider_expiry:, token_ref: nil, id: nil)
49
+ lease = {"id" => id || "lease-#{SecureRandom.hex(8)}",
50
+ "profile" => profile, "role" => role, "principal" => principal,
51
+ "task" => task, "assignment" => assignment, "repos" => repos.sort,
52
+ "state" => "preparing", "requested_at" => now,
53
+ "use_until" => use_until, "provider_expiry" => provider_expiry,
54
+ "token_ref" => token_ref, "cleanup" => nil}
55
+ write(lease)
56
+ lease
57
+ end
58
+
59
+ # Idempotency: the LIVE lease for the same task/assignment/
60
+ # profile/role correlation, if any. Only an ISSUED record is
61
+ # live: preparing (issue in flight or dead mid-issue) and
62
+ # revoke-pending (unresolved remote outcome) records are never
63
+ # returned — recovery settles them instead. A finished or
64
+ # scope-changed correlation does not match, and
65
+ # DEADLINE-AWARENESS is part of the match: an issued record
66
+ # past its use_until/provider expiry is not returned, so a
67
+ # retry mints a fresh grant while reconcile retires the expired
68
+ # one — an expired grant is never handed back as "live".
69
+ def find_live(task:, assignment:, profile:, role:)
70
+ all.filter_map do |lease|
71
+ next unless lease["task"] == task && lease["assignment"] == assignment &&
72
+ lease["profile"] == profile && lease["role"] == role &&
73
+ lease["state"] == "issued"
74
+ lease if live?(lease)
75
+ end.min_by { |lease| lease["requested_at"].to_s }
76
+ end
77
+
78
+ def load(id)
79
+ path = lease_path(id)
80
+ raise Error, "unknown lease '#{id}'" unless File.file?(path)
81
+
82
+ JSON.parse(File.read(path))
83
+ rescue JSON::ParserError
84
+ raise Error, "lease record '#{id}' is unreadable"
85
+ end
86
+
87
+ # The full inventory, including CORRUPT records as explicit
88
+ # markers: a record that cannot be parsed is a visible recovery
89
+ # problem (the remote token may still be live) — never a
90
+ # silently skipped entry that disappears from reconcile.
91
+ def all
92
+ return [] unless Dir.exist?(@dir)
93
+
94
+ Dir.glob(File.join(@dir, "*.json")).sort.filter_map do |path|
95
+ JSON.parse(File.read(path))
96
+ rescue Errno::EACCES
97
+ nil
98
+ rescue JSON::ParserError
99
+ {"id" => File.basename(path, ".json"), "state" => nil,
100
+ "corrupt" => true, "_path" => path}
101
+ end
102
+ end
103
+
104
+ # Atomic update under a STABLE sidecar write lock: the record is
105
+ # read, modified and replaced via temporary-file + fsync +
106
+ # rename. A crash at any point leaves either the old or the new
107
+ # complete record — never a truncated file — and the lock
108
+ # survives the replacement (it is not held on the record inode).
109
+ def update(id)
110
+ path = lease_path(id)
111
+ with_write_lock(id) do
112
+ lease = begin
113
+ JSON.parse(File.read(path))
114
+ rescue JSON::ParserError, Errno::ENOENT => e
115
+ # A corrupt/missing record is a named store failure, never a
116
+ # raw parser error escaping through the update seam.
117
+ raise Error, "lease '#{id}' cannot be read at #{path}: #{e.class} #{e.message}"
118
+ end
119
+ yield(lease)
120
+ atomic_write(path, lease)
121
+ lease
122
+ end
123
+ end
124
+
125
+ def write(lease)
126
+ atomic_write(lease_path(lease["id"]), lease)
127
+ lease
128
+ end
129
+
130
+ def with_write_lock(id)
131
+ FileUtils.mkdir_p(@dir)
132
+ File.open(File.join(@dir, "#{id}.write.lock"), File::CREAT | File::RDWR) do |lock|
133
+ lock.flock(File::LOCK_EX)
134
+ yield
135
+ end
136
+ end
137
+
138
+ # One request flow at a time per correlation (task/assignment/
139
+ # profile/role): a BLOCKING exclusive lock on the correlation's
140
+ # lockfile so the find_live + create + issue sequence is atomic
141
+ # against a concurrent identical request — the second caller
142
+ # waits and then returns the first caller's live lease.
143
+ def with_correlation_lock(task:, assignment:, profile:, role:)
144
+ FileUtils.mkdir_p(@dir)
145
+ key = Digest::SHA256.hexdigest([task, assignment, profile, role].join("\e"))
146
+ File.open(File.join(@dir, "correlation-#{key}.lock"), File::CREAT | File::RDWR) do |lock|
147
+ lock.flock(File::LOCK_EX)
148
+ yield
149
+ end
150
+ end
151
+
152
+ # One exec at a time per lease: an exclusive lock on the lease's
153
+ # exec lockfile, non-blocking — a parallel exec is a refusal.
154
+ def with_exec_lock(id)
155
+ FileUtils.mkdir_p(@dir)
156
+ File.open(File.join(@dir, "#{id}.exec.lock"), File::CREAT | File::RDWR) do |lock|
157
+ unless lock.flock(File::LOCK_EX | File::LOCK_NB)
158
+ raise Error, "lease '#{id}' already has a running exec"
159
+ end
160
+
161
+ yield
162
+ end
163
+ end
164
+
165
+ # Usable NOW: only an ISSUED record inside its deadlines. A
166
+ # preparing record was never delivered (or the process died
167
+ # mid-issue) and a revoke-pending record has no valid handoff
168
+ # or is mid-revocation — neither is ever usable or "live".
169
+ def live?(lease, at: Time.now)
170
+ return false unless lease["state"] == "issued"
171
+ return false if lease["use_until"] && Time.parse(lease["use_until"]) <= at
172
+ if lease["provider_expiry"] && Time.parse(lease["provider_expiry"]) <= at
173
+ return false
174
+ end
175
+
176
+ true
177
+ end
178
+
179
+ private
180
+
181
+ def atomic_write(path, lease)
182
+ FileUtils.mkdir_p(@dir)
183
+ temporary = "#{path}.#{Process.pid}.tmp"
184
+ File.open(temporary, "w") do |file|
185
+ file.write(JSON.pretty_generate(lease))
186
+ file.flush
187
+ file.fsync
188
+ end
189
+ # 0600 BEFORE the rename: the record never exists at the target
190
+ # path with umask-default permissions, even for an instant.
191
+ FileUtils.chmod(0o600, temporary)
192
+ begin
193
+ File.rename(temporary, path)
194
+ rescue Errno::ENOENT
195
+ FileUtils.mkdir_p(File.dirname(path))
196
+ File.rename(temporary, path)
197
+ end
198
+ end
199
+
200
+ def lease_path(id)
201
+ raise Error, "invalid lease id" unless id.to_s.match?(/\Alease-[a-f0-9]{16}\z/)
202
+
203
+ File.join(@dir, "#{id}.json")
204
+ end
205
+
206
+ def now
207
+ Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Lab
5
+ module Auth
6
+ module Plugins
7
+ # The env provider (task 8xs.t.pu0.7): exposes ONLY the named
8
+ # values of the configured safe sources to the leased child
9
+ # process. The host's whole environment and inherited
10
+ # credential overrides are never passed through; a standalone
11
+ # env profile declares process-scoped exposure only (the schema
12
+ # rejects it for profiles demanding remote expiry/revoke).
13
+ # When env is the HANDOFF of a provider lease, the same file
14
+ # carries that provider's token — this class is the standalone
15
+ # case.
16
+ class EnvProvider
17
+ def capabilities
18
+ {"expiry_mode" => "process"}
19
+ end
20
+
21
+ # @param issuer_env [Hash] the issuer process environment (the
22
+ # trusted values the profile names)
23
+ def initialize(issuer_env: ENV.to_h)
24
+ @issuer_env = issuer_env
25
+ end
26
+
27
+ # Deterministic remote identity: the exposure is process-
28
+ # scoped, so the reference is fixed and revocation is local.
29
+ def token_ref_for(lease_id:)
30
+ "env:#{lease_id}"
31
+ end
32
+
33
+ def issue(profile:, lease:)
34
+ names = profile.fetch("env_names")
35
+ raise Providers::RefusedError,
36
+ "the env profile declares no env_names" if names.empty?
37
+
38
+ values = names.to_h do |name|
39
+ value = @issuer_env[name]
40
+ raise Providers::RefusedError,
41
+ "issuer environment does not provide '#{name}'" if value.nil? || value.empty?
42
+
43
+ [name, value]
44
+ end
45
+
46
+ {"token" => JSON.generate(values), "token_ref" => "env:#{lease["id"]}",
47
+ "provider_expiry" => nil,
48
+ "via" => {"kind" => "env", "names" => names.sort}}
49
+ end
50
+
51
+ def revoke(profile:, token_ref:, token: nil)
52
+ # Process-scoped exposure ends with the process; there is no
53
+ # remote state to revoke and never a claimed remote expiry.
54
+ :revoked
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+ require "open3"
6
+ require "tempfile"
7
+
8
+ module Ace
9
+ module Lab
10
+ module Auth
11
+ module Plugins
12
+ # Forgejo provider (task 8xs.t.pu0.7): one SEPARATE scoped token
13
+ # per lease, issued on the configured role account through the
14
+ # TOKEN-AUTHENTICATED ADMIN API, limited to the profile's
15
+ # repositories and scopes; revocation deletes that EXACT token
16
+ # name — never a prefix wipe and never other tasks' tokens.
17
+ #
18
+ # Wire contract (PR #398 re-review R1): the issuer credential is
19
+ # an admin PAT, so both endpoints are the admin variants
20
+ # (`POST/DELETE /api/v1/admin/users/{account}/tokens`) — the
21
+ # user-scoped `/api/v1/users/.../tokens` requires BasicAuth or
22
+ # reverse-proxy auth and does not accept `Authorization: token`.
23
+ # Repository scoping uses the server's RepoTargetOption shape:
24
+ # objects with `owner` and `name`. A repo entry without an
25
+ # owner does not identify anything and is REFUSED before any
26
+ # request — never an all-repositories token. The creation
27
+ # response's granted scopes are validated against the request
28
+ # when the server reports them; a mismatch is a classified
29
+ # failure, never a trusted grant. Forgejo tokens carry no proven
30
+ # native TTL across versions, so the profile's expiry comes
31
+ # from the host's scheduled revoke (expiry_mode host-revoke).
32
+ #
33
+ # The token NAME is deterministic from the lease id
34
+ # (`ace-lab-<lease id>`): it is written to the lease record at
35
+ # CREATION, so an interrupted issue (lost response, dead
36
+ # process) can still be settled deterministically — the name is
37
+ # reconstructed and revoked; a 404 means it never existed
38
+ # remotely (:already_invalid, an idempotent result).
39
+ #
40
+ # Transport is an injectable seam (argv runner/capture shapes):
41
+ # tests pin the wire contract with fakes emulating the server;
42
+ # the default runs `curl` with the issuer token from the host's
43
+ # protected store. The issuer secret is read from its file
44
+ # reference and never appears in argv of the CHILD.
45
+ class ForgejoProvider
46
+ def initialize(runner: nil, capture: nil)
47
+ @runner = runner || method(:run_curl)
48
+ @capture = capture || method(:run_capture)
49
+ end
50
+
51
+ def capabilities
52
+ {"expiry_mode" => "host-revoke"}
53
+ end
54
+
55
+ # Deterministic remote identity: the Forgejo token name is
56
+ # derived from the lease id alone — known before any response.
57
+ def token_ref_for(lease_id:)
58
+ "ace-lab-#{lease_id}"
59
+ end
60
+
61
+ def issue(profile:, lease:)
62
+ issuer = profile.fetch("issuer")
63
+ url = issuer["url"] or raise Providers::RefusedError, "forgejo profile has no issuer url"
64
+ account = issuer["account"] or raise Providers::RefusedError, "forgejo profile has no role account"
65
+ token_file = issuer["token_file"] or raise Providers::RefusedError,
66
+ "forgejo profile has no issuer token_file reference"
67
+ raise Providers::RefusedError, "issuer token file '#{token_file}' is not readable" unless File.readable?(token_file)
68
+
69
+ token_name = token_ref_for(lease_id: lease["id"])
70
+ scopes = profile.fetch("scopes")
71
+ repositories = repo_targets(lease.fetch("repos"))
72
+
73
+ payload = JSON.generate({
74
+ "name" => token_name,
75
+ "scopes" => scopes,
76
+ "repositories" => repositories
77
+ }.compact)
78
+ result = with_secret_header("Authorization: token #{File.read(token_file).strip}") do |header|
79
+ @runner.call(
80
+ ["curl", "-sS", "-f", "-X", "POST",
81
+ "#{url}/api/v1/admin/users/#{account}/tokens",
82
+ "-H", "Content-Type: application/json",
83
+ *header,
84
+ "-d", payload]
85
+ )
86
+ end
87
+ body = JSON.parse(result)
88
+ secret = body["sha1"] or raise Providers::TransientError,
89
+ "forgejo did not return the token secret (check server API version)"
90
+ validate_granted_scopes(body, scopes)
91
+
92
+ {"token" => secret, "token_ref" => token_name,
93
+ "provider_expiry" => nil,
94
+ "via" => {"kind" => "file"}}
95
+ rescue JSON::ParserError
96
+ raise Providers::TransientError, "forgejo returned an unparseable response"
97
+ end
98
+
99
+ # The lease token is revoked by its server id (the
100
+ # deterministic token NAME) with the issuer's admin
101
+ # credential; the secret value is not part of this wire
102
+ # contract. 404 is the idempotent already-invalid result —
103
+ # deterministic recovery of an interrupted issue.
104
+ def revoke(profile:, token_ref:, token: nil)
105
+ issuer = profile.fetch("issuer")
106
+ url = issuer["url"] or raise Providers::RefusedError, "forgejo profile has no issuer url"
107
+ account = issuer["account"] or raise Providers::RefusedError, "forgejo profile has no role account"
108
+ token_file = issuer["token_file"]
109
+ _out, err, status = with_secret_header("Authorization: token #{File.read(token_file).strip}") do |header|
110
+ @capture.call(
111
+ ["curl", "-sS", "-f", "-X", "DELETE",
112
+ "#{url}/api/v1/admin/users/#{account}/tokens/#{token_ref}",
113
+ *header]
114
+ )
115
+ end
116
+ return :revoked if status.success?
117
+ return :already_invalid if err.to_s.match?(/\b404\b/)
118
+
119
+ raise Providers::TransientError,
120
+ "forgejo revoke failed: #{err.to_s.lines.first&.strip}"
121
+ end
122
+
123
+ private
124
+
125
+ # RepoTargetOption: {"owner" =>, "name" =>} objects parsed
126
+ # from `owner/name` entries. An entry without an owner does
127
+ # not identify a repository — refusing beats issuing an
128
+ # all-repositories token.
129
+ def repo_targets(repos)
130
+ if repos.empty?
131
+ raise Providers::RefusedError,
132
+ "an empty repositories payload would mint a token for ALL of the " \
133
+ "account's repositories — refuse before issuing"
134
+ end
135
+
136
+ repos.map do |entry|
137
+ owner, name = entry.to_s.split("/", 2)
138
+ if owner.to_s.empty? || name.to_s.empty?
139
+ raise Providers::RefusedError,
140
+ "repository '#{entry}' is not an identifiable 'owner/name' target — " \
141
+ "Forgejo RepoTargetOption requires both; refusing instead of issuing " \
142
+ "an all-repositories token"
143
+ end
144
+ {"owner" => owner, "name" => name}
145
+ end
146
+ end
147
+
148
+ # The response's granted scopes (when the server reports
149
+ # them) must cover the request — the enforced scope is the
150
+ # server's answer, never the request's claim.
151
+ def validate_granted_scopes(body, requested)
152
+ granted = body["scopes"]
153
+ return unless granted.is_a?(Array)
154
+
155
+ missing = requested.map(&:to_s) - granted.map(&:to_s)
156
+ unless missing.empty?
157
+ raise Providers::TransientError,
158
+ "forgejo granted scopes [#{granted.join(', ')}] do not cover the " \
159
+ "requested [#{missing.join(', ')}]; refusing the grant"
160
+ end
161
+ end
162
+
163
+ # The Authorization secret NEVER appears in curl argv (visible
164
+ # in ps): the header value lives in a 0600 ephemeral file for
165
+ # the duration of one request, passed as `curl -H @file`.
166
+ def with_secret_header(header)
167
+ Tempfile.create("ace-lab-curl-header") do |file|
168
+ file.chmod(0o600)
169
+ file.write("#{header}\n")
170
+ file.close
171
+ yield ["-H", "@#{file.path}"]
172
+ end
173
+ end
174
+
175
+ def run_curl(argv)
176
+ out, _err, status = run_capture(argv)
177
+ raise Providers::TransientError, "forgejo API call failed (curl exit #{status.exitstatus})" unless status.success?
178
+
179
+ out
180
+ end
181
+
182
+ def run_capture(argv)
183
+ Open3.capture3(*argv)
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+ require "base64"
6
+ require "tempfile"
7
+
8
+ module Ace
9
+ module Lab
10
+ module Auth
11
+ module Plugins
12
+ # GitHub provider (task 8xs.t.pu0.7): GitHub App JWT →
13
+ # installation access token for the CHOSEN repositories and
14
+ # permissions; the response's real `expires_at` becomes the
15
+ # provider expiry the status reports separately from the
16
+ # lease's use-until. The App key NEVER reaches the executor;
17
+ # the issued token is opaque, one per lease. An earlier
18
+ # use-until than the native expiry requires the host's
19
+ # scheduled revoke at use_until (DELETE /installation/token —
20
+ # 401 means the token is already invalid, anything else
21
+ # network-shaped stays outcome-unknown, never a claimed
22
+ # revocation).
23
+ class GithubProvider
24
+ def initialize(runner: nil, capture: nil)
25
+ @runner = runner || method(:run_curl)
26
+ @capture = capture || method(:run_capture)
27
+ end
28
+
29
+ def capabilities
30
+ {"expiry_mode" => "provider"}
31
+ end
32
+
33
+ def issue(profile:, lease:)
34
+ issuer = profile.fetch("issuer")
35
+ base = issuer["api_url"] || "https://api.github.com"
36
+ installation = issuer["installation_id"] or raise Providers::RefusedError,
37
+ "github profile has no installation_id"
38
+ key_file = issuer["key_file"] or raise Providers::RefusedError, "github profile has no key_file reference"
39
+ app_id = issuer["app_id"] or raise Providers::RefusedError, "github profile has no app_id"
40
+ raise Providers::RefusedError, "app key file '#{key_file}' is not readable" unless File.readable?(key_file)
41
+
42
+ jwt = app_jwt(app_id, File.read(key_file))
43
+ repos = lease.fetch("repos")
44
+ if repos.empty?
45
+ raise Providers::RefusedError,
46
+ "an empty repository list would mint a token for ALL installation " \
47
+ "repositories — refuse before issuing"
48
+ end
49
+
50
+ payload = JSON.generate({"repositories" => repos,
51
+ "permissions" => github_permissions(profile.fetch("scopes"))}.compact)
52
+ result = with_secret_header("Authorization: Bearer #{jwt}") do |header|
53
+ @runner.call(
54
+ ["curl", "-sS", "-f", "-X", "POST",
55
+ "#{base}/app/installations/#{installation}/access_tokens",
56
+ "-H", "Accept: application/vnd.github+json",
57
+ *header,
58
+ "-d", payload]
59
+ )
60
+ end
61
+ body = JSON.parse(result)
62
+ token = body["token"] or raise Providers::TransientError, "github did not return an installation token"
63
+ {"token" => token, "token_ref" => body["id"].to_s,
64
+ "provider_expiry" => body["expires_at"],
65
+ "via" => {"kind" => "file"}}
66
+ rescue JSON::ParserError
67
+ raise Providers::TransientError, "github returned an unparseable response"
68
+ end
69
+
70
+ # GitHub revokes the installation token that AUTHENTICATES
71
+ # the request (DELETE /installation/token -> 204): the
72
+ # credential is the ISSUED lease token, never a fresh App
73
+ # JWT - an App JWT names no installation token and revokes
74
+ # nothing. curl runs with --fail so an HTTP error can never
75
+ # surface as process success; 401 means the token is already
76
+ # invalid (an idempotent result), anything else
77
+ # network-shaped stays outcome-unknown, never a claimed
78
+ # revocation.
79
+ def revoke(profile:, token_ref:, token: nil)
80
+ if token.to_s.empty?
81
+ raise Providers::OutcomeUnknownError,
82
+ "the issued installation token (token_ref #{token_ref}) is required to revoke " \
83
+ "it - GitHub revokes the token that authenticates the request; outcome unknown"
84
+ end
85
+
86
+ issuer = profile.fetch("issuer")
87
+ base = issuer["api_url"] || "https://api.github.com"
88
+ _out, err, status = with_secret_header("Authorization: Bearer #{token}") do |header|
89
+ @capture.call(
90
+ ["curl", "-sS", "-f", "-X", "DELETE",
91
+ "#{base}/installation/token",
92
+ "-H", "Accept: application/vnd.github+json",
93
+ *header]
94
+ )
95
+ end
96
+ return :revoked if status.success?
97
+
98
+ if err.to_s.match?(/\b401\b/)
99
+ # 401 on the installation endpoint means the token is
100
+ # already invalid — an idempotent result, not a failure.
101
+ return :already_invalid
102
+ end
103
+
104
+ raise Providers::OutcomeUnknownError,
105
+ "github revoke outcome unknown: #{err.to_s.lines.first&.strip}"
106
+ end
107
+
108
+ # No deterministic remote identity: the installation token's
109
+ # id is only known from the creation RESPONSE — an unknown
110
+ # issue outcome cannot be settled by reconstruction and must
111
+ # wait for reconcile/native expiry, never a blind re-mint.
112
+ def token_ref_for(lease_id:)
113
+ nil
114
+ end
115
+
116
+ # GitHub's OWN permission schema for installation tokens —
117
+ # these are the real permission keys of the
118
+ # POST /app/installations/{id}/access_tokens contract (NOT
119
+ # Forgejo repository rights): blanket-mapping one provider's
120
+ # subjects onto the other silently produces permission keys
121
+ # the endpoint rejects or ignores.
122
+ INSTALLATION_PERMISSIONS = %w[
123
+ actions administration checks content_references contents
124
+ deployments environments issues labels members merge_queues
125
+ metadata organization_administration organization_custom_org_roles
126
+ organization_custom_properties organization_hooks
127
+ organization_packages organization_personal_access_requests
128
+ organization_personal_access_tokens organization_plan
129
+ organization_projects organization_rulesets organization_secrets
130
+ organization_self_hosted_runners organization_user_blocking
131
+ packages pages pull_requests repository_advisories
132
+ repository_hooks repository_projects secret_scanning_alerts
133
+ secrets security_events statuses team_discussions
134
+ vulnerability_alerts workflows
135
+ ].freeze
136
+ PERMISSION_LEVELS = %w[read write admin].freeze
137
+
138
+ private
139
+
140
+ # The Authorization secret NEVER appears in curl argv (visible
141
+ # in ps): the header value lives in a 0600 ephemeral file for
142
+ # the duration of one request, passed as `curl -H @file`.
143
+ def with_secret_header(header)
144
+ Tempfile.create("ace-lab-curl-header") do |file|
145
+ file.chmod(0o600)
146
+ file.write("#{header}\n")
147
+ file.close
148
+ yield ["-H", "@#{file.path}"]
149
+ end
150
+ end
151
+
152
+ # Profile scopes map onto installation token permissions as
153
+ # `level:subject` strings validated against GitHub's actual
154
+ # permission schema (e.g. `write:contents` →
155
+ # {"contents" => "write"}); the deployment's App installation
156
+ # must already grant them — the request cannot widen rights.
157
+ def github_permissions(scopes)
158
+ scopes.to_h do |scope|
159
+ level, subject = scope.split(":", 2)
160
+ subject, level = scope, "read" if subject.nil?
161
+ if subject.empty? || level.empty? || !PERMISSION_LEVELS.include?(level)
162
+ raise Providers::RefusedError,
163
+ "github scope '#{scope}' is malformed (expected level:subject with " \
164
+ "level in #{PERMISSION_LEVELS.join('|')}, e.g. write:contents)"
165
+ end
166
+ unless INSTALLATION_PERMISSIONS.include?(subject)
167
+ raise Providers::RefusedError,
168
+ "github scope '#{scope}': '#{subject}' is not a GitHub installation " \
169
+ "permission (real examples: contents, pull_requests, members, issues) " \
170
+ "— GitHub permission names are not Forgejo repository scopes"
171
+ end
172
+
173
+ [subject, level]
174
+ end
175
+ end
176
+
177
+ # Minimal RS256 GitHub App JWT (stdlib only): the App key
178
+ # stays issuer-side; the JWT lives for one request window.
179
+ def app_jwt(app_id, key_pem)
180
+ key = OpenSSL::PKey::RSA.new(key_pem)
181
+ now = Time.now.to_i
182
+ header = Base64.urlsafe_encode64(JSON.generate({"alg" => "RS256", "typ" => "JWT"}),
183
+ padding: false)
184
+ claims = Base64.urlsafe_encode64(JSON.generate({
185
+ "iat" => now - 60, "exp" => now + 540, "iss" => app_id
186
+ }), padding: false)
187
+ signature = Base64.urlsafe_encode64(key.sign("SHA256", "#{header}.#{claims}"),
188
+ padding: false)
189
+ "#{header}.#{claims}.#{signature}"
190
+ end
191
+
192
+ def run_curl(argv)
193
+ out, _err, status = run_capture(argv)
194
+ raise Providers::TransientError, "github API call failed (exit #{status.exitstatus})" unless status.success?
195
+
196
+ out
197
+ end
198
+
199
+ def run_capture(argv)
200
+ require "open3"
201
+ Open3.capture3(*argv)
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end