From 7308ecd50a17557e8f1129ac4943523f6e87069e Mon Sep 17 00:00:00 2001 From: Gheorghe Popescu Date: Mon, 27 Nov 2023 12:55:52 +0200 Subject: [PATCH] Add directory v3 client (#13) * Add Directory v3 Client --- .github/workflows/ci.yaml | 10 +- .rubocop.yml | 4 + .vscode/settings.json | 3 +- Gemfile | 5 + README.md | 82 +---- Rakefile | 24 ++ aserto.gemspec | 2 +- lib/aserto.rb | 1 + lib/aserto/directory/interceptors/headers.rb | 26 +- lib/aserto/directory/v3/client.rb | 114 +++++++ lib/aserto/directory/v3/config.rb | 60 ++++ lib/aserto/directory/v3/exporter.rb | 35 ++ lib/aserto/directory/v3/importer.rb | 40 +++ lib/aserto/directory/v3/model.rb | 36 ++ lib/aserto/directory/v3/reader.rb | 266 +++++++++++++++ lib/aserto/directory/v3/writer.rb | 125 +++++++ spec/aserto/directory/v3/client_spec.rb | 38 +++ spec/integration/directory_spec.rb | 338 +++++++++++++++++++ spec/integration/topaz.rb | 77 +++++ spec/spec_helper.rb | 19 +- 20 files changed, 1215 insertions(+), 90 deletions(-) create mode 100644 Rakefile create mode 100644 lib/aserto/directory/v3/client.rb create mode 100644 lib/aserto/directory/v3/config.rb create mode 100644 lib/aserto/directory/v3/exporter.rb create mode 100644 lib/aserto/directory/v3/importer.rb create mode 100644 lib/aserto/directory/v3/model.rb create mode 100644 lib/aserto/directory/v3/reader.rb create mode 100644 lib/aserto/directory/v3/writer.rb create mode 100644 spec/aserto/directory/v3/client_spec.rb create mode 100644 spec/integration/directory_spec.rb create mode 100644 spec/integration/topaz.rb diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0a3858a..ee4649c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,8 +36,14 @@ jobs: - name: Run rubocop run: bundle exec rubocop --parallel + - name: Set up Homebrew + uses: Homebrew/actions/setup-homebrew@master + + - name: Install topaz + run: brew tap aserto-dev/tap && brew install aserto-dev/tap/topaz && topaz install + - name: run RSpec - run: bundle exec rspec + run: bundle exec rake spec:all release: runs-on: ubuntu-latest @@ -47,7 +53,7 @@ jobs: name: Release to rubygems steps: - name: Read Configuration - uses: hashicorp/vault-action@v2.4.1 + uses: hashicorp/vault-action@v2.7.2 id: vault with: url: ${{ env.VAULT_ADDR }} diff --git a/.rubocop.yml b/.rubocop.yml index bebb484..9ba5a70 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,6 +7,7 @@ AllCops: require: - rubocop-rspec - rubocop-performance + - rubocop-rake Style/Documentation: Enabled: false @@ -34,3 +35,6 @@ RSpec/ExampleLength: RSpec/NestedGroups: Max: 4 + +Metrics/ParameterLists: + Enabled: false diff --git a/.vscode/settings.json b/.vscode/settings.json index 82ebe1e..3cf766d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ - "creds" + "creds", + "etag" ] } diff --git a/Gemfile b/Gemfile index deff26c..c4f6ba7 100644 --- a/Gemfile +++ b/Gemfile @@ -13,7 +13,12 @@ group :development do gem "bundler", ">= 1.15.0", "< 3.0" gem "codecov", "~> 0.6" gem "grpc_mock", "~> 0.4" + gem "pry-byebug", "~> 3.10" gem "rspec", "~> 3.0" gem "rubocop-performance", "~> 1.14" gem "rubocop-rspec", "~> 2.11" + + gem "rake", "~> 13.1" + + gem "rubocop-rake", "~> 0.6.0" end diff --git a/README.md b/README.md index 6d9118f..4d9ae0a 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ You can initialize a directory client as follows: ```ruby require 'aserto/directory/client' -directory_client = Aserto::Directory::Client.new( +directory_client =Aserto::Directory::V3::Client.new( url: "directory.eng.aserto.com:8443", tenant_id: "aserto-tenant-id", api_key: "basic directory api key", @@ -44,85 +44,7 @@ directory_client = Aserto::Directory::Client.new( - `tenant_id`: Aserto tenant ID (_required_ if using hosted directory) - `cert_path`: Path to the grpc service certificate when connecting to local topaz instance. -### Getting objects and relations -Get an object instance with the type `type-name` and the key `object-key`. For example: - -```ruby -user = directory_client.object(type: 'user', key: 'euang@acmecorp.com') -``` - -Get an array of relations of a certain type for an object instance. For example: - -```ruby -identity = 'euang@acmecorp.com'; -relations = directory_client.relation( - { - subject: { - type: 'user', - }, - object: { - type: 'identity', - key: identity - }, - relation: { - name: 'identifier', - objectType: 'identity' - } - } -) -``` - -### Setting objects and relations - -Create a new object -```ruby -user = directory_client.set_object(object: { type: "user", key: "test-object", display_name: "test object" }) -identity = directory_client.set_object(object: { type: "identity", key: "test-identity" }) -``` - -Update an existing object -```ruby -user = directory_client.set_object(object: { type: "user", key: "test-object", display_name: "test object" }) -user.display_name = 'test object edit' -updated_user = directory_client.set_object(object: user) -``` - -Create a new relation -```ruby -directory_client.set_relation( - subject: { type: "user", "test-object" }, - relation: "identifier", - object: { type: "identity", key: "test-identity" } -) -``` - -Delete a relation -```ruby -pp client.delete_relation( - subject: { type: "user", key: "test-object" }, - relation: { name: "identifier", object_type: "identity" }, - object: { type: "identity", key: "test-identity" } -) -``` - -### Checking permissions and relations -Check permission -```ruby -directory_client.check_permission( - subject: { type: "user", key: "011a88bc-7df9-4d92-ba1f-2ff319e101e1" }, - permission: { name: "read" }, - object: { type: "group", key: "executive" } -) -``` - -Check relation -```ruby -directory_client.check_relation( - subject: { type: "user", key: "dfdadc39-7335-404d-af66-c77cf13a15f8" }, - relation: { name: "identifier", object_type: "identity" }, - object: { type: "identity", key: "euang@acmecorp.com" } -) -``` +See https://rubydoc.info/gems/aserto/docs/Aserto/Directory/V3/Client for full documentation ## Authorizer `Aserto::Authorization` is a middleware that allows Ruby applications to use Aserto as the Authorization provider. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..c53bf44 --- /dev/null +++ b/Rakefile @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rake" +require "rspec/core/rake_task" +namespace :spec do + RSpec::Core::RakeTask.new(:unit) do |t| + t.pattern = Dir.glob("spec/aserto/**/*_spec.rb") + t.rspec_opts = "--format documentation" + end + + RSpec::Core::RakeTask.new(:integration) do |t| + t.pattern = Dir.glob("spec/integration/**/*_spec.rb") + t.rspec_opts = "--format documentation" + end + + desc "Run all tests" + task :all do + ["spec:unit", "spec:integration"].each do |t| + Rake::Task[t].execute + end + end +end + +task default: "spec:unit" diff --git a/aserto.gemspec b/aserto.gemspec index a3783f8..3d309ce 100644 --- a/aserto.gemspec +++ b/aserto.gemspec @@ -32,7 +32,7 @@ Gem::Specification.new do |spec| # runtime dependencies spec.add_runtime_dependency "aserto-authorizer", ">= 0.20.1" - spec.add_runtime_dependency "aserto-directory", ">= 0.30.0" + spec.add_runtime_dependency "aserto-directory", ">= 0.30.1" spec.add_runtime_dependency "jwt", "~> 2.4" spec.add_runtime_dependency "rack", "~> 2.0" end diff --git a/lib/aserto.rb b/lib/aserto.rb index 1c385b0..1bbddd7 100644 --- a/lib/aserto.rb +++ b/lib/aserto.rb @@ -12,6 +12,7 @@ require_relative "aserto/auth_client" require_relative "aserto/errors" require_relative "aserto/directory/client" +require_relative "aserto/directory/v3/client" module Aserto class << self diff --git a/lib/aserto/directory/interceptors/headers.rb b/lib/aserto/directory/interceptors/headers.rb index c1e22a7..9aaac92 100644 --- a/lib/aserto/directory/interceptors/headers.rb +++ b/lib/aserto/directory/interceptors/headers.rb @@ -10,11 +10,31 @@ def initialize(api_key, tenant_id) super() end - def request_response(method:, request:, call:, metadata:) + def request_response(request: nil, call: nil, method: nil, metadata: nil) + update_metadata(metadata) + yield(request, call, method, metadata) + end + + def bidi_streamer(requests: nil, call: nil, method: nil, metadata: nil) + update_metadata(metadata) + yield(requests, call, method, metadata) + end + + def client_streamer(requests: nil, call: nil, method: nil, metadata: nil) + update_metadata(metadata) + yield(requests, call, method, metadata) + end + + def server_streamer(request: nil, call: nil, method: nil, metadata: nil) + update_metadata(metadata) + yield(request, call, method, metadata) + end + + private + + def update_metadata(metadata) metadata["aserto-tenant-id"] = @tenant_id metadata["authorization"] = @api_key - - yield(method, request, call, metadata) end end end diff --git a/lib/aserto/directory/v3/client.rb b/lib/aserto/directory/v3/client.rb new file mode 100644 index 0000000..3102d56 --- /dev/null +++ b/lib/aserto/directory/v3/client.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "aserto/directory" +require_relative "../interceptors/headers" +require_relative "config" +require_relative "reader" +require_relative "writer" +require_relative "model" +require_relative "importer" +require_relative "exporter" + +module Aserto + module Directory + module V3 + class Client + extend Forwardable + include ::Aserto::Directory::V3::Reader + # @!parse include ::Aserto::Directory::V3::Reader + + include ::Aserto::Directory::V3::Writer + # @!parse include ::Aserto::Directory::V3::Writer + + include ::Aserto::Directory::V3::Model + # @!parse include ::Aserto::Directory::V3::Model + + include ::Aserto::Directory::V3::Importer + # @!parse include ::Aserto::Directory::V3::Importer + + include ::Aserto::Directory::V3::Exporter + # @!parse include ::Aserto::Directory::V3::Exporter + + # Creates a new Directory V3 Client + # + # @param config [Aserto::Directory::V3::Config] the service configuration + # Base configuration + # If non-nil, this configuration is used for any client that doesn't have its own configuration. + # If nil, only clients that have their own configuration will be created. + # + # @example Create a new Directory V3 Client with all the services + # client = Aserto::Directory::V3::Client.new( + # { + # url: "directory.eng.aserto.com:8443", + # tenant_id: "tenant-id", + # api_key: "basic api-key", + # } + # ) + # + # @example Create a new Directory V3 Client with reader only + # client = Aserto::Directory::V3::Client.new( + # { + # reader: { + # url: "directory.eng.aserto.com:8443", + # tenant_id: "tenant-id", + # api_key: "basic api-key", + # } + # } + # ) + # + # @return [Aserto::Directory::V3::Client] the new Directory V3 Client + def initialize(config) + base_config = ::Aserto::Directory::V3::Config.new(config) + + @reader = create_client(:reader, base_config.reader) + @writer = create_client(:writer, base_config.writer) + @importer = create_client(:importer, base_config.importer) + @exporter = create_client(:exporter, base_config.exporter) + @model = create_client(:model, base_config.model) + end + + private + + attr_reader :reader, :writer, :model, :importer, :exporter + + class NullClient + def initialize(name) + @name = name + end + + def method_missing(method, *_args) + puts "Cannot call '#{method}': '#{@name.to_s.capitalize}' client is not initialized." + end + + def respond_to_missing?(_name, _include_private) + true + end + end + + SERVICE_MAP = { + reader: ::Aserto::Directory::Reader::V3::Reader::Stub, + writer: ::Aserto::Directory::Writer::V3::Writer::Stub, + importer: ::Aserto::Directory::Importer::V3::Importer::Stub, + exporter: ::Aserto::Directory::Exporter::V3::Exporter::Stub, + model: ::Aserto::Directory::Model::V3::Model::Stub + }.freeze + + def create_client(type, config) + return NullClient.new(type) unless config + + SERVICE_MAP[type].new( + config.url, + config.credentials, + interceptors: config.interceptors + ) + end + end + + remove_const(:Reader) + remove_const(:Writer) + remove_const(:Model) + remove_const(:Importer) + remove_const(:Exporter) + end + end +end diff --git a/lib/aserto/directory/v3/config.rb b/lib/aserto/directory/v3/config.rb new file mode 100644 index 0000000..ebb5975 --- /dev/null +++ b/lib/aserto/directory/v3/config.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require_relative "../interceptors/headers" + +module Aserto + module Directory + module V3 + class Config + attr_reader :reader, :writer, :importer, :exporter, :model + + def initialize(config) + @base = { + url: config[:url] || "directory.prod.aserto.com:8443", + api_key: config[:api_key], + tenant_id: config[:tenant_id], + cert_path: config[:cert_path] + } + + @reader = build(**(config[:reader] || {})) + @writer = build(**(config[:writer] || {})) + @importer = build(**(config[:importer] || {})) + @exporter = build(**(config[:exporter] || {})) + @model = build(**(config[:model] || {})) + end + + private + + class BaseConfig + attr_reader :url, :credentials, :interceptors + + def initialize(url, credentials, interceptors) + @url = url + @credentials = credentials + @interceptors = interceptors + end + end + + def build( + url: @base[:url], + api_key: @base[:api_key], + tenant_id: @base[:tenant_id], + cert_path: @base[:cert_path] + ) + + interceptors = [] + interceptors = [Interceptors::Headers.new(api_key, tenant_id)] if !api_key.nil? && !tenant_id.nil? + BaseConfig.new(url, load_creds(cert_path), interceptors) + end + + def load_creds(cert_path) + if cert_path && File.file?(cert_path) + GRPC::Core::ChannelCredentials.new(File.read(cert_path)) + else + GRPC::Core::ChannelCredentials.new + end + end + end + end + end +end diff --git a/lib/aserto/directory/v3/exporter.rb b/lib/aserto/directory/v3/exporter.rb new file mode 100644 index 0000000..7fd0214 --- /dev/null +++ b/lib/aserto/directory/v3/exporter.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Aserto + module Directory + module V3 + module Exporter + DATA_TYPE = { + unknown: ::Aserto::Directory::Exporter::V3::Option::OPTION_UNKNOWN, + objects: ::Aserto::Directory::Exporter::V3::Option::OPTION_DATA_OBJECTS, + relations: ::Aserto::Directory::Exporter::V3::Option::OPTION_DATA_RELATIONS, + all: ::Aserto::Directory::Exporter::V3::Option::OPTION_DATA + }.freeze + + # + # Exports directory data + # + # @param [String] data_type one of [:unknown, :objects, :relations, :all] + # + def export(data_type: :unknown) + data = [] + operation = exporter.export( + Aserto::Directory::Exporter::V3::ExportRequest.new(options: DATA_TYPE[data_type]), + return_op: true + ) + + response = operation.execute + response.each { |r| data.push(r) } + operation.wait + + data + end + end + end + end +end diff --git a/lib/aserto/directory/v3/importer.rb b/lib/aserto/directory/v3/importer.rb new file mode 100644 index 0000000..9945abb --- /dev/null +++ b/lib/aserto/directory/v3/importer.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Aserto + module Directory + module V3 + module Importer + # + # Imports objects and relations + # + # @param Array[Hash] data to be imported + # + # @example + # directory.import( + # [ + # { object: { id: "import-user", type: "user" } }, + # { object: { id: "import-group", type: "group" } }, + # { + # relation: { + # object_id: "import-user", + # object_type: "user", + # relation: "member", + # subject_id: "import-group", + # subject_type: "group" + # } + # } + # ] + # ) + def import(data) + data.map! do |value| + Aserto::Directory::Importer::V3::ImportRequest.new(value) + end + operation = importer.import(data, return_op: true) + response = operation.execute + response.each { |r| } # ensures that the server sends trailing data + operation.wait + end + end + end + end +end diff --git a/lib/aserto/directory/v3/model.rb b/lib/aserto/directory/v3/model.rb new file mode 100644 index 0000000..4e580a5 --- /dev/null +++ b/lib/aserto/directory/v3/model.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Aserto + module Directory + module V3 + module Model + # rubocop:disable Naming/AccessorMethodName + + # Get the content of a manifest + # @return [Hash] { body: String, updated_at: Timestap, etag: String } + def get_manifest + response = {} + manifest_enum = @model.get_manifest(Aserto::Directory::Model::V3::GetManifestRequest.new) + manifest_enum.each do |resp| + response[:body] = resp.body.data if resp.respond_to?(:body) && !resp.body.nil? + if resp.respond_to?(:metadata) + response[:updated_at] ||= resp.metadata&.updated_at&.to_time + response[:etag] ||= resp.metadata&.etag + end + end + + response + end + + # Set the content of a manifest + # @param body [String] + # @return [Aserto::Directory::Model::V3::SetManifestResponse] + def set_manifest(body) + @model.set_manifest([Aserto::Directory::Model::V3::SetManifestRequest.new(body: { data: body })]) + end + + # rubocop:enable Naming/AccessorMethodName + end + end + end +end diff --git a/lib/aserto/directory/v3/reader.rb b/lib/aserto/directory/v3/reader.rb new file mode 100644 index 0000000..6da6cbe --- /dev/null +++ b/lib/aserto/directory/v3/reader.rb @@ -0,0 +1,266 @@ +# frozen_string_literal: true + +module Aserto + module Directory + module V3 + module Reader + # + # find an object by id and type + # + # @param object_id [String] + # @param object_type [String] + # + # @return [Aserto::Directory::Reader::V3::GetObjectResponse] + # + # @example + # client.get_object( + # object_type: "user", + # object_id: "rick@the-citadel.com" + # ) + def get_object(object_id:, object_type:) + reader.get_object( + Aserto::Directory::Reader::V3::GetObjectRequest.new( + object_id: object_id, + object_type: object_type + ) + ) + end + + # + # list objects by type + # + # @param object_type [String] + # @param page [Hash] + # @option page [Integer] :size + # @option page [String] :token + # + # @return [Aserto::Directory::V3::GetObjectsResponse] + # + # @example + # client.get_objects( + # object_type: "user", + # page: { size: 2 } + # ) + def get_objects(object_type:, page: { size: 100 }) + reader.get_objects( + Aserto::Directory::Reader::V3::GetObjectsRequest.new( + object_type: object_type, + page: Aserto::Directory::Common::V3::PaginationRequest.new(page) + ) + ) + end + + # + # find a relation between two objects + # + # @param [String] object_type + # @param [String] object_id + # @param [String] relation + # @param [String] subject_type + # @param [String] subject_id + # @param [String] subject_relation + # @param [Boolean] with_objects + # + # @return [Aserto::Directory::Reader::V3::GetRelationResponse] + # + # @example + # client.get_relation( + # object_type: "user", + # object_id: "rick@the-citadel.com", + # relation: "member", + # object_type: "group", + # object_id: "evil_genius" + # ) + def get_relation(object_type:, object_id:, relation:, subject_type:, subject_id:, subject_relation: "", + with_objects: false) + reader.get_relation( + Aserto::Directory::Reader::V3::GetRelationRequest.new( + object_type: object_type, + object_id: object_id, + relation: relation, + subject_type: subject_type, + subject_id: subject_id, + subject_relation: subject_relation, + with_objects: with_objects + ) + ) + end + + # + # List relations between two objects + # + # @param [String] object_type + # @param [String] object_id + # @param [String] relation + # @param [String] subject_type + # @param [String] subject_id + # @param [String] subject_relation + # @param [Boolean] with_objects + # @param page [Hash] + # @option page [Integer] :size + # @option page [String] :token + # + # @return [Aserto::Directory::Reader::V3::GetRelationsResponse] + # + # @example + # client.get_relations( + # object_type: "user", + # object_id: "rick@the-citadel.com", + # relation: "member", + # page: { size: 10 } + # ) + def get_relations(object_type: "", object_id: "", relation: "", subject_type: "", subject_id: "", + subject_relation: "", with_objects: false, page: { size: 100 }) + reader.get_relations( + Aserto::Directory::Reader::V3::GetRelationsRequest.new( + object_type: object_type, + object_id: object_id, + relation: relation, + subject_type: subject_type, + subject_id: subject_id, + subject_relation: subject_relation, + with_objects: with_objects, + page: Aserto::Directory::Common::V3::PaginationRequest.new(page) + ) + ) + end + + # + # Check relation between two objects + # + # @param [String] object_type + # @param [String] object_id + # @param [String] relation + # @param [String] subject_type + # @param [String] subject_id + # @param [Boolean] trace + # + # @return [Aserto::Directory::Reader::V3::CheckRelationResponse] + # + # @example + # client.check_relation( + # object_type: "user", + # object_id: "rick@the-citadel.com", + # relation: "member", + # object_type: "group", + # object_id: "evil_genius" + # ) + def check_relation(object_type:, object_id:, relation:, subject_type:, subject_id:, trace: false) + reader.check_relation( + Aserto::Directory::Reader::V3::CheckRelationRequest.new( + object_type: object_type, + object_id: object_id, + relation: relation, + subject_type: subject_type, + subject_id: subject_id, + trace: trace + ) + ) + end + + # + # Check relation between two objects + # + # @param [String] object_type + # @param [String] object_id + # @param [String] relation + # @param [String] subject_type + # @param [String] subject_id + # @param [Boolean] trace + # + # @return [Aserto::Directory::Reader::V3::CheckResponse] + # + # @example + # client.check( + # object_type: "user", + # object_id: "rick@the-citadel.com", + # relation: "member", + # object_type: "group", + # object_id: "evil_genius" + # ) + def check(object_type:, object_id:, relation:, subject_type:, subject_id:, trace: false) + reader.check( + Aserto::Directory::Reader::V3::CheckRequest.new( + object_type: object_type, + object_id: object_id, + relation: relation, + subject_type: subject_type, + subject_id: subject_id, + trace: trace + ) + ) + end + + # + # Check permission between two objects + # + # @param [String] object_type + # @param [String] object_id + # @param [String] permission + # @param [String] subject_type + # @param [String] subject_id + # @param [Boolean] trace + # + # @return [Aserto::Directory::Reader::V3::CheckPermissionResponse] + # + # @example + # client.check_permission( + # object_type: "user", + # object_id: "rick@the-citadel.com", + # permission: "read", + # object_type: "group", + # object_id: "evil_genius" + # ) + def check_permission(object_type:, object_id:, permission:, subject_type:, subject_id:, trace: false) + reader.check_permission( + Aserto::Directory::Reader::V3::CheckPermissionRequest.new( + object_type: object_type, + object_id: object_id, + permission: permission, + subject_type: subject_type, + subject_id: subject_id, + trace: trace + ) + ) + end + + # + # Returns object graph from anchor to subject or object. + # + # @param [String] anchor_type + # @param [String] anchor_id + # @param [String] object_type + # @param [String] object_id + # @param [String] relation + # @param [String] subject_type + # @param [String] + # + # @return [Aserto::Directory::Reader::V3::GetGraphResponse] + # + # @example + # directory.get_graph( + # anchor_type: "user", + # anchor_id: "rick@the-citadel.com", + # subject_id: "rick@the-citadel.com", + # subject_type: "user", + # relation: "member" + # ) + def get_graph(anchor_type:, anchor_id:, object_type: "", object_id: "", relation: "", subject_type: "", + subject_id: "", subject_relation: "") + reader.get_graph( + Aserto::Directory::Reader::V3::GetGraphRequest.new( + anchor_type: anchor_type, + anchor_id: anchor_id, + object_type: object_type, + object_id: object_id, + relation: relation, + subject_type: subject_type, + subject_id: subject_id, + subject_relation: subject_relation + ) + ) + end + end + end + end +end diff --git a/lib/aserto/directory/v3/writer.rb b/lib/aserto/directory/v3/writer.rb new file mode 100644 index 0000000..98ed9a0 --- /dev/null +++ b/lib/aserto/directory/v3/writer.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Aserto + module Directory + module V3 + module Writer + require "google/protobuf/well_known_types" + + # + # Create a new object + # + # @param [String] object_id + # @param [String] object_type + # @param [String] display_name + # @param [Hash] properties + # @param [String] etag + # + # @return [Aserto::Directory::Writer::V3::SetObjectResponse] + # + # @example + # client.set_object(object_id: "1234", object_type: "user", properties: { email: "test" }) + def set_object(object_id:, object_type:, display_name: "", properties: {}, etag: nil) + writer.set_object( + Aserto::Directory::Writer::V3::SetObjectRequest.new( + object: { + id: object_id, + type: object_type, + display_name: display_name, + properties: Google::Protobuf::Struct.from_hash(properties.transform_keys!(&:to_s)), + etag: etag + } + ) + ) + end + + # + # Delete an object + # + # @param [String] object_id + # @param [String] object_type + # @param [Boolean] with_relations + # + # @return [ Aserto::Directory::Writer::V3::DeleteObjectResponse] + # + # @example + # client.delete_object(object_id: "1234", object_type: "user") + def delete_object(object_id:, object_type:, with_relations: false) + writer.delete_object( + Aserto::Directory::Writer::V3::DeleteObjectRequest.new( + object_id: object_id, + object_type: object_type, + with_relations: with_relations + ) + ) + end + + # + # Creates a relation between two objects + # + # @param [String] object_type + # @param [String] object_id + # @param [String] relation + # @param [String] subject_type + # @param [String] subject_id + # + # @return [Aserto::Directory::Writer::V3::SetRelationResponse] + # + # @example + # client.set_relation( + # object_type: "user", + # object_id: "rick@the-citadel.com", + # relation: "member", + # object_type: "group", + # object_id: "evil_genius" + # ) + def set_relation(object_type:, object_id:, relation:, subject_type:, subject_id:) + writer.set_relation( + Aserto::Directory::Writer::V3::SetRelationRequest.new( + relation: { + object_type: object_type, + object_id: object_id, + relation: relation, + subject_type: subject_type, + subject_id: subject_id + } + ) + ) + end + + # + # Delete a relation between two objects + # + # @param [String] object_type + # @param [String] object_id + # @param [String] relation + # @param [String] subject_type + # @param [String] subject_id + # @param [String] subject_relation + # + # @return [Aserto::Directory::Writer::V3::DeleteRelationRequest] + # + # @example + # client.get_relation( + # object_type: "user", + # object_id: "rick@the-citadel.com", + # relation: "member", + # object_type: "group", + # object_id: "evil_genius" + # ) + def delete_relation(object_type:, object_id:, relation:, subject_type:, subject_id:, subject_relation: "") + writer.delete_relation( + Aserto::Directory::Writer::V3::DeleteRelationRequest.new( + object_type: object_type, + object_id: object_id, + relation: relation, + subject_type: subject_type, + subject_id: subject_id, + subject_relation: subject_relation + ) + ) + end + end + end + end +end diff --git a/spec/aserto/directory/v3/client_spec.rb b/spec/aserto/directory/v3/client_spec.rb new file mode 100644 index 0000000..b12b395 --- /dev/null +++ b/spec/aserto/directory/v3/client_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +describe Aserto::Directory::V3::Client do + describe ".reader" do + let(:client) { described_class.new(tenant_id: "1234", api_key: "basic test") } + + describe ".get_object" do + before do + GrpcMock.stub_request("/aserto.directory.reader.v3.Reader/GetObject").to_return do + Aserto::Directory::Reader::V3::GetObjectResponse.new( + { result: { id: "id", type: "type", display_name: "display_name" } } + ) + end + end + + it "returns the correct object" do + expect(client.get_object( + object_id: "id", + object_type: "type" + ).to_h).to eq( + { + result: { + created_at: nil, + display_name: "display_name", + etag: "", + id: "id", + properties: nil, + type: "type", + updated_at: nil + }, + relations: [], + page: nil + } + ) + end + end + end +end diff --git a/spec/integration/directory_spec.rb b/spec/integration/directory_spec.rb new file mode 100644 index 0000000..90034fd --- /dev/null +++ b/spec/integration/directory_spec.rb @@ -0,0 +1,338 @@ +# frozen_string_literal: true + +describe "Directory", type: :integration do + let :directory do + Aserto::Directory::V3::Client.new( + { + url: "localhost:9292", + cert_path: File.join(ENV.fetch("HOME", ""), ".config/topaz/certs/grpc-ca.crt") + } + ) + end + + let :manifest do + <<~YAML + # yaml-language-server: $schema=https://www.topaz.sh/schema/manifest.json + --- + + ### filename: manifest.yaml ### + ### datetime: 2023-10-17T00:00:00-00:00 ### + ### description: acmecorp manifest ### + + ### model ### + model: + version: 3 + + ### object type definitions ### + types: + ### display_name: User ### + user: + relations: + ### display_name: user#manager ### + manager: user + + ### display_name: Identity ### + identity: + relations: + ### display_name: identity#identifier ### + identifier: user + + ### display_name: Group ### + group: + relations: + ### display_name: group#member ### + member: user + permissions: + read: member + YAML + end + + it "sets the manifest" do + expect do + directory.set_manifest(manifest) + end.not_to raise_error + end + + it "reads the manifest" do + expect(directory.get_manifest.to_h[:body]).to eq(manifest) + end + + it "creates a new object" do + expect { directory.set_object(object_id: "my-user", object_type: "user") }.not_to raise_error + end + + it "creates another object" do + expect { directory.set_object(object_id: "my-group", object_type: "group") }.not_to raise_error + end + + it "reads an object" do + expect(directory.get_object(object_type: "user", object_id: "my-user").result.id).to eq("my-user") + end + + it "reads another object" do + expect(directory.get_object(object_type: "group", object_id: "my-group").result.id).to eq("my-group") + end + + it "creates a relation between user and group" do + expect do + directory.set_relation( + subject_id: "my-user", + subject_type: "user", + relation: "member", + object_id: "my-group", + object_type: "group" + ) + end.not_to raise_error + end + + it "reads the graph between user and group" do + expect(directory.get_graph( + anchor_type: "user", + anchor_id: "my-user", + subject_id: "my-user", + subject_type: "user", + relation: "member" + ).results.map(&:to_h)).to eq( + [ + { depth: 1, + is_cycle: false, + object_id: "my-group", + object_type: "group", + path: ["group:my-group|member|user:my-user#"], + relation: "member", + subject_id: "my-user", + subject_relation: "", + subject_type: "user" } + ] + ) + end + + it "reads a relation between user and group" do + expect(directory.get_relation( + subject_id: "my-user", + subject_type: "user", + relation: "member", + object_id: "my-group", + object_type: "group" + ).result.to_h).to include( + { subject_id: "my-user", + subject_type: "user", + relation: "member", + object_id: "my-group", + object_type: "group", + subject_relation: "" } + ) + end + + it "checks a relation between user and group" do + expect(directory.check_relation( + subject_id: "my-user", + subject_type: "user", + relation: "member", + object_id: "my-group", + object_type: "group" + ).to_h).to eq( + { check: true, trace: [] } + ) + end + + it "checks an user and a group" do + expect(directory.check( + subject_id: "my-user", + subject_type: "user", + relation: "member", + object_id: "my-group", + object_type: "group" + ).to_h).to eq( + { check: true, trace: [] } + ) + end + + it "checks a permission of an object" do + expect(directory.check_permission( + subject_id: "my-user", + subject_type: "user", + permission: "read", + object_id: "my-group", + object_type: "group" + ).to_h).to eq( + { check: true, trace: [] } + ) + end + + it "lists the relations for a given object" do + expect(directory.get_relations( + subject_id: "my-user", + subject_type: "user", + relation: "member" + ).results.map(&:to_h)[0]).to include( + { + subject_id: "my-user", + subject_type: "user", + relation: "member", + object_id: "my-group", + object_type: "group", + subject_relation: "" + } + ) + end + + it "deletes a relation between user and group" do + expect do + directory.delete_relation( + subject_id: "my-user", + subject_type: "user", + relation: "member", + object_id: "my-group", + object_type: "group" + ) + end.not_to raise_error + end + + it "raises error when getting a deleted relation" do + expect do + directory.get_relation( + subject_id: "my-user", + subject_type: "user", + relation: "member", + object_id: "my-group", + object_type: "group" + ) + end.to raise_error(GRPC::NotFound) + end + + it "lists users objects" do + expect(directory.get_objects(object_type: "user").results.length).to eq(1) + end + + it "lists group objects" do + expect(directory.get_objects(object_type: "group").results.length).to eq(1) + end + + it "deletes an object" do + expect { directory.delete_object(object_id: "my-user", object_type: "user") }.not_to raise_error + end + + it "deletes another object" do + expect { directory.delete_object(object_id: "my-group", object_type: "group") }.not_to raise_error + end + + it "raises error when getting a deleted object" do + expect do + directory.get_object( + object_type: "user", + object_id: "my-user" + ) + end.to raise_error(GRPC::NotFound) + end + + it "raises error when getting another deleted object" do + expect do + directory.get_object( + object_type: "group", + object_id: "my-group" + ) + end.to raise_error(GRPC::NotFound) + end + + it "returns [] when there are no user objects" do + expect(directory.get_objects(object_type: "user").results).to eq([]) + end + + it "returns [] when there are no group objects" do + expect(directory.get_objects(object_type: "group").results).to eq([]) + end + + it "imports objects and relations" do + expect do + directory.import( + [ + { object: { id: "import-user", type: "user" } }, + { object: { id: "import-group", type: "group" } }, + { + relation: { + subject_id: "import-user", + subject_type: "user", + relation: "member", + object_id: "import-group", + object_type: "group" + } + } + ] + ) + end.not_to raise_error + end + + it "exports objects" do + expect(directory.export(data_type: :objects).length).to eq(2) + end + + it "exports relations" do + expect(directory.export(data_type: :relations).length).to eq(1) + end + + it "exports all" do + expect(directory.export(data_type: :all).length).to eq(3) + end + + it "lists the new group objects" do + expect(directory.get_objects(object_type: "group").results[0].to_h[:id]).to eq("import-group") + end + + it "lists the new user objects" do + expect(directory.get_objects(object_type: "user").results[0].to_h[:id]).to eq("import-user") + end + + it "reads the relation with_objects" do + expect(directory.get_relation( + subject_id: "import-user", + subject_type: "user", + relation: "member", + object_id: "import-group", + object_type: "group", + with_objects: true + ).objects.length).to eq(2) + end + + it "deletes user object with relations" do + expect do + directory.delete_object(object_id: "import-user", object_type: "user", with_relations: true) + end.not_to raise_error + end + + it "deletes group object with relations" do + expect do + directory.delete_object(object_id: "import-group", object_type: "group", with_relations: true) + end.not_to raise_error + end + + it "raises error when getting a deleted object after import" do + expect do + directory.get_object( + object_type: "user", + object_id: "import-user" + ) + end.to raise_error(GRPC::NotFound) + end + + it "raises error when getting another deleted object after import" do + expect do + directory.get_object( + object_type: "group", + object_id: "import-group" + ) + end.to raise_error(GRPC::NotFound) + end + + it "raises error when getting a deleted relation after import" do + expect do + directory.get_relation( + subject_id: "import-user", + subject_type: "user", + relation: "member", + object_id: "import-group", + object_type: "group" + ) + end.to raise_error(GRPC::NotFound) + end +end diff --git a/spec/integration/topaz.rb b/spec/integration/topaz.rb new file mode 100644 index 0000000..df1136f --- /dev/null +++ b/spec/integration/topaz.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "timeout" + +class Topaz + class << self + # 2 minutes + WAIT_FOR_TOPAZ = 2 * 60 + CERT_FILE = File.join(ENV.fetch("HOME", ""), ".config/topaz/certs/grpc-ca.crt") + DB_DIR = File.join(ENV.fetch("HOME", ""), ".config/topaz/db") + CONFIG_DIR = File.join(ENV.fetch("HOME", ""), ".config/topaz/cfg") + + def run + stop + + if File.exist?(File.join(DB_DIR, "directory.db")) + File.rename(File.join(DB_DIR, "directory.db"), File.join(DB_DIR, "directory.bak")) + end + + if File.exist?(File.join(CONFIG_DIR, "config.yaml")) + File.rename(File.join(CONFIG_DIR, "config.yaml"), File.join(CONFIG_DIR, "config.bak")) + end + + configure + start + end + + def start + system "topaz start" + + Timeout.timeout(WAIT_FOR_TOPAZ) do + wait_for_certs + + client = Aserto::Directory::V3::Client.new( + { + url: "localhost:9292", + cert_path: CERT_FILE + } + ) + + client.get_objects(object_type: "user") + rescue GRPC::Unavailable => e + puts e.message + puts "sleep 2" + sleep 2 + puts "retry..." + + retry + end + + puts "server is running" + end + + def stop + system "topaz stop" + end + + def configure + system "topaz configure -r ghcr.io/aserto-policies/policy-todo:2.1.0 -n todo -d -s" + end + + def cleanup + stop + if File.exist?(File.join(DB_DIR, "directory.bak")) + File.rename(File.join(DB_DIR, "directory.bak"), File.join(DB_DIR, "directory.db")) + end + + return unless File.exist?(File.join(CONFIG_DIR, "config.bak")) + + File.rename(File.join(CONFIG_DIR, "config.bak"), File.join(CONFIG_DIR, "config.yaml")) + end + + def wait_for_certs + sleep(2) until File.exist?(CERT_FILE) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 0dee225..c6d404d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,9 +5,6 @@ require "grpc_mock/rspec" require "rack" -require "aserto" -require "google/protobuf/well_known_types" - require "simplecov" SimpleCov.start do enable_coverage :branch @@ -25,12 +22,28 @@ config.expose_dsl_globally = true + # integration tests setup + config.before(:all, type: :integration) do + require_relative "integration/topaz" + + GrpcMock.allow_net_connect! + Topaz.run + end + + config.after(:all, type: :integration) do + require_relative "integration/topaz" + + GrpcMock.disable_net_connect! + Topaz.cleanup + end + config.expect_with :rspec do |c| c.syntax = :expect end end # configure Aserto +require "aserto" Aserto.configure do |config| config.policy_name = "peoplefinder" config.authorizer_api_key = "123456"