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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +132 -1
- data/docs/usage.md +168 -4
- data/lib/ace/lab/atoms/install_schema.rb +135 -3
- 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/commands/prepare.rb +4 -2
- data/lib/ace/lab/cli/commands/verify.rb +2 -2
- 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/gemset_provisioner.rb +441 -0
- data/lib/ace/lab/molecules/project_locations.rb +62 -0
- data/lib/ace/lab/organisms/install.rb +13 -8
- data/lib/ace/lab/organisms/prepare.rb +44 -4
- data/lib/ace/lab/organisms/verify.rb +200 -2
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +1 -0
- metadata +16 -2
|
@@ -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
|