cerbos 0.11.0 → 0.12.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -1
  3. data/README.md +30 -2
  4. data/cerbos.gemspec +1 -0
  5. data/lib/cerbos/abstract_class.rb +10 -0
  6. data/lib/cerbos/client.rb +24 -41
  7. data/lib/cerbos/error.rb +79 -10
  8. data/lib/cerbos/hub/access_token.rb +137 -0
  9. data/lib/cerbos/hub/circuit_breaker.rb +185 -0
  10. data/lib/cerbos/hub/service.rb +18 -0
  11. data/lib/cerbos/hub/stores/client.rb +162 -0
  12. data/lib/cerbos/hub/stores/error.rb +108 -0
  13. data/lib/cerbos/hub/stores/file.rb +28 -0
  14. data/lib/cerbos/hub/stores/input/change_details/origin.rb +144 -0
  15. data/lib/cerbos/hub/stores/input/change_details/uploader.rb +38 -0
  16. data/lib/cerbos/hub/stores/input/change_details.rb +52 -0
  17. data/lib/cerbos/hub/stores/input/file_filter.rb +30 -0
  18. data/lib/cerbos/hub/stores/input/file_modification_condition.rb +34 -0
  19. data/lib/cerbos/hub/stores/input/file_operation.rb +66 -0
  20. data/lib/cerbos/hub/stores/input/string_match.rb +88 -0
  21. data/lib/cerbos/hub/stores/input.rb +17 -0
  22. data/lib/cerbos/hub/stores/output/get_files.rb +31 -0
  23. data/lib/cerbos/hub/stores/output/list_files.rb +31 -0
  24. data/lib/cerbos/hub/stores/output/modify_files.rb +35 -0
  25. data/lib/cerbos/hub/stores/output/replace_files.rb +43 -0
  26. data/lib/cerbos/hub/stores/output.rb +16 -0
  27. data/lib/cerbos/hub/stores.rb +15 -0
  28. data/lib/cerbos/hub.rb +12 -0
  29. data/lib/cerbos/input.rb +2 -1
  30. data/lib/cerbos/protobuf/buf/validate/validate_pb.rb +6 -6
  31. data/lib/cerbos/protobuf/cerbos/cloud/apikey/v1/apikey_pb.rb +26 -0
  32. data/lib/cerbos/protobuf/cerbos/cloud/apikey/v1/apikey_services_pb.rb +32 -0
  33. data/lib/cerbos/protobuf/cerbos/cloud/store/v1/store_pb.rb +52 -0
  34. data/lib/cerbos/protobuf/cerbos/cloud/store/v1/store_services_pb.rb +35 -0
  35. data/lib/cerbos/protobuf/cerbos/effect/v1/effect_pb.rb +1 -1
  36. data/lib/cerbos/protobuf/cerbos/engine/v1/engine_pb.rb +2 -2
  37. data/lib/cerbos/protobuf/cerbos/request/v1/request_pb.rb +2 -2
  38. data/lib/cerbos/protobuf/cerbos/response/v1/response_pb.rb +2 -2
  39. data/lib/cerbos/protobuf/cerbos/schema/v1/schema_pb.rb +1 -1
  40. data/lib/cerbos/protobuf/cerbos/svc/v1/svc_pb.rb +1 -1
  41. data/lib/cerbos/protobuf/google/api/annotations_pb.rb +1 -1
  42. data/lib/cerbos/protobuf/google/api/field_behavior_pb.rb +1 -1
  43. data/lib/cerbos/protobuf/google/api/http_pb.rb +2 -2
  44. data/lib/cerbos/protobuf/google/api/visibility_pb.rb +19 -0
  45. data/lib/cerbos/protobuf/grpc/health/v1/health_pb.rb +1 -1
  46. data/lib/cerbos/protobuf/protoc-gen-openapiv2/options/annotations_pb.rb +1 -1
  47. data/lib/cerbos/protobuf/protoc-gen-openapiv2/options/openapiv2_pb.rb +1 -1
  48. data/lib/cerbos/protobuf.rb +2 -0
  49. data/lib/cerbos/service.rb +33 -0
  50. data/lib/cerbos/version.rb +1 -1
  51. data/lib/cerbos.rb +7 -1
  52. data/yard_extensions.rb +8 -1
  53. metadata +45 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6069f0cfe470837489697c30605cb94410a15f0944c5c32f3065e67c11eada15
