cerbos 0.10.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/README.md +31 -3
  4. data/cerbos.gemspec +3 -2
  5. data/lib/cerbos/abstract_class.rb +10 -0
  6. data/lib/cerbos/client.rb +29 -44
  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/output/plan_resources.rb +10 -2
  31. data/lib/cerbos/protobuf/buf/validate/validate_pb.rb +6 -5
  32. data/lib/cerbos/protobuf/cerbos/cloud/apikey/v1/apikey_pb.rb +26 -0
  33. data/lib/cerbos/protobuf/cerbos/cloud/apikey/v1/apikey_services_pb.rb +32 -0
  34. data/lib/cerbos/protobuf/cerbos/cloud/store/v1/store_pb.rb +52 -0
  35. data/lib/cerbos/protobuf/cerbos/cloud/store/v1/store_services_pb.rb +35 -0
  36. data/lib/cerbos/protobuf/cerbos/effect/v1/effect_pb.rb +1 -1
  37. data/lib/cerbos/protobuf/cerbos/engine/v1/engine_pb.rb +4 -2
  38. data/lib/cerbos/protobuf/cerbos/request/v1/request_pb.rb +2 -2
  39. data/lib/cerbos/protobuf/cerbos/response/v1/response_pb.rb +2 -2
  40. data/lib/cerbos/protobuf/cerbos/schema/v1/schema_pb.rb +1 -1
  41. data/lib/cerbos/protobuf/cerbos/svc/v1/svc_pb.rb +2 -4
  42. data/lib/cerbos/protobuf/google/api/annotations_pb.rb +1 -1
  43. data/lib/cerbos/protobuf/google/api/field_behavior_pb.rb +1 -1
  44. data/lib/cerbos/protobuf/google/api/http_pb.rb +2 -2
  45. data/lib/cerbos/protobuf/google/api/visibility_pb.rb +19 -0
  46. data/lib/cerbos/protobuf/grpc/health/v1/health_pb.rb +4 -2
  47. data/lib/cerbos/protobuf/grpc/health/v1/health_services_pb.rb +12 -2
  48. data/lib/cerbos/protobuf/protoc-gen-openapiv2/options/annotations_pb.rb +1 -1
  49. data/lib/cerbos/protobuf/protoc-gen-openapiv2/options/openapiv2_pb.rb +1 -1
  50. data/lib/cerbos/protobuf.rb +2 -0
  51. data/lib/cerbos/service.rb +33 -0
  52. data/lib/cerbos/version.rb +1 -1
  53. data/lib/cerbos.rb +7 -1
  54. data/yard_extensions.rb +8 -1
  55. metadata +49 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 07ade13e70cd396203ce234a92079eac80d83ff17ee6e106ee63ea58352b2790
4
- data.tar.gz: 988e7d8eb63d51c9ed1c7e705095dcaddb7d56efecc5302695106971cf07b169
3
+ metadata.gz: b1f96bbfba8e309d992b65cff1615eed19a8731c1b16c69f7cd9eb578e8f3f11
4
+ data.tar.gz: 934e07596201c3638201af7546ac1bd428ff2c4779cb8b25916a66fbb2d58fc4
5
5
  SHA512:
