atlas_rb 1.3.7 → 1.3.9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2edc49cf965b1b1489e639b3ae8fd0413d036fe81b07a83c48f851ed975da1a0
4
- data.tar.gz: ba269fae419aa13545e9e5c664e7c1b66282bcb4ff9c8e238d1333e2c51f444e
3
+ metadata.gz: cf2f8c7f9ef316468a2544efe2f4471c23c547ae36d50b9abfee2efd03db38c8
4
+ data.tar.gz: 9da90557d04f84ddad26c05b6c50eab0d9a7378ccf57fbb37ba6b3e7e4355cd4
5
5
  SHA512:
6
- metadata.gz: 5ed3a6c2f5d1934a25654fd0130e4f8bc8cf41e00530dfee5b333d4e09bdf9caacdd25f88eecb88426bbf9ba3a61f870faf2b9fece2fb13b7a066a24818bf4e8
7
- data.tar.gz: ea5ddec2aea0f36cfbebc2150035517857beb24a015b19deade470507604d771bd8b9bd50151d8260e425de355bbe7af502e7dd284819207260a5066cad814ff
6
+ metadata.gz: 640d113ae6693aacb7d7d2a943634561281ebb5324d7db5284429673a48ff1ae9cbedda4d55b058710c497573e4c7c379dee18b08119925c5a0e7e73f7105088
7
+ data.tar.gz: 8e7209552ebc5b7b8db138809bd89ef077ac282342c39d483f1c125f68d757c155ff12865f7a1c24bb65040bf287eb13c7ecd81549117cb0cf01d3a1bf58cc79
data/.version CHANGED
@@ -1 +1 @@
1
- 1.3.7
1
+ 1.3.9
data/Gemfile.lock CHANGED
@@ -1,15 +1,17 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- atlas_rb (1.3.7)
4
+ atlas_rb (1.3.9)
5
5
  faraday (~> 2.7)
6
6
  faraday-follow_redirects (~> 0.3.0)
7
7
  faraday-multipart (~> 1)
8
8
  hashie (~> 5.0)
9
+ jwt (~> 2.7)
9
10
 
10
11
  GEM
11
12
  remote: https://rubygems.org/
12
13
  specs:
14
+ base64 (0.3.0)
13
15
  diff-lcs (1.6.1)
14
16
  faraday (2.14.2)
15
17
  faraday-net_http (>= 2.0, < 3.5)
@@ -24,6 +26,8 @@ GEM
24
26
  hashie (5.1.0)
25
27
  logger
26
28
  json (2.19.9)
29
+ jwt (2.10.3)
30
+ base64
27
31
  logger (1.7.0)
28
32
  multipart-post (2.4.1)
29
33
  net-http (0.9.1)
