From c66182a71d47b6ba51d877f636f954f035a850c7 Mon Sep 17 00:00:00 2001 From: Andrew Haines Date: Fri, 3 Jun 2022 15:40:38 +0100 Subject: [PATCH] feat: Add `on_validation_error` option to `Cerbos::Client#initialize` (#22) Signed-off-by: Andrew Haines --- CHANGELOG.md | 3 ++ lib/cerbos/client.rb | 26 ++++++++++++- lib/cerbos/error.rb | 17 +++++++- spec/cerbos/client_spec.rb | 79 +++++++++++++++++++++++++++++++++++++- 4 files changed, 121 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27bca5a..3eb73b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ ## [Unreleased] +### Added +- `on_validation_error` option to `Cerbos::Client#initialize` ([#22](https://github.com/cerbos/cerbos-sdk-ruby/pull/22)) + ### Changed - Minor documentation fixes ([#21](https://github.com/cerbos/cerbos-sdk-ruby/pull/21)) diff --git a/lib/cerbos/client.rb b/lib/cerbos/client.rb index 25f82d6..d058555 100644 --- a/lib/cerbos/client.rb +++ b/lib/cerbos/client.rb @@ -12,6 +12,7 @@ class Client # @param target [String] Cerbos PDP server address (`"host"`, `"host:port"`, or `"unix:/path/to/socket"`). # @param tls [TLS, MutualTLS, false] gRPC connection encryption settings (`false` for plaintext). # @param grpc_channel_args [Hash{String, Symbol => String, Integer}] low-level settings for the gRPC channel (see [available keys in the gRPC documentation](https://grpc.github.io/grpc/core/group__grpc__arg__keys.html)). + # @param on_validation_error [:return, :raise, #call] action to take when input fails schema validation (`:return` to return the validation errors in the response, `:raise` to raise {Error::ValidationFailed}, or a callback to invoke). # @param playground_instance [String, nil] identifier of the playground instance to use when prototyping against the hosted demo PDP. # @param timeout [Numeric, nil] timeout for gRPC calls, in seconds (`nil` to never time out). # @@ -23,7 +24,15 @@ class Client # # @example Connect to the hosted demo PDP to experiment [in the playground](https://play.cerbos.dev) # client = Cerbos::Client.new("demo-pdp.cerbos.cloud", tls: Cerbos::TLS.new, playground_instance: "gE623b0180QlsG5a4QIN6UOZ6f3iSFW2") - def initialize(target, tls:, grpc_channel_args: {}, playground_instance: nil, timeout: nil) + # + # @example Raise an error when input fails schema validation + # client = Cerbos::Client.new("localhost:3593", tls: false, on_validation_error: :raise) + # + # @example Invoke a callback when input fails schema validation + # client = Cerbos::Client.new("localhost:3593", tls: false, on_validation_error: ->(validation_errors) { do_something_with validation_errors }) + def initialize(target, tls:, grpc_channel_args: {}, on_validation_error: :return, playground_instance: nil, timeout: nil) + @on_validation_error = on_validation_error + handle_errors do credentials = tls ? tls.to_channel_credentials : :this_channel_is_insecure @@ -139,7 +148,9 @@ def check_resources(principal:, resources:, aux_data: nil, include_metadata: fal response = perform_request(@cerbos_service, :check_resources, request) - Output::CheckResources.from_protobuf(response) + Output::CheckResources.from_protobuf(response).tap do |output| + handle_validation_errors output + end end end @@ -207,6 +218,17 @@ def handle_errors raise Error, error.message end + def handle_validation_errors(output) + return if @on_validation_error == :return + + validation_errors = output.results.flat_map(&:validation_errors) + return if validation_errors.empty? + + raise Error::ValidationFailed.new(validation_errors) if @on_validation_error == :raise + + @on_validation_error.call validation_errors + end + def perform_request(service, rpc, request) service.public_send(rpc, request) end diff --git a/lib/cerbos/error.rb b/lib/cerbos/error.rb index c316db9..2fcbdba 100644 --- a/lib/cerbos/error.rb +++ b/lib/cerbos/error.rb @@ -3,6 +3,21 @@ module Cerbos # Base type for errors thrown by the `cerbos` gem. class Error < StandardError + # Input failed schema validation. + class ValidationFailed < Error + # The validation errors that occurred. + # + # @return [Array] + attr_reader :validation_errors + + # @private + def initialize(validation_errors) + super "Input failed schema validation" + + @validation_errors = validation_errors + end + end + # An error indicating an unsuccessful gRPC operation. class NotOK < Error # The gRPC status code. @@ -33,7 +48,7 @@ def self.from_grpc_bad_status(error) # @private def initialize(code:, details:, metadata: {}) - super("gRPC error #{code}: #{details}") + super "gRPC error #{code}: #{details}" @code = code @details = details diff --git a/spec/cerbos/client_spec.rb b/spec/cerbos/client_spec.rb index 59943b5..02bb724 100644 --- a/spec/cerbos/client_spec.rb +++ b/spec/cerbos/client_spec.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true RSpec.describe Cerbos::Client do - subject(:client) { described_class.new(target, tls: tls) } + subject(:client) { described_class.new(target, on_validation_error: on_validation_error, tls: tls) } let(:target) { "#{host}:#{port}" } let(:host) { "localhost" } + let(:on_validation_error) { :return } shared_examples "client" do describe "#allow?" do @@ -337,6 +338,82 @@ )) end end + + context "when configured to raise on validation error" do + let(:on_validation_error) { :raise } + + it "raises an error when validation fails", :aggregate_failures do + expect { + client.allow?( + principal: { + id: "me@example.com", + policy_version: "1", + scope: "test", + roles: ["USER"], + attributes: { + country: "NZ" + } + }, + resource: { + kind: "document", + id: "invalid", + policy_version: "1", + scope: "test", + attributes: { + owner: 123 + } + }, + action: "view" + ) + }.to raise_error { |error| + expect(error).to be_a(Cerbos::Error::ValidationFailed).and(have_attributes( + validation_errors: [ + Cerbos::Output::CheckResources::Result::ValidationError.new( + path: "/owner", + message: "expected string, but got number", + source: :SOURCE_RESOURCE + ) + ] + )) + } + end + end + + context "when configured with a callback on validation error" do + let(:on_validation_error) { instance_double(Proc, call: nil) } + + it "raises an error when validation fails", :aggregate_failures do + client.allow?( + principal: { + id: "me@example.com", + policy_version: "1", + scope: "test", + roles: ["USER"], + attributes: { + country: "NZ" + } + }, + resource: { + kind: "document", + id: "invalid", + policy_version: "1", + scope: "test", + attributes: { + owner: 123 + } + }, + action: "view" + ) + + expect(on_validation_error).to have_received(:call).with([ + Cerbos::Output::CheckResources::Result::ValidationError.new( + path: "/owner", + message: "expected string, but got number", + source: :SOURCE_RESOURCE + ) + ]) + end + end end context "with plaintext" do