ace-lab 0.14.1 → 0.15.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,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+ require_relative "error"
5
+
6
+ module Ace
7
+ module Lab
8
+ module Auth
9
+ # One ordinary child process group. Credentials are environment values,
10
+ # never argv. Cleanup runs on normal exit, deadline and interruption.
11
+ # This does not contain a malicious process that leaves its group.
12
+ class LocalCommand
13
+ def call(argv, environment, deadline:)
14
+ remaining = deadline - Time.now
15
+ raise Refusal, "lease expired before execution" unless remaining.positive?
16
+
17
+ pid = Process.spawn(environment, [argv.first, argv.first], *argv.drop(1),
18
+ unsetenv_others: true, pgroup: true)
19
+ begin
20
+ previous = Signal.trap("TERM") { raise SignalException, "TERM" } if Thread.current == Thread.main
21
+ _, status = Timeout.timeout(remaining) { Process.wait2(pid) }
22
+ reaped = true
23
+ status.exitstatus || 128 + status.termsig.to_i
24
+ rescue Timeout::Error
25
+ 124
26
+ ensure
27
+ begin
28
+ reaped = stop_group(pid, reaped: reaped)
29
+ Timeout.timeout(2) { Process.wait(pid) } unless reaped
30
+ rescue Errno::ECHILD
31
+ nil
32
+ rescue SystemCallError, Timeout::Error => e
33
+ raise ExecutionCleanupError, "command process group cleanup is unconfirmed (#{e.class}: #{e.message})"
34
+ ensure
35
+ Signal.trap("TERM", previous) if previous
36
+ end
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def stop_group(pid, reaped:)
43
+ Process.kill("TERM", -pid)
44
+ sleep 0.05
45
+ # Darwin reports EPERM when a group contains only our unreaped
46
+ # zombie. Reap it before checking for surviving descendants.
47
+ begin
48
+ reaped ||= !Process.waitpid(pid, Process::WNOHANG).nil?
49
+ rescue Errno::ECHILD
50
+ # wait2 may have reaped the leader before an interrupt updated
51
+ # our flag. Descendants still need the group KILL below.
52
+ reaped = true
53
+ end
54
+ Process.kill("KILL", -pid)
55
+ reaped
56
+ rescue Errno::ESRCH
57
+ reaped
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -1,48 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "base64"
4
5
  require "openssl"
5
6
  require "open3"
6
7
  require "tempfile"
8
+ require "tmpdir"
9
+ require "fileutils"
10
+ require "uri"
11
+ require "rbconfig"
12
+ require "ipaddr"
13
+ require_relative "../credential_handoff"
7
14
 
8
15
  module Ace
9
16
  module Lab
10
17
  module Auth
11
18
  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.
19
+ # Forgejo 15 requires Basic authentication with an administrator
20
+ # credential and Sudo header on the user-token endpoints. The
21
+ # credential stays in a private, temporary curl header file. A
22
+ # successful issue must prove the returned repository restrictions.
23
+ # Host-owned issuance and timed revocation are separate requirements.
45
24
  class ForgejoProvider
25
+ ASKPASS = File.expand_path("../../../../../libexec/forgejo-askpass", __dir__).freeze
46
26
  def initialize(runner: nil, capture: nil)
47
27
  @runner = runner || method(:run_curl)
48
28
  @capture = capture || method(:run_capture)
@@ -58,36 +38,75 @@ module Ace
58
38
  "ace-lab-#{lease_id}"
59
39
  end
60
40
 
