ace-lab 0.13.1 → 0.14.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,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