atlas-auth 0.1.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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +132 -0
  4. data/lib/atlas/authorization.rb +78 -0
  5. data/lib/atlas/client.rb +124 -0
  6. data/lib/atlas/error.rb +92 -0
  7. data/lib/atlas/handshake.rb +241 -0
  8. data/lib/atlas/jwks_cache.rb +138 -0
  9. data/lib/atlas/pagination.rb +52 -0
  10. data/lib/atlas/resources/actions.rb +46 -0
  11. data/lib/atlas/resources/actor_tokens.rb +18 -0
  12. data/lib/atlas/resources/api_keys.rb +38 -0
  13. data/lib/atlas/resources/attack_protection.rb +18 -0
  14. data/lib/atlas/resources/audit_logs.rb +14 -0
  15. data/lib/atlas/resources/base.rb +32 -0
  16. data/lib/atlas/resources/billing.rb +35 -0
  17. data/lib/atlas/resources/bot_signals.rb +26 -0
  18. data/lib/atlas/resources/branding.rb +24 -0
  19. data/lib/atlas/resources/data_subject_requests.rb +28 -0
  20. data/lib/atlas/resources/domains.rb +26 -0
  21. data/lib/atlas/resources/email_templates.rb +29 -0
  22. data/lib/atlas/resources/fga.rb +146 -0
  23. data/lib/atlas/resources/import_export.rb +35 -0
  24. data/lib/atlas/resources/instance.rb +18 -0
  25. data/lib/atlas/resources/instance_security.rb +46 -0
  26. data/lib/atlas/resources/invitations.rb +22 -0
  27. data/lib/atlas/resources/jwt_templates.rb +32 -0
  28. data/lib/atlas/resources/localizations.rb +30 -0
  29. data/lib/atlas/resources/log_streams.rb +35 -0
  30. data/lib/atlas/resources/lti_platforms.rb +30 -0
  31. data/lib/atlas/resources/managed_waf.rb +35 -0
  32. data/lib/atlas/resources/messaging.rb +32 -0
  33. data/lib/atlas/resources/network_acls.rb +30 -0
  34. data/lib/atlas/resources/oauth_clients.rb +55 -0
  35. data/lib/atlas/resources/oauth_providers.rb +44 -0
  36. data/lib/atlas/resources/organizations.rb +140 -0
  37. data/lib/atlas/resources/radius_clients.rb +30 -0
  38. data/lib/atlas/resources/rate_limit.rb +18 -0
  39. data/lib/atlas/resources/resource_servers.rb +30 -0
  40. data/lib/atlas/resources/restrictions.rb +29 -0
  41. data/lib/atlas/resources/risk_based_mfa.rb +18 -0
  42. data/lib/atlas/resources/roles.rb +55 -0
  43. data/lib/atlas/resources/scim_provisioning.rb +46 -0
  44. data/lib/atlas/resources/scim_tokens.rb +23 -0
  45. data/lib/atlas/resources/sessions.rb +29 -0
  46. data/lib/atlas/resources/sign_in_tokens.rb +17 -0
  47. data/lib/atlas/resources/sms_templates.rb +37 -0
  48. data/lib/atlas/resources/sso_connections.rb +35 -0
  49. data/lib/atlas/resources/sso_onboarding.rb +37 -0
  50. data/lib/atlas/resources/tokens.rb +15 -0
  51. data/lib/atlas/resources/users.rb +121 -0
  52. data/lib/atlas/resources/waitlist.rb +19 -0
  53. data/lib/atlas/resources/webhooks.rb +35 -0
  54. data/lib/atlas/transport.rb +158 -0
  55. data/lib/atlas/verifier.rb +227 -0
  56. data/lib/atlas/version.rb +6 -0
  57. data/lib/atlas.rb +41 -0
  58. metadata +148 -0
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require "securerandom"
6
+ require "digest"
7
+ require "base64"
8
+
9
+ require_relative "error"
10
+ require_relative "transport"
11
+
12
+ module Atlas
13
+ # A fresh session minted by redeeming a cross-property handshake nonce.
14
+ #
15
+ # These are the cookie VALUES a satellite's Rack/Rails handler sets as its own
16
+ # first-party cookies: +jwt+ as +__session+ (script-readable, short-lived) and
17
+ # +refresh_token+ as +__atlas_rt+ (HttpOnly).
18
+ #
19
+ # @!attribute [r] jwt
20
+ # @return [String] the +__session+ JWT value.
21
+ # @!attribute [r] refresh_token
22
+ # @return [String] the +__atlas_rt+ refresh value (set HttpOnly).
23
+ # @!attribute [r] session_id
24
+ # @return [String] the session id ("" when the server omits it).
25
+ # @!attribute [r] expires_in
26
+ # @return [Integer] seconds until the +__session+ JWT expires.
27
+ HandshakeSession = Struct.new(:jwt, :refresh_token, :session_id, :expires_in, keyword_init: true)
28
+
29
+ # Cross-property SSO handshake — the second property's server-side redeem step.
30
+ #
31
+ # When a satellite (a property on a DIFFERENT registrable domain than the main
32
+ # app) has no local session, a middleware bounces the browser through
33
+ # +GET {fapi_origin}/v1/client/handshake+, which — if the user has an Atlas
34
+ # session — redirects back with
35
+ # +?__atlas_hs=ok&__atlas_hu=<user_id>&__atlas_hn=<nonce>+. These helpers read
36
+ # those params and exchange the single-use nonce for a fresh session,
37
+ # server-to-server, so the tokens come back in the response BODY (never a URL).
38
+ #
39
+ # PKCE binding (RFC 7636, S256). The +__atlas_hn+ nonce rides the return URL,
40
+ # which lands in access logs, +Referer+ headers, and browser history — so the
41
+ # nonce alone MUST NOT be a bearer credential. Before bouncing to the handshake
42
+ # we mint a PKCE pair with {create_pkce_pair}: the +challenge+ (a SHA-256 hash)
43
+ # travels on the loggable outbound URL as +code_challenge+, while the +verifier+
44
+ # is stashed server-side in an HttpOnly cookie (+__atlas_hv+, path +/+, ~300s)
45
+ # that never appears in any URL. At redeem we send the +verifier+ back in the
46
+ # request BODY; the Atlas server hashes it and checks it matches the challenge
47
+ # it recorded against the nonce. An attacker who scrapes the nonce out of a log
48
+ # can't redeem it without the verifier, which was never logged. (The Atlas
49
+ # server now REQUIRES +code_verifier+ on redeem — an unbound redeem fails.)
50
+ #
51
+ # Same-registrable-domain SUBDOMAINS don't need this — the handshake sets a
52
+ # parent-domain cookie directly; this is only the cross-domain path.
53
+ #
54
+ # Framework-agnostic usage in a satellite's route handler (Rack/Rails/Sinatra):
55
+ #
56
+ # params = Atlas::Handshake.read_handshake_params(request.url)
57
+ # if params
58
+ # session = Atlas::Handshake.redeem_handshake(
59
+ # fapi_origin: "https://id.atlasauth.net",
60
+ # publishable_key: "pk_live_...",
61
+ # user_id: params[:user_id],
62
+ # nonce: params[:nonce],
63
+ # # The verifier we stashed in __atlas_hv when we bounced out (below):
64
+ # code_verifier: request.cookies["__atlas_hv"],
65
+ # )
66
+ # if session
67
+ # # Set the satellite's OWN first-party cookies:
68
+ # response.set_cookie("__session",
69
+ # value: session.jwt, path: "/", same_site: :lax, secure: true)
70
+ # response.set_cookie("__atlas_rt",
71
+ # value: session.refresh_token, path: "/",
72
+ # httponly: true, same_site: :lax, secure: true)
73
+ # response.delete_cookie("__atlas_hv") # single-use; done with it.
74
+ # end
75
+ # # else: nonce was bad/expired/used — fall through to sign-in.
76
+ # end
77
+ #
78
+ # A middleware that TRIGGERS the handshake mints a PKCE pair, stashes the
79
+ # verifier, and redirects once to
80
+ # +{fapi_origin}/v1/client/handshake?publishable_key={pk}&redirect_url={current_url}&code_challenge={challenge}+
81
+ # only when there is no local session AND the +__atlas_hs+ query param is absent
82
+ # (its presence means we already came back from the handshake), so it can never
83
+ # loop:
84
+ #
85
+ # pkce = Atlas::Handshake.create_pkce_pair
86
+ # response.set_cookie("__atlas_hv",
87
+ # value: pkce[:verifier], path: "/", max_age: 300,
88
+ # httponly: true, same_site: :lax, secure: true)
89
+ # url = "#{fapi_origin}/v1/client/handshake" \
90
+ # "?publishable_key=#{pk}" \
91
+ # "&redirect_url=#{CGI.escape(current_url)}" \
92
+ # "&code_challenge=#{pkce[:challenge]}"
93
+ # redirect_to url
94
+ module Handshake
95
+ module_function
96
+
97
+ # Mint a PKCE pair (RFC 7636, S256) for one handshake round-trip.
98
+ #
99
+ # The handshake nonce travels back to the satellite on a query string, so it
100
+ # is written to access logs, leaked in +Referer+ headers, and kept in browser
101
+ # history. If the nonce alone could be redeemed, anyone who reads one of those
102
+ # logs holds a usable credential. PKCE fixes that by splitting the secret in
103
+ # two: the +challenge+ (an irreversible SHA-256 hash) is what rides the
104
+ # loggable outbound URL as +code_challenge+, and the +verifier+ (the
105
+ # pre-image) is stashed server-side in an HttpOnly cookie (+__atlas_hv+) that
106
+ # never touches a URL. At redeem the verifier is sent in the request BODY and
107
+ # the Atlas server hashes it and checks it against the challenge it bound to
108
+ # the nonce — so a scraped nonce is inert without the verifier, which was
109
+ # never logged.
110
+ #
111
+ # +verifier+ is 32 bytes of {SecureRandom} base64url-encoded without padding
112
+ # (43 chars, well inside RFC 7636's 43–128 range). +challenge+ is the
113
+ # base64url-no-padding SHA-256 digest of the verifier's ASCII bytes (the S256
114
+ # transform). Both use +padding: false+ because RFC 7636 §3 forbids the
115
+ # trailing +=+ and requires the URL-safe alphabet.
116
+ #
117
+ # @return [Hash{Symbol=>String}] +{ verifier:, challenge: }+ — send
118
+ # +challenge+ on the outbound handshake URL as +code_challenge+, stash
119
+ # +verifier+ HttpOnly, and pass it back to {redeem_handshake} as
120
+ # +code_verifier+.
121
+ def create_pkce_pair
122
+ verifier = Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false)
123
+ challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
124
+ { verifier: verifier, challenge: challenge }
125
+ end
126
+
127
+ # Redeem a handshake nonce for a fresh session.
128
+ #
129
+ # POSTs to +{fapi_origin}/v1/client/handshake/redeem+ with header
130
+ # +X-Publishable-Key: {publishable_key}+ and JSON body
131
+ # +{ "user_id" => user_id, "nonce" => nonce, "code_verifier" => code_verifier }+.
132
+ #
133
+ # @param fapi_origin [String] origin of the Atlas Frontend API,
134
+ # e.g. +https://id.atlasauth.net+.
135
+ # @param publishable_key [String] the instance publishable key (+pk_...+).
136
+ # @param user_id [String] the +__atlas_hu+ value from the return URL.
137
+ # @param nonce [String] the single-use +__atlas_hn+ nonce from the return URL.
138
+ # @param code_verifier [String, nil] the PKCE verifier stashed (in the
139
+ # +__atlas_hv+ HttpOnly cookie) when the handshake was triggered — the
140
+ # pre-image of the +code_challenge+ that rode the outbound URL. The Atlas
141
+ # server now REQUIRES this: a redeem without the verifier that matches the
142
+ # bound challenge fails, and this returns +nil+ like any other bad-nonce.
143
+ # @param http [#call, nil] injectable requester matching the Transport seam —
144
+ # +call(method, url, headers, body) -> [status_integer, body_string]+.
145
+ # Defaults to {Atlas::NetHTTPRequester}.
146
+ #
147
+ # @return [Atlas::HandshakeSession, nil] the fresh session, or +nil+ when the
148
+ # nonce is missing/expired/already used, the verifier doesn't bind, or the
149
+ # request could not complete. A bad nonce is a normal "signed-out" signal —
150
+ # this NEVER raises on an auth failure or a transport error, so it can't
151
+ # take the page down.
152
+ def redeem_handshake(fapi_origin:, publishable_key:, user_id:, nonce:, code_verifier: nil, http: nil)
153
+ requester = http || NetHTTPRequester.new
154
+ url = "#{fapi_origin.sub(%r{/+\z}, '')}/v1/client/handshake/redeem"
155
+ headers = {
156
+ "content-type" => "application/json",
157
+ "accept" => "application/json",
158
+ "x-publishable-key" => publishable_key
159
+ }
160
+ payload = JSON.generate(
161
+ { "user_id" => user_id, "nonce" => nonce, "code_verifier" => code_verifier }
162
+ )
163
+
164
+ status, text = requester.call("POST", url, headers, payload)
165
+ return nil unless status.is_a?(Integer) && status.between?(200, 299)
166
+
167
+ data = parse_json(text)
168
+ return nil unless data.is_a?(Hash)
169
+
170
+ jwt = data["jwt"]
171
+ refresh_token = data["refresh_token"]
172
+ return nil if jwt.nil? || jwt.empty? || refresh_token.nil? || refresh_token.empty?
173
+
174
+ HandshakeSession.new(
175
+ jwt: jwt,
176
+ refresh_token: refresh_token,
177
+ session_id: data["session_id"] || "",
178
+ expires_in: (data["expires_in"] || 0).to_i
179
+ )
180
+ rescue StandardError
181
+ # A transport error is indistinguishable, to the user, from a signed-out
182
+ # state — return nil and let the caller fall back to sign-in.
183
+ nil
184
+ end
185
+
186
+ # Extract the handshake params from a return URL or query string.
187
+ #
188
+ # @param url_or_query [String] a full URL, a query string, or a bare
189
+ # +key=value&...+ fragment.
190
+ # @return [Hash{Symbol=>String}, nil] +{ user_id:, nonce: }+ when
191
+ # +__atlas_hs == "ok"+ and both +__atlas_hu+ and +__atlas_hn+ are present;
192
+ # +nil+ otherwise.
193
+ def read_handshake_params(url_or_query)
194
+ return nil if url_or_query.nil?
195
+
196
+ query = url_or_query.include?("?") ? url_or_query[(url_or_query.index("?") + 1)..] : url_or_query
197
+ query = query.sub(/#.*\z/, "") if query # drop any fragment
198
+ return nil if query.nil? || query.empty?
199
+
200
+ params = {}
201
+ URI.decode_www_form(query).each { |key, value| params[key] = value }
202
+
203
+ return nil unless params["__atlas_hs"] == "ok"
204
+
205
+ user_id = params["__atlas_hu"]
206
+ nonce = params["__atlas_hn"]
207
+ return nil if user_id.nil? || user_id.empty? || nonce.nil? || nonce.empty?
208
+
209
+ { user_id: user_id, nonce: nonce }
210
+ rescue StandardError
211
+ nil
212
+ end
213
+
214
+ def parse_json(text)
215
+ return nil if text.nil? || text.empty?
216
+
217
+ JSON.parse(text)
218
+ rescue JSON::ParserError
219
+ nil
220
+ end
221
+ end
222
+
223
+ # Convenience mirrors on the top-level module, matching the standalone auth
224
+ # helpers (+Atlas.has_permission?+, +Atlas.protect+, ...).
225
+ module_function
226
+
227
+ # @see Atlas::Handshake.create_pkce_pair
228
+ def create_pkce_pair
229
+ Handshake.create_pkce_pair
230
+ end
231
+
232
+ # @see Atlas::Handshake.redeem_handshake
233
+ def redeem_handshake(**kwargs)
234
+ Handshake.redeem_handshake(**kwargs)
235
+ end
236
+
237
+ # @see Atlas::Handshake.read_handshake_params
238
+ def read_handshake_params(url_or_query)
239
+ Handshake.read_handshake_params(url_or_query)
240
+ end
241
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "base64"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ module Atlas
9
+ # §7.3: "JWKS cached in-process with kid-miss refetch (max 1/min) so key
10
+ # rotation needs no deploys."
11
+ #
12
+ # The rate limit on the refetch is the security property, not a politeness.
13
+ # Without it, an attacker sends tokens carrying random +kid+ values and every
14
+ # one forces an outbound request to the JWKS endpoint — turning any
15
+ # unauthenticated caller into a traffic amplifier. The cache's job is as much
16
+ # to refuse to fetch as it is to fetch.
17
+ class JwksCache
18
+ # §7.3: at most one refetch per minute, however many misses arrive.
19
+ REFETCH_INTERVAL_MS = 60_000
20
+ # §7.3 serves +Cache-Control: max-age=3600+; honoured rather than ignored.
21
+ DEFAULT_TTL_MS = 3_600_000
22
+
23
+ # Exposed so a caller can assert the rate limit actually bit.
24
+ # One of :fresh, :cached, :refetched, :throttled, :failed.
25
+ attr_reader :last_outcome
26
+
27
+ # @param url [String] the instance JWKS URL.
28
+ # @param fetch_impl [#call] optional +call(url) -> [status, body]+ for tests.
29
+ # @param now [#call] optional clock returning epoch milliseconds.
30
+ def initialize(url:, fetch_impl: nil, now: nil, ttl_ms: DEFAULT_TTL_MS,
31
+ refetch_interval_ms: REFETCH_INTERVAL_MS)
32
+ @url = url
33
+ @fetch_impl = fetch_impl
34
+ @now = now
35
+ @ttl_ms = ttl_ms
36
+ @refetch_interval_ms = refetch_interval_ms
37
+ @cached = nil
38
+ @fetched_at = 0
39
+ @last_attempt_at = 0
40
+ @last_outcome = :fresh
41
+ @mutex = Mutex.new
42
+ end
43
+
44
+ # The JWKS to verify against, refetching if this +kid+ is unknown. Returns
45
+ # whatever is cached when a refetch is throttled or fails — a stale JWKS
46
+ # still verifies every token signed by a key it contains.
47
+ #
48
+ # @return [Hash, nil] a +{ "keys" => [...] }+ hash, or nil if never fetched.
49
+ def get(kid = nil)
50
+ @mutex.synchronize do
51
+ now = current_time
52
+ expired = @cached.nil? || (now - @fetched_at) >= @ttl_ms
53
+ kid_miss = !@cached.nil? && !key?(kid)
54
+
55
+ if !expired && !kid_miss
56
+ @last_outcome = :cached
57
+ return @cached
58
+ end
59
+
60
+ if kid_miss && !expired && (now - @last_attempt_at) < @refetch_interval_ms
61
+ @last_outcome = :throttled
62
+ return @cached
63
+ end
64
+
65
+ @last_attempt_at = now
66
+ begin
67
+ body = fetch_jwks
68
+ raise "JWKS is malformed" unless body.is_a?(Hash) && body["keys"].is_a?(Array)
69
+
70
+ @cached = body
71
+ @fetched_at = now
72
+ @last_outcome = kid_miss ? :refetched : :fresh
73
+ @cached
74
+ rescue StandardError
75
+ # Keep serving what we have. A JWKS outage should degrade to "new keys
76
+ # do not work yet", not "nobody can authenticate".
77
+ @last_outcome = :failed
78
+ @cached
79
+ end
80
+ end
81
+ end
82
+
83
+ # Test and diagnostic surface — never used for a security decision.
84
+ def snapshot
85
+ { keys: @cached ? @cached["keys"].length : 0, fetched_at: @fetched_at }
86
+ end
87
+
88
+ private
89
+
90
+ def current_time
91
+ @now ? @now.call : (Time.now.to_f * 1000).to_i
92
+ end
93
+
94
+ def key?(kid)
95
+ return false if @cached.nil?
96
+ return !@cached["keys"].empty? if kid.nil?
97
+
98
+ @cached["keys"].any? { |k| k["kid"] == kid }
99
+ end
100
+
101
+ def fetch_jwks
102
+ if @fetch_impl
103
+ status, text = @fetch_impl.call(@url)
104
+ raise "JWKS fetch failed (#{status})" unless status.between?(200, 299)
105
+
106
+ return JSON.parse(text)
107
+ end
108
+
109
+ uri = URI.parse(@url)
110
+ http = Net::HTTP.new(uri.host, uri.port)
111
+ http.use_ssl = uri.scheme == "https"
112
+ request = Net::HTTP::Get.new(uri.request_uri)
113
+ request["accept"] = "application/json"
114
+ response = http.request(request)
115
+ raise "JWKS fetch failed (#{response.code})" unless response.code.to_i.between?(200, 299)
116
+
117
+ JSON.parse(response.body.to_s)
118
+ end
119
+ end
120
+
121
+ # Read the +kid+ from a JWT header without verifying anything.
122
+ def self.read_kid(jwt)
123
+ header = jwt.to_s.split(".").first
124
+ return nil if header.nil? || header.empty?
125
+
126
+ decoded = Base64.urlsafe_decode64(pad_base64(header))
127
+ parsed = JSON.parse(decoded)
128
+ parsed["kid"].is_a?(String) ? parsed["kid"] : nil
129
+ rescue StandardError
130
+ nil
131
+ end
132
+
133
+ # Base64url strings from JWTs are unpadded; restore padding before decoding.
134
+ def self.pad_base64(str)
135
+ str += "=" * ((4 - (str.length % 4)) % 4)
136
+ str
137
+ end
138
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Atlas
4
+ # Cursor-pagination helpers.
5
+ #
6
+ # Walk every page of a cursor-paginated BAPI list, yielding items one at a
7
+ # time. Works with any resource +list+ that takes +limit+/+starting_after+
8
+ # (plus any route-specific filters) and returns a
9
+ # +{ "data", "has_more", "next_cursor" }+ page — +users.list+,
10
+ # +organizations.list+, +invitations.list+, +audit_logs.list+, +waitlist.list+.
11
+ #
12
+ # Atlas.paginate(client.users).each { |user| puts user["id"] }
13
+ # Atlas.paginate(client.users, status: "active").to_a
14
+ #
15
+ # Kept as free functions rather than bolted onto every return value: the page
16
+ # is the primitive most callers want, and a caller who needs the whole set
17
+ # opts into the extra round trips explicitly.
18
+ module Pagination
19
+ module_function
20
+
21
+ # Yield every item across every cursor page. Returns an Enumerator when no
22
+ # block is given, so +.each+, +.map+, +.lazy+, +.first(n)+ all work.
23
+ #
24
+ # @param resource [#list] anything exposing a +list(params)+ method.
25
+ # @param params [Hash] filters passed through on every page request.
26
+ def paginate(resource, params = {}, &block)
27
+ return enum_for(:paginate, resource, params) unless block_given?
28
+
29
+ cursor = params[:starting_after] || params["starting_after"]
30
+ loop do
31
+ page = resource.list(params.merge(starting_after: cursor))
32
+ (page["data"] || []).each(&block)
33
+ cursor = page["next_cursor"]
34
+ break if !page["has_more"] || cursor.nil?
35
+ end
36
+ end
37
+
38
+ # Collect every page of a cursor-paginated list into a single array.
39
+ def collect(resource, params = {})
40
+ paginate(resource, params).to_a
41
+ end
42
+ end
43
+
44
+ # Convenience delegators so callers write +Atlas.paginate(...)+.
45
+ def self.paginate(resource, params = {}, &block)
46
+ Pagination.paginate(resource, params, &block)
47
+ end
48
+
49
+ def self.collect(resource, params = {})
50
+ Pagination.collect(resource, params)
51
+ end
52
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Atlas
6
+ module Resources
7
+ # +/v1/actions+ — tenant code run in the hardened isolate at auth triggers,
8
+ # plus the per-trigger binding lists.
9
+ class Actions < Base
10
+ def list
11
+ request(method: :get, path: "/v1/actions")
12
+ end
13
+
14
+ def create(body, idempotency_key: nil)
15
+ request(method: :post, path: "/v1/actions", body: body, idempotency_key: idempotency_key)
16
+ end
17
+
18
+ def get(id)
19
+ request(method: :get, path: "/v1/actions/#{enc(id)}")
20
+ end
21
+
22
+ def update(id, body)
23
+ request(method: :patch, path: "/v1/actions/#{enc(id)}", body: body)
24
+ end
25
+
26
+ def delete(id)
27
+ request(method: :delete, path: "/v1/actions/#{enc(id)}")
28
+ end
29
+
30
+ # Dry-run against a sample event in the sandbox; nothing is persisted.
31
+ def test(id, event = {})
32
+ request(method: :post, path: "/v1/actions/#{enc(id)}/test", body: { event: event })
33
+ end
34
+
35
+ # The actions bound to a trigger, in run order.
36
+ def get_bindings(trigger)
37
+ request(method: :get, path: "/v1/actions/bindings/#{enc(trigger)}")
38
+ end
39
+
40
+ # Replace a trigger's ordered binding set.
41
+ def set_bindings(trigger, action_ids)
42
+ request(method: :put, path: "/v1/actions/bindings/#{enc(trigger)}", body: { action_ids: action_ids })
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Atlas
6
+ module Resources
7
+ # +/v1/actor_tokens+ — impersonation tokens (act-as another subject).
8
+ class ActorTokens < Base
9
+ def create(body, idempotency_key: nil)
10
+ request(method: :post, path: "/v1/actor_tokens", body: body, idempotency_key: idempotency_key)
11
+ end
12
+
13
+ def revoke(id)
14
+ request(method: :post, path: "/v1/actor_tokens/#{enc(id)}/revoke")
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Atlas
6
+ module Resources
7
+ # +/v1/api_keys+ — end-user API keys a tenant mints for its own subjects.
8
+ class ApiKeys < Base
9
+ # Optionally narrowed to one subject via +subject_type+/+subject_id+.
10
+ def list(query = nil)
11
+ request(method: :get, path: "/v1/api_keys", query: query)
12
+ end
13
+
14
+ # Mint a key. The secret is returned once, here.
15
+ def create(body, idempotency_key: nil)
16
+ request(method: :post, path: "/v1/api_keys", body: body, idempotency_key: idempotency_key)
17
+ end
18
+
19
+ # Check a presented secret. Rate-limited — it is an online credential check.
20
+ def verify(secret)
21
+ request(method: :post, path: "/v1/api_keys/verify", body: { secret: secret })
22
+ end
23
+
24
+ def get(id)
25
+ request(method: :get, path: "/v1/api_keys/#{enc(id)}")
26
+ end
27
+
28
+ def update(id, body)
29
+ request(method: :patch, path: "/v1/api_keys/#{enc(id)}", body: body)
30
+ end
31
+
32
+ # Revoke a key. It stays queryable but never authenticates again.
33
+ def delete(id)
34
+ request(method: :delete, path: "/v1/api_keys/#{enc(id)}")
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Atlas
6
+ module Resources
7
+ # +/v1/attack_protection+ — brute-force, breached-password, and related gates.
8
+ class AttackProtection < Base
9
+ def get
10
+ request(method: :get, path: "/v1/attack_protection")
11
+ end
12
+
13
+ def update(body)
14
+ request(method: :patch, path: "/v1/attack_protection", body: body)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Atlas
6
+ module Resources
7
+ # +/v1/audit_logs+ — the instance audit trail, cursor-paginated.
8
+ class AuditLogs < Base
9
+ def list(params = {})
10
+ request(method: :get, path: "/v1/audit_logs", query: params)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Atlas
4
+ module Resources
5
+ # Shared base for every resource namespace. Holds the config-bound transport
6
+ # and forwards +#request+, so each namespace method stays a one-liner naming
7
+ # a verb, a path, and its shapes.
8
+ class Base
9
+ def initialize(transport)
10
+ @transport = transport
11
+ end
12
+
13
+ private
14
+
15
+ # Forward to the transport. Keeps +Transport::OMIT+ as the default body so
16
+ # a method that names no body sends none (no content-type), matching the
17
+ # reference SDKs.
18
+ def request(method:, path:, query: nil, body: Atlas::Transport::OMIT,
19
+ idempotency_key: nil, raw: false)
20
+ @transport.request(
21
+ method: method, path: path, query: query, body: body,
22
+ idempotency_key: idempotency_key, raw: raw
23
+ )
24
+ end
25
+
26
+ # Percent-encode a single path segment (an id, slug, provider name).
27
+ def enc(segment)
28
+ URI.encode_www_form_component(segment.to_s)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Atlas
6
+ module Resources
7
+ # +/v1/billing+ — the plans a tenant defines for its users, and the read-only
8
+ # subscription view.
9
+ class Billing < Base
10
+ def list_plans
11
+ request(method: :get, path: "/v1/billing/plans")
12
+ end
13
+
14
+ def create_plan(body, idempotency_key: nil)
15
+ request(method: :post, path: "/v1/billing/plans", body: body, idempotency_key: idempotency_key)
16
+ end
17
+
18
+ def get_plan(id)
19
+ request(method: :get, path: "/v1/billing/plans/#{enc(id)}")
20
+ end
21
+
22
+ def update_plan(id, body)
23
+ request(method: :patch, path: "/v1/billing/plans/#{enc(id)}", body: body)
24
+ end
25
+
26
+ def delete_plan(id)
27
+ request(method: :delete, path: "/v1/billing/plans/#{enc(id)}")
28
+ end
29
+
30
+ def list_subscriptions(params = nil)
31
+ request(method: :get, path: "/v1/billing/subscriptions", query: params)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Atlas
6
+ module Resources
7
+ # +/v1/bot_signals+ — export the anti-bot signal lake and its training labels,
8
+ # and add analyst labels.
9
+ class BotSignals < Base
10
+ # Page the signal lake, newest first, for an incremental pull.
11
+ def list(query = nil)
12
+ request(method: :get, path: "/v1/bot_signals", query: query)
13
+ end
14
+
15
+ # Page the training labels, newest first.
16
+ def list_labels(query = nil)
17
+ request(method: :get, path: "/v1/bot_labels", query: query)
18
+ end
19
+
20
+ # Attach a training label to a user / device / ip_hash / attempt.
21
+ def create_label(body, idempotency_key: nil)
22
+ request(method: :post, path: "/v1/bot_labels", body: body, idempotency_key: idempotency_key)
23
+ end
24
+ end
25
+ end
26
+ end