41
+ # fj and ACE's Forgejo HTTP adapters read the same XDG login file.
42
+ # Give this child only its leased token, with no ambient fj aliases
43
+ # or logins, and remove the private directory when it exits.
44
+ def with_exec_environment(profile:, credential:)
45
+ unless linux_xdg?
46
+ raise Providers::RefusedError, "forgejo session projection currently requires Linux XDG data directories"
47
+ end
48
+ issuer = profile["issuer"]
49
+ url = issuer_origin!(issuer)
50
+ host = url.hostname
51
+ authority = host.include?(":") ? "[#{host}]" : host
52
+ authority += ":#{url.port}" if url.port != url.default_port
53
+ account = issuer["account"].to_s
54
+ raise Providers::RefusedError, "forgejo role account is invalid" unless account.match?(/\A[a-zA-Z0-9_.-]+\z/)
55
+ token = begin
56
+ File.read(credential).strip
57
+ rescue SystemCallError => e
58
+ raise Providers::RefusedError, "forgejo lease credential is unreadable (#{e.class.name})"
59
+ end
60
+ raise Providers::RefusedError, "forgejo lease credential is empty" if token.empty?
61
+ prefix = CredentialHandoff.projection_prefix(credential)
62
+ Dir.mktmpdir(prefix, File.dirname(credential)) do |dir|
63
+ login_dir = File.join(dir, "forgejo-cli")
64
+ Dir.mkdir(login_dir, 0o700)
65
+ File.open(File.join(login_dir, "keys.json"), File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
66
+ file.write(JSON.generate({"hosts" => {authority => {"type" => "Application", "token" => token}},
67
+ "aliases" => {}, "default_ssh" => []}))
68
+ end
69
+ yield({"XDG_DATA_HOME" => dir, "GIT_ASKPASS" => ASKPASS, "GIT_TERMINAL_PROMPT" => "0",
70
+ "GIT_CONFIG_COUNT" => "2", "GIT_CONFIG_KEY_0" => "credential.helper", "GIT_CONFIG_VALUE_0" => "",
71
+ "GIT_CONFIG_KEY_1" => "credential.useHttpPath", "GIT_CONFIG_VALUE_1" => "false", "LC_ALL" => "C.UTF-8",
72
+ "ACE_LAB_FORGEJO_ORIGIN" => "#{url.scheme}://#{authority}", "ACE_LAB_FORGEJO_SCHEME" => url.scheme,
73
+ "ACE_LAB_FORGEJO_AUTHORITY" => authority, "ACE_LAB_FORGEJO_ACCOUNT" => account})
74
+ end
75
+ end
76
+
61
77
  def issue(profile:, lease:)
62
- issuer = profile.fetch("issuer")
63
- url = issuer["url"] or raise Providers::RefusedError, "forgejo profile has no issuer url"
78
+ issuer = profile["issuer"]
79
+ url = issuer_origin!(issuer).to_s.chomp("/")
64
80
  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)
81
+ header_value = issuer_header(issuer)
68
82
 
69
83
  token_name = token_ref_for(lease_id: lease["id"])
70
84
  scopes = profile.fetch("scopes")
71
- repositories = repo_targets(lease.fetch("repos"))
85
+ requested_repos = lease.fetch("repos")
86
+ repositories = repo_targets(requested_repos)
72
87
 
73
88
  payload = JSON.generate({
74
89
  "name" => token_name,
75
90
  "scopes" => scopes,
76
91
  "repositories" => repositories
77
92
  }.compact)
