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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 80f3e5a3fa6f404e3416096b1315e90babd798263583e3f61a47a33c59bf21b6
4
+ data.tar.gz: 9f243710f91725c971972e8216e54348dd23009bb60ec0189929b168d7f0775c
5
+ SHA512:
6
+ metadata.gz: 00ed1612a4c79bc241ec64c2d1a9d49cd637292c1da84baaf8efbb480ed1afcf344f1ad556aca5825a0ddb629da8f6ad095f1dc6fa90f1303e710ad79c23f4c7
7
+ data.tar.gz: 4e11a62f2eb27a06a15f8512d4d0a9bc85083fc7cdbf058754113b1112affb1be4dc37345c494625cb514ab57a48cd9bd01782b1178e77c13a973ad87c96e3af
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Atlas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # Atlas Ruby SDK
2
+
3
+ The official **Ruby backend SDK** for [Atlas](https://atlasauth.net) — a typed client over the `sk_` Backend API (BAPI), plus local session-token verification. It is the Ruby peer of the TypeScript `@atlasauth/backend` and Python `atlas-backend` SDKs and covers the full management surface (all 45 resource namespaces).
4
+
5
+ > This is a **server-side** SDK. It uses your instance **secret key** (`sk_...`) and must never ship to a browser or mobile app. For client-side sign-in flows use the JS, Swift, Kotlin, or Flutter SDKs.
6
+
7
+ ## Install
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ gem "atlas-auth"
12
+ ```
13
+
14
+ ```sh
15
+ bundle install
16
+ # or
17
+ gem install atlas-auth
18
+ ```
19
+
20
+ Requires Ruby 3.0+. The only runtime dependency is [`jwt`](https://rubygems.org/gems/jwt) (for session-token verification); everything else is standard library.
21
+
22
+ ## Quickstart
23
+
24
+ ```ruby
25
+ require "atlas"
26
+
27
+ atlas = Atlas::Client.new(ENV.fetch("ATLAS_SECRET_KEY")) # "sk_live_..."
28
+
29
+ # Create a user
30
+ user = atlas.users.create(email_address: "ada@example.com", password: "•••••••")
31
+
32
+ # Read one, list many
33
+ atlas.users.get(user["id"])
34
+ page = atlas.users.list(limit: 20)
35
+
36
+ # Walk every page of a cursor-paginated list
37
+ Atlas.paginate(atlas.organizations).each { |org| puts org["name"] }
38
+ all_users = Atlas.collect(atlas.users, status: "active")
39
+ ```
40
+
41
+ ### Configuration
42
+
43
+ ```ruby
44
+ Atlas::Client.new(
45
+ "sk_live_...",
46
+ api_url: "https://api.atlasauth.net", # default: https://api.atlasauth.net
47
+ open_timeout: 30,
48
+ read_timeout: 30,
49
+ )
50
+ ```
51
+
52
+ Every request is authenticated with `Authorization: Bearer <secret_key>`; the key is never logged or placed in a URL.
53
+
54
+ ### Idempotency
55
+
56
+ Write operations accept an `idempotency_key:` so a retried request is applied at most once:
57
+
58
+ ```ruby
59
+ atlas.users.create({ email_address: "ada@example.com" }, idempotency_key: "signup-42")
60
+ ```
61
+
62
+ ## Errors
63
+
64
+ Any non-2xx response raises a typed `Atlas::APIError` carrying the HTTP status and the full `{ errors: [...] }` envelope. Status codes map to subclasses so you can rescue precisely:
65
+
66
+ ```ruby
67
+ begin
68
+ atlas.users.get("user_missing")
69
+ rescue Atlas::NotFoundError => e
70
+ e.status # => 404
71
+ e.code # => "NOT_FOUND" (first error's stable code)
72
+ e.errors # => [{ "code" => "NOT_FOUND", "message" => "..." }]
73
+ rescue Atlas::APIError => e
74
+ # BadRequestError, AuthenticationError, PermissionError, ConflictError,
75
+ # RateLimitError, ServerError, ... all inherit from APIError.
76
+ end
77
+ ```
78
+
79
+ ## Session-token verification
80
+
81
+ Verify Atlas session JWTs **locally** — no round trip per request. The JWKS is cached in-process with kid-miss refetch throttled to once per minute (§7.3).
82
+
83
+ ```ruby
84
+ backend = Atlas::Backend.new(
85
+ jwks_url: "https://api.atlasauth.net/v1/jwks",
86
+ issuer: "https://your-instance.atlasauth.net",
87
+ authorized_parties: ["https://app.example.com"], # optional azp allowlist
88
+ )
89
+
90
+ result = backend.verify(session_jwt)
91
+ if result.ok?
92
+ result.claims["sub"] # the user id
93
+ result.protect(permission: "org:billing:manage")
94
+ else
95
+ result.reason # :invalid, :malformed, :no_keys, :unauthorized_party
96
+ end
97
+
98
+ # From a request's headers (Authorization: Bearer <jwt> or the __session cookie):
99
+ backend.authenticate_request(request.headers)
100
+ ```
101
+
102
+ `verify` is the fast local path. `verify_online` asks Atlas whether the session is still live (a round trip; fails closed on an outage).
103
+
104
+ ### Rails
105
+
106
+ Wire a shared client in an initializer:
107
+
108
+ ```ruby
109
+ # config/initializers/atlas.rb
110
+ ATLAS = Atlas::Client.new(Rails.application.credentials.atlas_secret_key)
111
+ BACKEND = Atlas::Backend.new(
112
+ jwks_url: "https://api.atlasauth.net/v1/jwks",
113
+ issuer: "https://your-instance.atlasauth.net",
114
+ )
115
+ ```
116
+
117
+ Then verify in a `before_action` and branch on `BACKEND.authenticate_request(request.headers)`.
118
+
119
+ ## Resources
120
+
121
+ All 45 namespaces are available on the client: `users`, `sessions`, `organizations`, `roles`, `permissions`, `oauth_clients`, `resource_servers`, `sso_connections`, `scim_tokens`, `scim_provisioning`, `domains`, `waitlist`, `allowlist`, `blocklist`, `attack_protection`, `actor_tokens`, `invitations`, `webhooks`, `sign_in_tokens`, `audit_logs`, `jwt_templates`, `api_keys`, `oauth_providers`, `sso_onboarding`, `fga`, `rate_limit_policy`, `risk_based_mfa`, `bot_signals`, `network_acls`, `managed_waf`, `log_streams`, `branding`, `email_templates`, `sms_templates`, `localizations`, `actions`, `billing`, `messaging`, `import_export`, `data_subject_requests`, `radius_clients`, `lti_platforms`, `instance`, `instance_security`, `tokens`.
122
+
123
+ ## Development
124
+
125
+ ```sh
126
+ bundle install
127
+ rake test # or: ruby -Ilib -Itest test/client_test.rb
128
+ ```
129
+
130
+ ## License
131
+
132
+ MIT
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Atlas
4
+ # Raised by +protect+ when a claims set fails an authorization condition. An
5
+ # app maps this to a 401/403. Mirrors +@atlas/authz+'s +ForbiddenError+.
6
+ class ForbiddenError < Error
7
+ # @return [Symbol] why the check failed (:unauthenticated, :permission, :role, ...).
8
+ attr_reader :reason
9
+ # @return [Hash] the condition that was evaluated.
10
+ attr_reader :condition
11
+
12
+ def initialize(reason, condition = {})
13
+ @reason = reason
14
+ @condition = condition
15
+ super("Forbidden: #{reason}")
16
+ end
17
+ end
18
+
19
+ # The shared authorization primitive — the Ruby peer of +@atlas/authz+.
20
+ #
21
+ # A condition is a hash; an empty condition means "signed in". Supported keys
22
+ # (string or symbol):
23
+ # * +:permission+ — the claims must grant this org permission.
24
+ # * +:role+ — the claims' org role must equal this.
25
+ # * +:any_permission+ — at least one of these permissions is granted.
26
+ # * +:all_permissions+ — every one of these permissions is granted.
27
+ module Authorization
28
+ module_function
29
+
30
+ # True when the claims satisfy the condition.
31
+ def has_from_claims?(claims, condition = {})
32
+ evaluate(claims, condition)[:allowed]
33
+ end
34
+
35
+ # Evaluate a condition, returning +{ allowed:, reason: }+.
36
+ def evaluate(claims, condition = {})
37
+ condition ||= {}
38
+ permission = fetch(condition, :permission)
39
+ role = fetch(condition, :role)
40
+ any_permission = fetch(condition, :any_permission)
41
+ all_permissions = fetch(condition, :all_permissions)
42
+
43
+ # Empty condition: signed in is enough.
44
+ if permission.nil? && role.nil? && any_permission.nil? && all_permissions.nil?
45
+ return { allowed: true, reason: nil }
46
+ end
47
+
48
+ granted = Array(fetch(claims, :org_permissions))
49
+ org_role = fetch(claims, :org_role)
50
+
51
+ return deny(:permission) if permission && !granted.include?(permission)
52
+ return deny(:role) if role && org_role != role
53
+ if any_permission && (Array(any_permission) & granted).empty?
54
+ return deny(:permission)
55
+ end
56
+ if all_permissions && !(Array(all_permissions) - granted).empty?
57
+ return deny(:permission)
58
+ end
59
+
60
+ { allowed: true, reason: nil }
61
+ end
62
+
63
+ def deny(reason)
64
+ { allowed: false, reason: reason }
65
+ end
66
+
67
+ # Look a key up under both its symbol and string form.
68
+ def fetch(hash, key)
69
+ return nil unless hash
70
+
71
+ if hash.key?(key)
72
+ hash[key]
73
+ elsif hash.key?(key.to_s)
74
+ hash[key.to_s]
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "transport"
4
+
5
+ require_relative "resources/users"
6
+ require_relative "resources/sessions"
7
+ require_relative "resources/organizations"
8
+ require_relative "resources/roles"
9
+ require_relative "resources/oauth_clients"
10
+ require_relative "resources/resource_servers"
11
+ require_relative "resources/sso_connections"
12
+ require_relative "resources/scim_tokens"
13
+ require_relative "resources/domains"
14
+ require_relative "resources/waitlist"
15
+ require_relative "resources/restrictions"
16
+ require_relative "resources/attack_protection"
17
+ require_relative "resources/actor_tokens"
18
+ require_relative "resources/invitations"
19
+ require_relative "resources/webhooks"
20
+ require_relative "resources/sign_in_tokens"
21
+ require_relative "resources/audit_logs"
22
+ require_relative "resources/jwt_templates"
23
+ require_relative "resources/api_keys"
24
+ require_relative "resources/oauth_providers"
25
+ require_relative "resources/sso_onboarding"
26
+ require_relative "resources/scim_provisioning"
27
+ require_relative "resources/fga"
28
+ require_relative "resources/rate_limit"
29
+ require_relative "resources/risk_based_mfa"
30
+ require_relative "resources/bot_signals"
31
+ require_relative "resources/network_acls"
32
+ require_relative "resources/managed_waf"
33
+ require_relative "resources/log_streams"
34
+ require_relative "resources/branding"
35
+ require_relative "resources/email_templates"
36
+ require_relative "resources/sms_templates"
37
+ require_relative "resources/localizations"
38
+ require_relative "resources/actions"
39
+ require_relative "resources/billing"
40
+ require_relative "resources/messaging"
41
+ require_relative "resources/import_export"
42
+ require_relative "resources/data_subject_requests"
43
+ require_relative "resources/radius_clients"
44
+ require_relative "resources/lti_platforms"
45
+ require_relative "resources/instance"
46
+ require_relative "resources/instance_security"
47
+ require_relative "resources/tokens"
48
+
49
+ module Atlas
50
+ # The typed management client for the Atlas Backend API — the secret-key
51
+ # surface, the Ruby peer of TypeScript's +createAtlasClient+.
52
+ #
53
+ # Each namespace is one memoized accessor handing the shared, config-bound
54
+ # transport to a resource class, so this file reads as a table of contents for
55
+ # the whole surface.
56
+ #
57
+ # atlas = Atlas::Client.new("sk_live_...")
58
+ # user = atlas.users.create(email_address: "ada@example.com")
59
+ # Atlas.paginate(atlas.organizations).each { |org| puts org["name"] }
60
+ class Client
61
+ # @param secret_key [String] the instance secret key (+sk_...+). Sent as
62
+ # +Authorization: Bearer <key>+ on every request; never logged, never in a URL.
63
+ # @param api_url [String] base URL of the instance's Backend API.
64
+ # @param http [#call, nil] injectable requester (tests, custom transport).
65
+ # @param open_timeout [Numeric] connect timeout, seconds.
66
+ # @param read_timeout [Numeric] read timeout, seconds.
67
+ def initialize(secret_key, api_url: DEFAULT_API_URL, http: nil,
68
+ open_timeout: 30, read_timeout: 30)
69
+ @transport = Transport.new(
70
+ secret_key: secret_key, api_url: api_url, http: http,
71
+ open_timeout: open_timeout, read_timeout: read_timeout
72
+ )
73
+ end
74
+
75
+ # @return [Atlas::Transport] the underlying config-bound transport.
76
+ attr_reader :transport
77
+
78
+ def users = @users ||= Resources::Users.new(@transport)
79
+ def sessions = @sessions ||= Resources::Sessions.new(@transport)
80
+ def organizations = @organizations ||= Resources::Organizations.new(@transport)
81
+ def roles = @roles ||= Resources::Roles.new(@transport)
82
+ def permissions = @permissions ||= Resources::Permissions.new(@transport)
83
+ def oauth_clients = @oauth_clients ||= Resources::OAuthClients.new(@transport)
84
+ def resource_servers = @resource_servers ||= Resources::ResourceServers.new(@transport)
85
+ def sso_connections = @sso_connections ||= Resources::SsoConnections.new(@transport)
86
+ def scim_tokens = @scim_tokens ||= Resources::ScimTokens.new(@transport)
87
+ def domains = @domains ||= Resources::Domains.new(@transport)
88
+ def waitlist = @waitlist ||= Resources::Waitlist.new(@transport)
89
+ def allowlist = @allowlist ||= Resources::Restriction.new(@transport, "/v1/allowlist_identifiers")
90
+ def blocklist = @blocklist ||= Resources::Restriction.new(@transport, "/v1/blocklist_identifiers")
91
+ def attack_protection = @attack_protection ||= Resources::AttackProtection.new(@transport)
92
+ def actor_tokens = @actor_tokens ||= Resources::ActorTokens.new(@transport)
93
+ def invitations = @invitations ||= Resources::Invitations.new(@transport)
94
+ def webhooks = @webhooks ||= Resources::Webhooks.new(@transport)
95
+ def sign_in_tokens = @sign_in_tokens ||= Resources::SignInTokens.new(@transport)
96
+ def audit_logs = @audit_logs ||= Resources::AuditLogs.new(@transport)
97
+ def jwt_templates = @jwt_templates ||= Resources::JwtTemplates.new(@transport)
98
+ def api_keys = @api_keys ||= Resources::ApiKeys.new(@transport)
99
+ def oauth_providers = @oauth_providers ||= Resources::OAuthProviders.new(@transport)
100
+ def sso_onboarding = @sso_onboarding ||= Resources::SsoOnboarding.new(@transport)
101
+ def scim_provisioning = @scim_provisioning ||= Resources::ScimProvisioning.new(@transport)
102
+ def fga = @fga ||= Resources::Fga.new(@transport)
103
+ def rate_limit_policy = @rate_limit_policy ||= Resources::RateLimitPolicy.new(@transport)
104
+ def risk_based_mfa = @risk_based_mfa ||= Resources::RiskBasedMfa.new(@transport)
105
+ def bot_signals = @bot_signals ||= Resources::BotSignals.new(@transport)
106
+ def network_acls = @network_acls ||= Resources::NetworkAcls.new(@transport)
107
+ def managed_waf = @managed_waf ||= Resources::ManagedWaf.new(@transport)
108
+ def log_streams = @log_streams ||= Resources::LogStreams.new(@transport)
109
+ def branding = @branding ||= Resources::Branding.new(@transport)
110
+ def email_templates = @email_templates ||= Resources::EmailTemplates.new(@transport)
111
+ def sms_templates = @sms_templates ||= Resources::SmsTemplates.new(@transport)
112
+ def localizations = @localizations ||= Resources::Localizations.new(@transport)
113
+ def actions = @actions ||= Resources::Actions.new(@transport)
114
+ def billing = @billing ||= Resources::Billing.new(@transport)
115
+ def messaging = @messaging ||= Resources::Messaging.new(@transport)
116
+ def import_export = @import_export ||= Resources::ImportExport.new(@transport)
117
+ def data_subject_requests = @data_subject_requests ||= Resources::DataSubjectRequests.new(@transport)
118
+ def radius_clients = @radius_clients ||= Resources::RadiusClients.new(@transport)
119
+ def lti_platforms = @lti_platforms ||= Resources::LtiPlatforms.new(@transport)
120
+ def instance = @instance ||= Resources::Instance.new(@transport)
121
+ def instance_security = @instance_security ||= Resources::InstanceSecurity.new(@transport)
122
+ def tokens = @tokens ||= Resources::Tokens.new(@transport)
123
+ end
124
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Atlas
4
+ # Base class for every error the SDK raises. Rescue +Atlas::Error+ to catch
5
+ # anything the gem can throw.
6
+ class Error < StandardError; end
7
+
8
+ # Raised before a request goes out when the client is misconfigured
9
+ # (e.g. a missing secret key).
10
+ class ConfigurationError < Error; end
11
+
12
+ # Raised on any non-2xx response from the Backend API.
13
+ #
14
+ # Atlas answers a failed BAPI call with the §9.1 envelope:
15
+ #
16
+ # { "errors": [ { "code", "message", "param?", "meta?" } ] }
17
+ #
18
+ # +code+ is the stable, machine-readable part of that contract — integrators
19
+ # branch on it (+LAST_ADMIN+, +NOT_FOUND+, +SCOPE_MISSING+, ...) — so it is
20
+ # surfaced first-class here rather than buried in a parsed body. The raw
21
+ # +errors+ array and the HTTP +status+ are both kept so a caller can inspect
22
+ # +param+/+meta+ (e.g. a rate limit's +retry_after+) when they need to.
23
+ class APIError < Error
24
+ # @return [Integer] HTTP status of the failed response.
25
+ attr_reader :status
26
+
27
+ # @return [Array<Hash>] the full §9.1 error envelope, in order. Each item
28
+ # carries string keys "code", "message", and optionally "param"/"meta".
29
+ attr_reader :errors
30
+
31
+ def initialize(status, errors, message = nil)
32
+ @status = status
33
+ @errors = errors || []
34
+ first = @errors.first
35
+ super(message || (first && (first["message"] || first[:message])) ||
36
+ "Atlas API request failed with status #{status}")
37
+ end
38
+
39
+ # The first error's stable code — the field callers branch on most.
40
+ # @return [String, nil]
41
+ def code
42
+ first = @errors.first
43
+ first && (first["code"] || first[:code])
44
+ end
45
+
46
+ # True when any error in the envelope carries the given stable code.
47
+ def has_code?(code)
48
+ @errors.any? { |e| (e["code"] || e[:code]) == code }
49
+ end
50
+
51
+ # A short human summary for logs. Never includes the secret key.
52
+ def to_s
53
+ "#{self.class.name}: status=#{@status} code=#{code.inspect} #{super}"
54
+ end
55
+ end
56
+
57
+ # 400 — the request was malformed or a parameter was invalid.
58
+ class BadRequestError < APIError; end
59
+ # 401 — the secret key is missing, wrong, or revoked.
60
+ class AuthenticationError < APIError; end
61
+ # 403 — authenticated but not permitted (e.g. scope missing).
62
+ class PermissionError < APIError; end
63
+ # 404 — the resource does not exist for this instance.
64
+ class NotFoundError < APIError; end
65
+ # 409 — a conflict/terminal-state error (e.g. re-actioning a DSAR).
66
+ class ConflictError < APIError; end
67
+ # 422 — the request was well-formed but semantically rejected.
68
+ class UnprocessableEntityError < APIError; end
69
+ # 429 — rate limited. Inspect +errors.first["meta"]["retry_after"]+.
70
+ class RateLimitError < APIError; end
71
+ # 5xx — an Atlas-side failure.
72
+ class ServerError < APIError; end
73
+
74
+ module ErrorFactory
75
+ # Map an HTTP status onto the most specific error class.
76
+ STATUS_MAP = {
77
+ 400 => BadRequestError,
78
+ 401 => AuthenticationError,
79
+ 403 => PermissionError,
80
+ 404 => NotFoundError,
81
+ 409 => ConflictError,
82
+ 422 => UnprocessableEntityError,
83
+ 429 => RateLimitError
84
+ }.freeze
85
+
86
+ # Build the right typed error from a status + parsed error items.
87
+ def self.build(status, errors)
88
+ klass = STATUS_MAP[status] || (status >= 500 ? ServerError : APIError)
89
+ klass.new(status, errors)
90
+ end
91
+ end
92
+ end