4
- data.tar.gz: 89737c4e0d7a1b268ff2ea2a61f74da1f3fe0e0ada8e7cbd98d1a1c1e0240065
3
+ metadata.gz: b1f96bbfba8e309d992b65cff1615eed19a8731c1b16c69f7cd9eb578e8f3f11
4
+ data.tar.gz: 934e07596201c3638201af7546ac1bd428ff2c4779cb8b25916a66fbb2d58fc4
5
5
  SHA512:
6
- metadata.gz: e0f3ad9e33541ae436d2791a1c0fa5869524894ded9eaeba791382a472bf7315eb36d36e1901eed12e50341922b80109f11b1ddedd9bd62fef64522f359a4cee
7
- data.tar.gz: c6480ab5a0aa49a25ff2579bae299fb3f2b976163bfe0455070c134bfef8fce133f0bbb3b2ffcfe8d55af1996316e2e2dd72b653db028b65471a1fdc0c5f58b4
6
+ metadata.gz: 97b55e3a336f6389b899e62c3f19bf5411d85c319866b46ccc736c08d3869b89dcf930887fe650c6d151841047009b8ceadca315b83ac5214b5a3059cc2f77e5
7
+ data.tar.gz: fc2c68df0275eb67f3f2f6000f3f452d045f1de5dab1d56e593e3e5cb631e16052d59dbf1af293b5466adffcc0f7518cd5c210a3b0edec832b5524b3b3712f6e
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  No notable changes.
4
4
 