78
- result = with_secret_header("Authorization: token #{File.read(token_file).strip}") do |header|
93
+ result = with_secret_header(header_value) do |header|
79
94
  @runner.call(
80
- ["curl", "-sS", "-f", "-X", "POST",
81
- "#{url}/api/v1/admin/users/#{account}/tokens",
95
+ ["curl", "-q", "--connect-timeout", "5", "--max-time", "20", "-sS", "-f", "-X", "POST",
96
+ "#{url}/api/v1/users/#{account}/tokens",
82
97
  "-H", "Content-Type: application/json",
83
98
  *header,
84
99
  "-d", payload]
85
100
  )
86
101
  end
87
102
  body = JSON.parse(result)
88
- secret = body["sha1"] or raise Providers::TransientError,
89
- "forgejo did not return the token secret (check server API version)"
103
+ raise Providers::TransientError, "forgejo returned an invalid token response" unless body.is_a?(Hash)
104
+ secret = body["sha1"]
105
+ unless secret.is_a?(String) && !secret.empty?
106
+ raise Providers::TransientError, "forgejo did not return the token secret (check server API version)"
107
+ end
90
108
  validate_granted_scopes(body, scopes)
109
+ validate_granted_repositories(body, requested_repos)
91
110
 
92
111
  {"token" => secret, "token_ref" => token_name,
93
112
  "provider_expiry" => nil,
@@ -102,19 +121,19 @@ module Ace
102
121
  # contract. 404 is the idempotent already-invalid result —
103
122
  # deterministic recovery of an interrupted issue.
104
123
  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"
124
+ issuer = profile["issuer"]
125
+ url = issuer_origin!(issuer).to_s.chomp("/")
107
126
  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|
127
+ _out, err, status = with_secret_header(issuer_header(issuer)) do |header|
110
128
  @capture.call(
111
- ["curl", "-sS", "-f", "-X", "DELETE",
112
- "#{url}/api/v1/admin/users/#{account}/tokens/#{token_ref}",
129
+ ["curl", "-q", "--connect-timeout", "5", "--max-time", "20", "-sS", "-f", "-X", "DELETE",
130
+ "#{url}/api/v1/users/#{account}/tokens/#{token_ref}",
113
131
  *header]
114
132
  )
115
133
  end
116
134
  return :revoked if status.success?
117
- return :already_invalid if err.to_s.match?(/\b404\b/)
135
+ return :already_invalid if status.exitstatus == 22 &&
136
+ err.to_s.match?(/(?:\A|\n)curl: \(22\) The requested URL returned error: 404\s*(?:\n|\z)/)
118
137
 
119
138
  raise Providers::TransientError,
120
139
  "forgejo revoke failed: #{err.to_s.lines.first&.strip}"
@@ -122,6 +141,54 @@ module Ace
122
141
 
123
142
  private
124
143
 
144
+ def linux_xdg?
145
+ RbConfig::CONFIG.fetch("host_os").start_with?("linux")
146
+ end
147
+
148
+ def issuer_origin!(issuer)
149
+ raise Providers::RefusedError, "forgejo issuer is invalid" unless issuer.is_a?(Hash)
150
+ url = URI.parse(issuer.fetch("url"))
151
+ unless %w[http https].include?(url.scheme) && url.host && url.userinfo.nil? &&
152
+ ["", "/"].include?(url.path.to_s) && url.query.nil? && url.fragment.nil?
153
+ raise Providers::RefusedError, "forgejo issuer url must be a bare HTTP(S) origin"
154
+ end
155
+ if url.scheme == "http" && !loopback_host?(url.hostname)
156
+ raise Providers::RefusedError, "forgejo issuer requires HTTPS except on loopback"
157
+ end
158
+ url
159
+ rescue URI::InvalidURIError, KeyError, TypeError
160
+ raise Providers::RefusedError, "forgejo issuer url is invalid"
161
+ end
162
+
163
+ def loopback_host?(host)
164
+ return true if host.casecmp?("localhost")
165
+
166
+ IPAddr.new(host).loopback?
167
+ rescue IPAddr::InvalidAddressError
168
+ false
169
+ end
170
+
171
+ def issuer_header(issuer)
172
+ account = issuer["account"].to_s
173
+ username = issuer["admin_username"].to_s
174
+ path = issuer["password_file"].to_s
175
+ unless account.match?(/\A[a-zA-Z0-9_.-]+\z/) && username.match?(/\A[a-zA-Z0-9_.-]+\z/)
176
+ raise Providers::RefusedError, "forgejo issuer account and admin_username must be valid user names"
177
+ end
178
+ raise Providers::RefusedError, "forgejo issuer password file is not readable" unless File.file?(path) && File.readable?(path)
179
+ password = File.read(path).strip
180
+ raise Providers::RefusedError, "forgejo issuer password is empty" if password.empty?
181
+ "Authorization: Basic #{Base64.strict_encode64("#{username}:#{password}")}\nSudo: #{account}"
182
+ end
183
+
184
+ def validate_granted_repositories(body, requested)
185
+ repos = body["repositories"]
186
+ granted = repos.map { |repo| repo.is_a?(Hash) ? repo["full_name"].to_s.downcase : "" }.sort if repos.is_a?(Array)
187
+ return if granted && granted == requested.map { |repo| repo.to_s.downcase }.sort
188
+
189
+ raise Providers::TransientError, "forgejo did not confirm the requested repository restrictions"
190
+ end
191
+
125
192
  # RepoTargetOption: {"owner" =>, "name" =>} objects parsed
126
193
  # from `owner/name` entries. An entry without an owner does
127
194
  # not identify a repository — refusing beats issuing an
@@ -145,12 +212,10 @@ module Ace
145
212
  end
146
213
  end
147
214
 
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.
215
+ # The server's answer, not the request, establishes the grant.
151
216
  def validate_granted_scopes(body, requested)
152
217
  granted = body["scopes"]
153
- return unless granted.is_a?(Array)
218
+ raise Providers::TransientError, "forgejo did not confirm granted scopes" unless granted.is_a?(Array)
154
219
 
155
220
  missing = requested.map(&:to_s) - granted.map(&:to_s)
156
221
  unless missing.empty?
@@ -158,6 +223,9 @@ module Ace
158
223
  "forgejo granted scopes [#{granted.join(', ')}] do not cover the " \
159
224
  "requested [#{missing.join(', ')}]; refusing the grant"
160
225
  end
226
+ unless (granted.map(&:to_s) - requested.map(&:to_s)).empty?
227
+ raise Providers::TransientError, "forgejo granted unrequested scopes; refusing the grant"
228
+ end
161
229
  end
162
230
 
163
231
  # The Authorization secret NEVER appears in curl argv (visible
@@ -69,6 +69,11 @@ module Ace
69
69
  roles = string_list(raw["roles"], name, "roles")
70
70
  principals = string_list(raw["principals"], name, "principals")
71
71
  repos = string_list(raw["repos"], name, "repos")
72
+ env_names = string_list(raw["env_names"], name, "env_names")
73
+ if env_names.any? { |value| value.include?("=") || value.include?("\0") }
74
+ raise Ace::Lab::InvalidConfigurationError,
75
+ "auth profile '#{name}' env_names cannot contain '=' or NUL"
76
+ end
72
77
  ttl = positive_minutes(raw["ttl_minutes"], name, DEFAULT_TTL_MINUTES)
73
78
  max_ttl = positive_minutes(raw["max_ttl_minutes"], name, DEFAULT_MAX_TTL_MINUTES)
74
79
  raise_conflict(name, "ttl_minutes exceeds max_ttl_minutes") if ttl > max_ttl
@@ -92,7 +97,7 @@ module Ace
92
97
  "ttl_minutes" => ttl, "max_ttl_minutes" => max_ttl,
93
98
  "expiry_mode" => expiry_mode,
94
99
  "issuer" => raw["issuer"].is_a?(Hash) ? raw["issuer"] : {},
95
- "env_names" => string_list(raw["env_names"], name, "env_names")}
100
+ "env_names" => env_names}
96
101
  end
97
102
 
98
103
  def default_expiry_mode(provider)
@@ -23,6 +23,9 @@ module Ace
23
23
  # {"token" => secret (never serialized), "token_ref" => provider id,
24
24
  # "provider_expiry" => time-ish | nil, "via" => handoff descriptor}
25
25
  # or raises RefusedError / TransientError / OutcomeUnknownError
26
+ # RefusedError guarantees no credential was issued. If issuance
27
+ # may have happened, the provider must raise OutcomeUnknownError
28
+ # or TransientError so recovery never treats the outcome as closed.
26
29
  # revoke(profile:, token_ref:, token:) -> :revoked | :already_invalid |
27
30
  # raises TransientError / OutcomeUnknownError
28
31
  # (`token` is the issued secret from the handoff — providers
@@ -31,6 +34,11 @@ module Ace
31
34
  # id ignore it; a 404-shaped unknown id settles as
32
35
  # :already_invalid so interrupted issues recover
33
36
  # deterministically)
37
+ # with_exec_environment(profile:, credential:) { |env| ... }
38
+ # optional scoped child-environment projection. Raise RefusedError
39
+ # before dispatch on failure; clean up temporary secrets when the
40
+ # block exits. Crash-recoverable projections use the existing
41
+ # CredentialHandoff.projection_prefix lifecycle.
34
42
  class Providers
35
43
  class RefusedError < StandardError; end
36
44
  class TransientError < StandardError; end