6
- metadata.gz: 5e39a03911131fc3500f98c0ccd5c2fc8ff7317e6b34101632fd1bfe46b84a66b9cb236aa3697361a78e32db8f5ad39642e07ffc65d3e91f0fb960a5380ed969
7
- data.tar.gz: 7d98aab58a966f4dad4a5f3866b766bf90e1a88aea850ceab101f7aee2e05192ab86058bb693a8c2cb7ec66bb65b2a3fe7555b3e07dadce62f10637184bc7f11
6
+ metadata.gz: 97b55e3a336f6389b899e62c3f19bf5411d85c319866b46ccc736c08d3869b89dcf930887fe650c6d151841047009b8ceadca315b83ac5214b5a3059cc2f77e5
7
+ data.tar.gz: fc2c68df0275eb67f3f2f6000f3f452d045f1de5dab1d56e593e3e5cb631e16052d59dbf1af293b5466adffcc0f7518cd5c210a3b0edec832b5524b3b3712f6e
data/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
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
+
11
+ ## [0.11.0] - 2025-06-03
12
+
13
+ ### Added
14
+
15
+ - Support for multiple actions in `Cerbos::Client#plan_resources` ([#239](https://github.com/cerbos/cerbos-sdk-ruby/pull/239))
16
+
17
+ Requires a policy decision point server running Cerbos 0.44+.
18
+
19
+ ### Removed
20
+
21
+ - Support for Ruby 3.1 ([#234](https://github.com/cerbos/cerbos-sdk-ruby/pull/234))
22
+
5
23
  ## [0.10.0] - 2025-02-06
6
24
 
7
25
  ### Added
@@ -101,7 +119,9 @@ No notable changes.
101
119
 
102
120
  - Initial implementation of `Cerbos::Client` ([#2](https://github.com/cerbos/cerbos-sdk-ruby/pull/2))
103
121
 
104
- [Unreleased]: https://github.com/cerbos/cerbos-sdk-ruby/compare/v0.10.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
124
+ [0.11.0]: https://github.com/cerbos/cerbos-sdk-ruby/compare/v0.10.0...v0.11.0
105
125
  [0.10.0]: https://github.com/cerbos/cerbos-sdk-ruby/compare/v0.9.1...v0.10.0
106
126
  [0.9.1]: https://github.com/cerbos/cerbos-sdk-ruby/compare/v0.9.0...v0.9.1
107
127
  [0.9.0]: https://github.com/cerbos/cerbos-sdk-ruby/compare/v0.8.0...v0.9.0
data/README.md CHANGED
@@ -7,12 +7,12 @@
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
 
14
14
  - Cerbos 0.16+
15
- - Ruby 3.1+
15
+ - Ruby 3.2+
16
16
 
17
17
  ## Installation
18
18
 
@@ -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
@@ -31,7 +31,8 @@ Gem::Specification.new do |spec|
31
31
  "yard_extensions.rb"
32
32
  ]
33
33
 
34
- spec.required_ruby_version = ">= 3.1.0"
35
- spec.add_dependency "grpc", "~> 1.46"
34
+ spec.required_ruby_version = ">= 3.2.0"
35
+ spec.add_dependency "concurrent-ruby", "~> 1.2"
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
@@ -206,7 +205,8 @@ module Cerbos
206
205
  #
207
206
  # @param principal [Input::Principal, Hash] the principal for whom to plan.
208
207
  # @param resource [Input::ResourceQuery, Hash] partial details of the resources for which to plan.
209
- # @param action [String] the action for which to plan.
208
+ # @param action [String] deprecated (use `actions` instead).
209
+ # @param actions [Array<String>] the actions for which to plan (requires a policy decision point server running Cerbos v0.44+).
210
210
  # @param aux_data [Input::AuxData, Hash, nil] auxiliary data.
211
211
  # @param include_metadata [Boolean] `true` to include additional metadata ({Output::CheckResources::Result::Metadata}) in the results.
212
212
  # @param request_id [String] identifier for tracing the request.
@@ -218,23 +218,24 @@ module Cerbos
218
218
  # plan = client.plan_resources(
219
219
  # principal: {id: "user@example.com", roles: ["USER"]},
220
220
  # resource: {kind: "document"},
221
- # action: "view"
221
+ # actions: ["view"]
222
222
  # )
223
223
  #
224
224
  # plan.conditional? # => true
225
225
  # plan.condition # => #<Cerbos::Output::PlanResources::Expression ...>
226
- def plan_resources(principal:, resource:, action:, aux_data: nil, include_metadata: false, request_id: SecureRandom.uuid, grpc_metadata: {})
227
- handle_errors do
226
+ def plan_resources(principal:, resource:, action: "", actions: [], aux_data: nil, include_metadata: false, request_id: SecureRandom.uuid, grpc_metadata: {})
227
+ Error.handle do
228
228
  request = Protobuf::Cerbos::Request::V1::PlanResourcesRequest.new(
229
229
  principal: Input.coerce_required(principal, Input::Principal).to_protobuf,
230
230
  resource: Input.coerce_required(resource, Input::ResourceQuery).to_protobuf,
231
231
  action: action,
232
+ actions: actions,
232
233
  aux_data: Input.coerce_optional(aux_data, Input::AuxData)&.to_protobuf,
233
234
  include_meta: include_metadata,
234
235
  request_id: request_id
235
236
  )
236
237
 
237
- response = perform_request(@cerbos_service, :plan_resources, request, grpc_metadata)
238
+ response = @cerbos_service.call(:plan_resources, request, grpc_metadata)
238
239
 
239
240
  Output::PlanResources.from_protobuf(response).tap do |output|
240
241
  handle_validation_errors output
@@ -248,10 +249,10 @@ module Cerbos
248
249
  #
249
250
  # @return [Output::ServerInfo]
250
251
  def server_info(grpc_metadata: {})
251
- handle_errors do
252
+ Error.handle do
252
253
  request = Protobuf::Cerbos::Request::V1::ServerInfoRequest.new
253
254
 
254
- response = perform_request(@cerbos_service, :server_info, request, grpc_metadata)
255
+ response = @cerbos_service.call(:server_info, request, grpc_metadata)
255
256
 
256
257
  Output::ServerInfo.from_protobuf(response)
257
258
  end
@@ -259,18 +260,6 @@ module Cerbos
259
260
 
260
261
  private
261
262
 
262
- def handle_errors
263
- yield
264
- rescue Error
265
- raise
266
- rescue ArgumentError, TypeError => error
267
- raise Error::InvalidArgument.new(details: error.message)
268
- rescue GRPC::BadStatus => error
269
- raise Error::NotOK.from_grpc_bad_status(error)
270
- rescue => error
271
- raise Error, error.message
272
- end
273
-
274
263
  def handle_validation_errors(output)
275
264
  return if @on_validation_error == :return
276
265
 
@@ -281,9 +270,5 @@ module Cerbos
281
270
 
282
271
  @on_validation_error.call validation_errors
283
272
  end
284
-
285
- def perform_request(service, rpc, request, metadata)
286
- service.public_send(rpc, request, metadata: @grpc_metadata.merge(metadata.transform_keys(&:to_sym)))
287
- end
288
273
  end
289
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