5
+ ## [0.12.0] - 2025-08-12
6
+
7
+ ### Added
8
+
9
+ - `Cerbos::Hub::Stores::Client` for interacting with policy stores in Cerbos Hub ([#251](https://github.com/cerbos/cerbos-sdk-ruby/pull/251))
10
+
5
11
  ## [0.11.0] - 2025-06-03
6
12
 
7
13
  ### Added
@@ -113,7 +119,8 @@ No notable changes.
113
119
 
114
120
  - Initial implementation of `Cerbos::Client` ([#2](https://github.com/cerbos/cerbos-sdk-ruby/pull/2))
115
121
 
116
- [Unreleased]: https://github.com/cerbos/cerbos-sdk-ruby/compare/v0.11.0...HEAD
122
+ [Unreleased]: https://github.com/cerbos/cerbos-sdk-ruby/compare/v0.12.0...HEAD
123
+ [0.12.0]: https://github.com/cerbos/cerbos-sdk-ruby/compare/v0.11.0...v0.12.0
117
124
  [0.11.0]: https://github.com/cerbos/cerbos-sdk-ruby/compare/v0.10.0...v0.11.0
118
125
  [0.10.0]: https://github.com/cerbos/cerbos-sdk-ruby/compare/v0.9.1...v0.10.0
119
126
  [0.9.1]: https://github.com/cerbos/cerbos-sdk-ruby/compare/v0.9.0...v0.9.1
data/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [Cerbos](https://cerbos.dev) helps you super-charge your authorization implementation by writing context-aware access control policies for your application resources.
8
8
  Author access rules using an intuitive YAML configuration language, use your Git-ops infrastructure to test and deploy them, and make simple API requests to the Cerbos policy decision point (PDP) server to evaluate the policies and make dynamic access decisions.
9
9
 
10
- The Cerbos Ruby SDK makes it easy to interact with the Cerbos PDP from your Ruby applications.
10
+ The Cerbos Ruby SDK makes it easy to interact with the [Cerbos PDP](https://www.cerbos.dev/product-cerbos-pdp) and [Cerbos Hub](https://www.cerbos.dev/product-cerbos-hub) from your Ruby applications.
11
11
 
12
12
  ## Prerequisites
13
13
 
@@ -30,7 +30,11 @@ $ gem install cerbos
30
30
 
31
31
  ## Example usage
32
32
 
33
+ ### Cerbos PDP
34
+
33
35
  ```ruby
36
+ require "cerbos"
37
+
34
38
  client = Cerbos::Client.new("localhost:3593", tls: false)
35
39
 
36
40
  decision = client.check_resource(
@@ -52,12 +56,36 @@ decision.allow?("view") # => true
52
56
  decision.allow?("edit") # => false
53
57
  ```
54
58
 
55
- For more details, [see the `Client` documentation](https://www.rubydoc.info/gems/cerbos/Cerbos/Client).
59
+ For more details, [see the `Cerbos::Client` documentation](https://www.rubydoc.info/gems/cerbos/Cerbos/Client).
60
+
61
+ ### Cerbos Hub [policy stores](https://docs.cerbos.dev/cerbos-hub/policy-stores)
62
+
63
+ ```ruby
64
+ require "cerbos"
65
+
66
+ client = Cerbos::Hub::Stores::Client.new(
67
+ client_id: ENV.fetch("CERBOS_HUB_CLIENT_ID"),
68
+ client_secret: ENV.fetch("CERBOS_HUB_CLIENT_SECRET")
69
+ )
70
+
71
+ response = client.modify_files(
72
+ store_id: ENV.fetch("CERBOS_HUB_STORE_ID"),
73
+ operations: [
74
+ {add_or_update: {path: "foo.yaml", contents: File.binread("path/to/foo.yaml")}},
75
+ {delete: "bar.yaml"}
76
+ ]
77
+ )
78
+
79
+ puts response.new_store_version
80
+ ```
81
+
82
+ For more details, [see the `Cerbos::Hub::Stores::Client` documentation](https://www.rubydoc.info/gems/cerbos/Cerbos/Hub/Stores/Client).
56
83
 
57
84
  ## Further reading
58
85
 
59
86
  - [API reference](https://www.rubydoc.info/gems/cerbos/Cerbos)
60
87
  - [Cerbos documentation](https://docs.cerbos.dev)
88
+ - [Cerbos Hub documentation](https://docs.cerbos.dev/cerbos-hub/)
61
89
 
62
90
  ## Get help
63
91
 
data/cerbos.gemspec CHANGED
@@ -32,6 +32,7 @@ Gem::Specification.new do |spec|
32
32
  ]
33
33
 
34
34
  spec.required_ruby_version = ">= 3.2.0"
35
+ spec.add_dependency "concurrent-ruby", "~> 1.2"
35
36
  spec.add_dependency "grpc", "~> 1.52"
36
37
  spec.add_dependency "google-protobuf", ">= 3.21.12", "< 5.0"
37
38
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerbos
4
+ # @private
5
+ module AbstractClass
6
+ def initialize
7
+ raise NoMethodError, "Can't initialize #{self.class.name} directly, initialize a subclass instead (#{self.class.subclasses.map(&:name).join(", ")})"
8
+ end
9
+ end
10
+ end
data/lib/cerbos/client.rb CHANGED
@@ -39,32 +39,31 @@ module Cerbos
39
39
  # @example Invoke a callback when input fails schema validation
40
40
  # client = Cerbos::Client.new("localhost:3593", tls: false, on_validation_error: ->(validation_errors) { do_something_with validation_errors })
41
41
  def initialize(target, tls:, grpc_channel_args: {}, grpc_metadata: {}, on_validation_error: :return, playground_instance: nil, timeout: nil)
42
- @grpc_metadata = grpc_metadata.transform_keys(&:to_sym)
43
42
  @on_validation_error = on_validation_error
44
43
 
45
- handle_errors do
44
+ Error.handle do
46
45
  credentials = tls ? tls.to_channel_credentials : :this_channel_is_insecure
47
46
 
48
47
  unless playground_instance.nil?
49
48
  credentials = credentials.compose(GRPC::Core::CallCredentials.new(->(*) { {"playground-instance" => playground_instance} }))
50
49
  end
51
50
 
52
- channel_args = grpc_channel_args.merge({
53
- "grpc.primary_user_agent" => [grpc_channel_args["grpc.primary_user_agent"], "cerbos-sdk-ruby/#{VERSION}"].compact.join(" ")
54
- })
55
-
56
- @cerbos_service = Protobuf::Cerbos::Svc::V1::CerbosService::Stub.new(
57
- target,
58
- credentials,
59
- channel_args: channel_args,
60
- timeout: timeout
51
+ @cerbos_service = Service.new(
52
+ stub: Protobuf::Cerbos::Svc::V1::CerbosService::Stub,
53
+ target:,
54
+ credentials:,
55
+ grpc_channel_args:,
56
+ grpc_metadata:,
57
+ timeout:
61
58
  )
62
59
 
63
- @health_service = Protobuf::Grpc::Health::V1::Health::Stub.new(
64
- target,
65
- credentials,
66
- channel_args: channel_args,
67
- timeout: timeout
60
+ @health_service = Service.new(
61
+ stub: Protobuf::Grpc::Health::V1::Health::Stub,
62
+ target:,
63
+ credentials:,
64
+ grpc_channel_args:,
65
+ grpc_metadata:,
66
+ timeout:
68
67
  )
69
68
  end
70
69
  end
@@ -111,10 +110,10 @@ module Cerbos
111
110
  # admin_api = client.check_health(service: "cerbos.svc.v1.CerbosAdminService")
112
111
  # admin_api.status # => :DISABLED
113
112
  def check_health(service: "cerbos.svc.v1.CerbosService", grpc_metadata: {})
114
- handle_errors do
113
+ Error.handle do
115
114
  request = Protobuf::Grpc::Health::V1::HealthCheckRequest.new(service: service)
116
115
 
117
- response = perform_request(@health_service, :check, request, grpc_metadata)
116
+ response = @health_service.call(:check, request, grpc_metadata)
118
117
 
119
118
  Output::HealthCheck.from_protobuf(response)
120
119
  end
@@ -145,7 +144,7 @@ module Cerbos
145
144
  #
146
145
  # decision.allow?("view") # => true
147
146
  def check_resource(principal:, resource:, actions:, aux_data: nil, include_metadata: false, request_id: SecureRandom.uuid, grpc_metadata: {})
148
- handle_errors do
147
+ Error.handle do
149
148
  check_resources(
150
149
  principal: principal,
151
150
  resources: [Input::ResourceCheck.new(resource: resource, actions: actions)],
@@ -185,7 +184,7 @@ module Cerbos
185
184
  #
186
185
  # decision.allow?(resource: {kind: "document", id: "1"}, action: "view") # => true
187
186
  def check_resources(principal:, resources:, aux_data: nil, include_metadata: false, request_id: SecureRandom.uuid, grpc_metadata: {})
188
- handle_errors do
187
+ Error.handle do
189
188
  request = Protobuf::Cerbos::Request::V1::CheckResourcesRequest.new(
190
189
  principal: Input.coerce_required(principal, Input::Principal).to_protobuf,
191
190
  resources: Input.coerce_array(resources, Input::ResourceCheck).map(&:to_protobuf),
@@ -194,7 +193,7 @@ module Cerbos
194
193
  request_id: request_id
195
194
  )
196
195
 
197
- response = perform_request(@cerbos_service, :check_resources, request, grpc_metadata)
196
+ response = @cerbos_service.call(:check_resources, request, grpc_metadata)
198
197
 
199
198
  Output::CheckResources.from_protobuf(response).tap do |output|
200
199
  handle_validation_errors output
@@ -225,7 +224,7 @@ module Cerbos
225
224
  # plan.conditional? # => true
226
225
  # plan.condition # => #<Cerbos::Output::PlanResources::Expression ...>
227
226
  def plan_resources(principal:, resource:, action: "", actions: [], aux_data: nil, include_metadata: false, request_id: SecureRandom.uuid, grpc_metadata: {})
228
- handle_errors do
227
+ Error.handle do
229
228
  request = Protobuf::Cerbos::Request::V1::PlanResourcesRequest.new(
230
229
  principal: Input.coerce_required(principal, Input::Principal).to_protobuf,
231
230
  resource: Input.coerce_required(resource, Input::ResourceQuery).to_protobuf,
@@ -236,7 +235,7 @@ module Cerbos
236
235
  request_id: request_id
237
236
  )
238
237
 
239
- response = perform_request(@cerbos_service, :plan_resources, request, grpc_metadata)
238
+ response = @cerbos_service.call(:plan_resources, request, grpc_metadata)
240
239
 
241
240
  Output::PlanResources.from_protobuf(response).tap do |output|
242
241
  handle_validation_errors output
@@ -250,10 +249,10 @@ module Cerbos
250
249
  #
251
250
  # @return [Output::ServerInfo]
252
251
  def server_info(grpc_metadata: {})
253
- handle_errors do
252
+ Error.handle do
254
253
  request = Protobuf::Cerbos::Request::V1::ServerInfoRequest.new
255
254
 
256
- response = perform_request(@cerbos_service, :server_info, request, grpc_metadata)
255
+ response = @cerbos_service.call(:server_info, request, grpc_metadata)
257
256
 
258
257
  Output::ServerInfo.from_protobuf(response)
259
258
  end
@@ -261,18 +260,6 @@ module Cerbos
261
260
 
262
261
  private
263
262
 
264
- def handle_errors
265
- yield
266
- rescue Error
267
- raise
268
- rescue ArgumentError, TypeError => error
269
- raise Error::InvalidArgument.new(details: error.message)
270
- rescue GRPC::BadStatus => error
271
- raise Error::NotOK.from_grpc_bad_status(error)
272
- rescue => error
273
- raise Error, error.message
274
- end
275
-
276
263
  def handle_validation_errors(output)
277
264
  return if @on_validation_error == :return
278
265
 
@@ -283,9 +270,5 @@ module Cerbos
283
270
 
284
271
  @on_validation_error.call validation_errors
285
272
  end
286
-
287
- def perform_request(service, rpc, request, metadata)
288
- service.public_send(rpc, request, metadata: @grpc_metadata.merge(metadata.transform_keys(&:to_sym)))
289
- end
290
273
  end
291
274
  end
data/lib/cerbos/error.rb CHANGED
@@ -56,80 +56,149 @@ module Cerbos
56
56
  end
57
57
  end
58
58
 
59
- # The gRPC operation was cancelled.
59
+ # The operation was aborted.
60
+ class Aborted < NotOK
61
+ def initialize(code: GRPC::Core::StatusCodes::ABORTED, **args)
62
+ super
63
+ end
64
+ end
65
+
66
+ # The entity that the client attempted to create already exists.
67
+ class AlreadyExists < NotOK
68
+ def initialize(code: GRPC::Core::StatusCodes::ALREADY_EXISTS, **args)
69
+ super
70
+ end
71
+ end
72
+
73
+ # The operation was cancelled.
60
74
  class Cancelled < NotOK
61
75
  def initialize(code: GRPC::Core::StatusCodes::CANCELLED, **args)
62
76
  super
63
77
  end
64
78
  end
65
79
 
66
- # The gRPC operation timed out.
80
+ # The operation resulted in unrecoverable data loss or corruption.
81
+ class DataLoss < NotOK
82
+ def initialize(code: GRPC::Core::StatusCodes::DATA_LOSS, **args)
83
+ super
84
+ end
85
+ end
86
+
87
+ # The operation timed out.
67
88
  class DeadlineExceeded < NotOK
68
89
  def initialize(code: GRPC::Core::StatusCodes::DEADLINE_EXCEEDED, **args)
69
90
  super
70
91
  end
71
92
  end
72
93
 
73
- # The gRPC operation failed due to an internal error.
94
+ # The operation was rejected because the system is not in a state required for the operation's execution.
95
+ class FailedPrecondition < NotOK
96
+ def initialize(code: GRPC::Core::StatusCodes::FAILED_PRECONDITION, **args)
97
+ super
98
+ end
99
+ end
100
+
101
+ # The operation failed due to an internal error.
74
102
  class InternalError < NotOK
75
103
  def initialize(code: GRPC::Core::StatusCodes::INTERNAL, **args)
76
104
  super
77
105
  end
78
106
  end
79
107
 
80
- # The gRPC operation was rejected because an argument was invalid.
108
+ # The operation was rejected because an argument was invalid.
81
109
  class InvalidArgument < NotOK
82
110
  def initialize(code: GRPC::Core::StatusCodes::INVALID_ARGUMENT, **args)
83
111
  super
84
112
  end
85
113
  end
86
114
 
87
- # The gRPC operation was rejected because the requested entity was not found.
115
+ # The requested entity was not found.
88
116
  class NotFound < NotOK
89
117
  def initialize(code: GRPC::Core::StatusCodes::NOT_FOUND, **args)
90
118
  super
91
119
  end
92
120
  end
93
121
 
94
- # The gRPC operation failed because a resource has been exhausted.
122
+ # The operation was attempted past the valid range.
123
+ class OutOfRange < NotOK
124
+ def initialize(code: GRPC::Core::StatusCodes::OUT_OF_RANGE, **args)
125
+ super
126
+ end
127
+ end
128
+
129
+ # The caller does not have permission to execute the specified operation.
130
+ class PermissionDenied < NotOK
131
+ def initialize(code: GRPC::Core::StatusCodes::PERMISSION_DENIED, **args)
132
+ super
133
+ end
134
+ end
135
+
136
+ # The operation failed because a resource has been exhausted.
95
137
  class ResourceExhausted < NotOK
96
138
  def initialize(code: GRPC::Core::StatusCodes::RESOURCE_EXHAUSTED, **args)
97
139
  super
98
140
  end
99
141
  end
100
142
 
101
- # The gRPC operation was rejected because it did not have valid authentication credentials.
143
+ # The operation was rejected because it did not have valid authentication credentials.
102
144
  class Unauthenticated < NotOK
103
145
  def initialize(code: GRPC::Core::StatusCodes::UNAUTHENTICATED, **args)
104
146
  super
105
147
  end
106
148
  end
107
149
 
108
- # The gRPC operation failed because the service is unavailable.
150
+ # The operation failed because the service is unavailable.
109
151
  class Unavailable < NotOK
110
152
  def initialize(code: GRPC::Core::StatusCodes::UNAVAILABLE, **args)
111
153
  super
112
154
  end
113
155
  end
114
156
 
115
- # The gRPC operation is not supported.
157
+ # The operation is not supported.
116
158
  class Unimplemented < NotOK
117
159
  def initialize(code: GRPC::Core::StatusCodes::UNIMPLEMENTED, **args)
118
160
  super
119
161
  end
120
162
  end
121
163
 
164
+ # An unknown error occurred.
165
+ class Unknown < NotOK
166
+ def initialize(code: GRPC::Core::StatusCodes::UNKNOWN, **args)
167
+ super
168
+ end
169
+ end
170
+
122
171
  GRPC_BAD_STATUS_ERROR_CLASS = {
172
+ GRPC::Aborted => Aborted,
173
+ GRPC::AlreadyExists => AlreadyExists,
123
174
  GRPC::Cancelled => Cancelled,
175
+ GRPC::DataLoss => DataLoss,
124
176
  GRPC::DeadlineExceeded => DeadlineExceeded,
177
+ GRPC::FailedPrecondition => FailedPrecondition,
125
178
  GRPC::Internal => InternalError,
126
179
  GRPC::InvalidArgument => InvalidArgument,
127
180
  GRPC::NotFound => NotFound,
181
+ GRPC::OutOfRange => OutOfRange,
182
+ GRPC::PermissionDenied => PermissionDenied,
128
183
  GRPC::ResourceExhausted => ResourceExhausted,
129
184
  GRPC::Unauthenticated => Unauthenticated,
130
185
  GRPC::Unavailable => Unavailable,
131
- GRPC::Unimplemented => Unimplemented
186
+ GRPC::Unimplemented => Unimplemented,
187
+ GRPC::Unknown => Unknown
132
188
  }.freeze
133
189
  private_constant :GRPC_BAD_STATUS_ERROR_CLASS
190
+
191
+ # @private
192
+ def self.handle
193
+ yield
194
+ rescue Error
195
+ raise
196
+ rescue ArgumentError, TypeError => error
197
+ raise InvalidArgument.new(details: error.message)
198
+ rescue GRPC::BadStatus => error
199
+ raise NotOK.from_grpc_bad_status(error)
200
+ rescue => error
201
+ raise Error, error.message
202
+ end
134
203
  end
135
204
  end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerbos
4
+ module Hub
5
+ # @private
6
+ class AccessToken
7
+ def initialize(client_id:, client_secret:, **options)
8
+ @client_id = client_id
9
+ @client_secret = client_secret
10
+
11
+ @lock = Concurrent::ReadWriteLock.new
12
+ @result = None.new
13
+
14
+ @service = Cerbos::Service.new(
15
+ stub: Protobuf::Cerbos::Cloud::Apikey::V1::ApiKeyService::Stub,
16
+ credentials: GRPC::Core::ChannelCredentials.new,
17
+ **options
18
+ )
19
+ end
20
+
21
+ def fetch
22
+ token = @lock.with_read_lock { @result.token }
23
+ return token unless token.nil?
24
+
25
+ @lock.with_write_lock do
26
+ token = @result.token
27
+ return token unless token.nil?
28
+
29
+ attempt = @result.next_attempt
30
+ attempted_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
+
32
+ Error.handle do
33
+ request = Protobuf::Cerbos::Cloud::Apikey::V1::IssueAccessTokenRequest.new(
34
+ client_id: @client_id,
35
+ client_secret: @client_secret
36
+ )
37
+
38
+ response = Hub.with_circuit_breaker { @service.call(:issue_access_token, request, {}) }
39
+
40
+ @result = Success.new(response:, attempted_at:)
41
+ end
42
+
43
+ @result.token
44
+ rescue Error => error
45
+ @result = Failure.new(error:, attempt:, attempted_at:)
46
+ raise
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ # @private
53
+ class None
54
+ def token
55
+ nil
56
+ end
57
+
58
+ def next_attempt
59
+ 1
60
+ end
61
+ end
62
+ private_constant :None
63
+
64
+ # @private
65
+ class Success
66
+ def initialize(response:, attempted_at:)
67
+ @token = response.access_token
68
+ @refresh_at = attempted_at + response.expires_in.seconds - 300
69
+ end
70
+
71
+ def token
72
+ @token if Process.clock_gettime(Process::CLOCK_MONOTONIC) < @refresh_at
73
+ end
74
+
75
+ def next_attempt
76
+ 1
77
+ end
78
+ end
79
+ private_constant :Success
80
+
81
+ # @private
82
+ class Failure
83
+ def initialize(error:, attempt:, attempted_at:)
84
+ @error = error
85
+ @attempt = attempt
86
+ @retry_at = attempted_at + retry_in
87
+ end
88
+
89
+ def token
90
+ remaining = @retry_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)
91
+ return nil if remaining <= 0
92
+ raise @error if remaining.infinite?
93
+
94
+ begin
95
+ raise @error
96
+ rescue
97
+ raise Error::Cancelled.new(details: "Previous authentication attempt failed, backing off for %.3gs" % remaining)
98
+ end
99
+ end
100
+
101
+ def next_attempt
102
+ @attempt + 1
103
+ end
104
+
105
+ private
106
+
107
+ def retry_in
108
+ case @error
109
+ when Error::Aborted, Error::Cancelled
110
+ -Float::INFINITY # immediately
111
+ when Error::Unauthenticated
112
+ Float::INFINITY # never
113
+ else
114
+ backoff
115
+ end
116
+ end
117
+
118
+ MIN_INTERVAL = 0.5
119
+ MAX_INTERVAL = 60
120
+ MULTIPLIER = 1.5
121
+ RANDOMIZATION_FACTOR = 0.5
122
+ USE_MAX_INTERVAL_AFTER_ATTEMPT = (Math.log(MAX_INTERVAL / MIN_INTERVAL) / Math.log(MULTIPLIER)).ceil
123
+
124
+ def backoff
125
+ interval = if @attempt > USE_MAX_INTERVAL_AFTER_ATTEMPT
126
+ MAX_INTERVAL
127
+ else
128
+ MULTIPLIER**(@attempt - 1) * MIN_INTERVAL
129
+ end
130
+
131
+ interval * (1 + (2 * rand - 1) * RANDOMIZATION_FACTOR)
132
+ end
133
+ end
134
+ private_constant :Failure
135
+ end
136
+ end
137
+ end