data/README.md CHANGED
@@ -106,6 +106,41 @@ In BYO-JWT mode:
106
106
  To rotate or revoke, ask Cerberus to regenerate your token (Atlas rotates
107
107
  the user's `jti`, invalidating outstanding tokens — single-token model).
108
108
 
109
+ ### Relay-signing mode (the `ATLAS_TOKEN` replacement)
110
+
111
+ The default relay authenticates with the shared `ATLAS_TOKEN` and *asserts* the
112
+ acting user via a `User: NUID` header. Relay-signing replaces that with a
113
+ **proven** identity: the relay **signs** a short-lived assertion with its own
114
+ private key (`iss=cerberus`, `aud=atlas`, `sub` = the acting nuid, ES256), which
115
+ Atlas verifies against the matching public key. No shared secret, no asserted
116
+ header.
117
+
118
+ Configure a signing key (and the `kid` Atlas indexes its public key by) — value
119
+ or callable, so a Rails host reads it from credentials at request time:
120
+
121
+ ```ruby
122
+ AtlasRb.configure do |config|
123
+ config.assertion_signing_key = -> { Rails.application.credentials.cerberus_signing_key }
124
+ config.assertion_signing_kid = -> { Rails.application.credentials.cerberus_signing_kid }
125
+ end
126
+ ```
127
+
128
+ When a signing key is configured, the regular relay (`connection` / `multipart`)
129
+ signs instead of sending `ATLAS_TOKEN` + `User:`. Otherwise it behaves exactly as
130
+ before — so signing **coexists with `ATLAS_TOKEN` during cutover** (turn it on by
131
+ configuring the key; roll back by clearing it).
132
+
133
+ Two things to know:
134
+
135
+ - **Acting-as rides a signed `obo` claim.** An `on_behalf_of` request is signed
136
+ with `sub` = the operator and `obo` = the target, inside the signature — so the
137
+ target can't be forged onto a stolen assertion, and no `On-Behalf-Of` header is
138
+ sent. Atlas admin-gates the operator and ignores any header obo on this path.
139
+ (Requires an Atlas on the signed-obo release; older Atlas would silently ignore
140
+ the claim — don't enable signing for acting-as traffic until Atlas is current.)
141
+ - **`ATLAS_JWT` still wins.** A personal token (BYO-JWT) takes precedence over
142
+ relay-signing.
143
+
109
144
  ### System-path credentials
110
145
 
111
146
  Calls under `AtlasRb::System::*` (currently just SSO user provisioning)
@@ -44,5 +44,28 @@ module AtlasRb
44
44
  # @return [Proc, nil] callable returning the on-behalf-of NUID, or nil
45
45
  # to send no `On-Behalf-Of:` header.
46
46
  attr_accessor :default_on_behalf_of
47
+
48
+ # Relay signing (the cerberus_token replacement). When set, the regular
49
+ # relay path *signs* a short-lived assertion (ES256, `sub` = acting nuid)
50
+ # instead of sending `ATLAS_TOKEN` + a `User:` header — identity becomes
51
+ # proven, not asserted. Leave nil (the default) to keep the legacy relay.
52
+ #
53
+ # Accepts either a value or a callable (resolved per request, so a Rails
54
+ # host can read it from request-scoped state / credentials). The value may
55
+ # be a PEM string or an `OpenSSL::PKey`; a PEM is parsed for you.
56
+ #
57
+ # @example Rails host (initializer), reading the EC private key from credentials
58
+ # AtlasRb.configure do |config|
59
+ # config.assertion_signing_key = -> { Rails.application.credentials.cerberus_signing_key }
60
+ # config.assertion_signing_kid = -> { Rails.application.credentials.cerberus_signing_kid }
61
+ # end
62
+ #
63
+ # @return [String, OpenSSL::PKey, Proc, nil] EC private key (PEM/key/callable), or nil.
64
+ attr_accessor :assertion_signing_key
65
+
66
+ # @return [String, Proc, nil] the `kid` stamped in the assertion header so
67
+ # Atlas selects the matching public key. Value or callable. Required when
68
+ # {#assertion_signing_key} is set.
69
+ attr_accessor :assertion_signing_kid
47
70
  end
48
71
  end
@@ -30,9 +30,31 @@ module AtlasRb
30
30
  # precedence over `ATLAS_TOKEN`. This is the standalone-script path: a
31
31
  # librarian exports their minted token and runs headless against the API.
32
32
  #
33
+ # **Relay-signing mode ({AtlasRb.config#assertion_signing_key} set).** The
34
+ # cryptographic replacement for the `ATLAS_TOKEN` relay: instead of a shared
35
+ # secret + an asserted `User:` header, the regular relay path **signs** a
36
+ # short-lived assertion (ES256, `iss=cerberus`, `aud=atlas`, `sub` = the
37
+ # acting nuid) with Cerberus's private key — Atlas verifies it with the
38
+ # public key. No `User:` header; identity is the proven `sub`. **Acting-as
39
+ # rides a signed `obo` claim** inside the assertion (the target can't be forged
40
+ # onto a stolen assertion; Atlas admin-gates the operator and ignores any
41
+ # header obo on this path) — it is no longer punted to the legacy relay. Off
42
+ # unless a signing key is configured (so it coexists with `ATLAS_TOKEN`
43
+ # during cutover). `ATLAS_JWT`, if set, still wins over signing.
44
+ #
45
+ # Requires an Atlas that verifies the signed `obo` claim (the signed-obo
46
+ # release); against an older Atlas an `obo` would be silently ignored, so
47
+ # don't enable signing for acting-as traffic until Atlas is on that version.
48
+ #
33
49
  # The module is mixed in via `extend`, so its methods become class methods on
34
50
  # the host (e.g. `AtlasRb::Work.connection({})`).
35
51
  module FaradayHelper
52
+ # Wire contract Atlas enforces for relay-signing assertions (see Atlas
53
+ # ApplicationController#verify_cerberus_assertion). iss/aud are fixed; the
54
+ # short TTL bounds replay (Atlas allows 30s leeway on exp).
55
+ ASSERTION_ISSUER = "cerberus"
56
+ ASSERTION_AUDIENCE = "atlas"
57
+ ASSERTION_TTL = 30 # seconds
36
58
  # Build a JSON-content Faraday connection to the Atlas API.
37
59
  #
38
60
  # @param params [Hash] query-string / body params to attach to the request.
@@ -151,30 +173,74 @@ module AtlasRb
151
173
 
152
174
  private
153
175
 
154
- # Build the auth + identity headers shared by {#connection} and
155
- # {#multipart}. BYO-JWT mode (`ATLAS_JWT` set) sends only the bearer the
156
- # token carries the acting user, so no `User:` header, and `On-Behalf-Of`
157
- # is suppressed (Atlas 403s acting-as on the JWT path). Relay mode uses
158
- # `ATLAS_TOKEN` and names the acting user, falling through to the
159
- # configured `default_nuid` / `default_on_behalf_of` callables when the
160
- # caller passes neither.
176
+ # Build the auth + identity headers shared by {#connection} and {#multipart}.
177
+ # Precedence: ATLAS_JWT (BYO-JWT) > relay-signing > ATLAS_TOKEN relay. The
178
+ # acting nuid / on_behalf_of fall through to the configured `default_nuid` /
179
+ # `default_on_behalf_of` callables here, once, for whichever mode applies.
161
180
  def auth_headers(nuid, on_behalf_of)
162
181
  jwt = ENV.fetch("ATLAS_JWT", nil)
163
182
  return { "Authorization" => "Bearer #{jwt}" } if jwt
164
183
 
165
- relay_headers(nuid, on_behalf_of)
166
- end
167
-
168
- # Relay-path headers: ATLAS_TOKEN bearer + the acting-user identity headers,
169
- # falling through to the configured default_nuid / default_on_behalf_of.
170
- def relay_headers(nuid, on_behalf_of)
171
184
  nuid ||= AtlasRb.config.default_nuid&.call
172
185
  on_behalf_of ||= AtlasRb.config.default_on_behalf_of&.call
173
186
 
187
+ signed_relay_headers(nuid, on_behalf_of) || relay_headers(nuid, on_behalf_of)
188
+ end
189
+
190
+ # A signed-assertion Authorization header (sub = acting nuid), or nil to
191
+ # defer to the legacy relay. nil when signing isn't configured or there is no
192
+ # acting nuid to put in `sub`. Acting-as is carried IN the assertion as a
193
+ # signed `obo` claim (Atlas honours it on the assertion path as of the
194
+ # signed-obo release), so it is no longer punted to the cerberus_token relay.
195
+ def signed_relay_headers(nuid, on_behalf_of)
196
+ return nil unless nuid
197
+
198
+ key = assertion_signing_key
199
+ return nil unless key
200
+
201
+ { "Authorization" => "Bearer #{signed_assertion(nuid.to_s, key, on_behalf_of)}" }
202
+ end
203
+
204
+ # Legacy relay headers: ATLAS_TOKEN bearer + acting-user identity headers.
205
+ # `nuid` / `on_behalf_of` are already resolved by {#auth_headers}.
206
+ def relay_headers(nuid, on_behalf_of)
174
207
  headers = { "Authorization" => "Bearer #{ENV.fetch("ATLAS_TOKEN", nil)}" }
175
208
  headers["User"] = "NUID #{nuid}" if nuid
176
209
  headers["On-Behalf-Of"] = "NUID #{on_behalf_of}" if on_behalf_of
177
210
  headers
178
211
  end
212
+
213
+ # Mint a Cerberus relay assertion for `nuid`, signed ES256 with `key`. The
214
+ # `kid` header tells Atlas which public key to verify against; iss/aud are
215
+ # the fixed contract; the short TTL bounds replay; `jti` is forward-compat
216
+ # for an Atlas-side one-time cache. When `on_behalf_of` is given, it rides as
217
+ # a SIGNED `obo` claim — acting-as that can't be forged onto a stolen
218
+ # assertion (Atlas admin-gates the operator and ignores any header obo here).
219
+ def signed_assertion(nuid, key, on_behalf_of = nil)
220
+ now = Time.now.to_i
221
+ payload = { "iss" => ASSERTION_ISSUER, "aud" => ASSERTION_AUDIENCE, "sub" => nuid,
222
+ "iat" => now, "exp" => now + ASSERTION_TTL, "jti" => SecureRandom.uuid,
223
+ "obo" => on_behalf_of&.to_s }.compact # obo only when acting-as
224
+ JWT.encode(payload, key, "ES256", { kid: assertion_signing_kid })
225
+ end
226
+
227
+ # Resolve the configured signing key to an OpenSSL::PKey, or nil if signing
228
+ # is not configured. Accepts a callable (resolved per request), a PEM
229
+ # string (parsed), or an already-built key.
230
+ def assertion_signing_key
231
+ raw = config_value(AtlasRb.config.assertion_signing_key)
232
+ return nil if raw.nil?
233
+
234
+ raw.is_a?(OpenSSL::PKey::PKey) ? raw : OpenSSL::PKey.read(raw)
235
+ end
236
+
237
+ def assertion_signing_kid
238
+ config_value(AtlasRb.config.assertion_signing_kid)
239
+ end
240
+
241
+ # Config slots may hold a value or a callable resolved at request time.
242
+ def config_value(value)
243
+ value.respond_to?(:call) ? value.call : value
244
+ end
179
245
  end
180
246
  end
data/lib/atlas_rb.rb CHANGED
@@ -3,6 +3,9 @@
3
3
  require "faraday"
4
4
  require "faraday/multipart"
5
5
  require "faraday/follow_redirects"
6
+ require "jwt"
7
+ require "openssl"
8
+ require "securerandom"
6
9
  require_relative "atlas_rb/version"
7
10
  require_relative "atlas_rb/errors"
8
11
  require_relative "atlas_rb/configuration"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: atlas_rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.7
4
+ version: 1.3.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cliff
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-14 00:00:00.000000000 Z
11
+ date: 2026-06-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '2.7'
27
+ - !ruby/object:Gem::Dependency
28
+ name: jwt
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.7'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.7'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: faraday-multipart
29
43
  requirement: !ruby/object:Gem::Requirement