ace-lab 0.13.1 → 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 +94 -1
- data/docs/usage.md +130 -0
- data/lib/ace/lab/atoms/install_schema.rb +72 -11
- 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.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 +223 -4
- data/lib/ace/lab/molecules/project_locations.rb +62 -0
- data/lib/ace/lab/organisms/prepare.rb +28 -2
- data/lib/ace/lab/organisms/verify.rb +139 -9
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +1 -0
- metadata +15 -2
|
@@ -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
|
|
@@ -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
|