diff --git a/CHANGELOG.md b/CHANGELOG.md index d168b16..74feb9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## upcoming + +### Added + +- Add support for [personal access token](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_auth.htm#make-a-sign-in-request-with-a-personal-access-token) authentication + +### Changed + +- Updated to API version 3.6 + + ## [4.0.0] - 2020-11-30 ### Changed/Fixed diff --git a/README.md b/README.md index 4244f44..f88b918 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,12 @@ client = TableauApi.new(host: 'https://tableau.domain.tld', site_name: 'Default' client.auth.trusted_ticket ``` +### Personal Access Token Authentication +``` +client = TableauApi.new(host: 'https://tableau.domain.tld', site_name: 'Default', personal_access_token_name: 'ExampleTokenName', personal_access_token_secret: 'ExampleTokenSecret') +client.users.create(username: 'baz') +``` + ### Workbooks ``` # find a workbook by name @@ -87,6 +93,7 @@ Then run the commands below: docker run -it -d \ -v $(pwd):/src \ -e TABLEAU_HOST -e TABLEAU_ADMIN_USERNAME -e TABLEAU_ADMIN_PASSWORD \ + -e TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_NAME -e TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_SECRET \ ruby /bin/bash docker exec -it CONTAINER_ID /bin/bash -c "cd /src && bundle && rake" ``` diff --git a/lib/tableau_api/client.rb b/lib/tableau_api/client.rb index 0f595d2..f32ef9e 100644 --- a/lib/tableau_api/client.rb +++ b/lib/tableau_api/client.rb @@ -1,8 +1,19 @@ module TableauApi class Client - attr_reader :host, :username, :password, :site_id, :site_name + AUTH_TYPE_PERSONAL_ACCESS_TOKEN = :personal_access_token_name + AUTH_TYPE_USERNAME_AND_PASSWORD = :username_and_password + AUTH_TYPE_TRUSTED_TICKET = :trusted_ticket - def initialize(host:, site_name:, username:, password: nil) + attr_reader :host, + :username, + :password, + :site_id, + :site_name, + :personal_access_token_name, + :personal_access_token_secret + + # rubocop:disable Metrics/ParameterLists + def initialize(host:, site_name:, username: nil, password: nil, personal_access_token_name: nil, personal_access_token_secret: nil) @resources = {} raise 'host is required' if host.to_s.empty? @@ -11,10 +22,20 @@ def initialize(host:, site_name:, username:, password: nil) raise 'site_name is required' if site_name.to_s.empty? @site_name = site_name - raise 'username is required' if username.to_s.empty? + raise 'username or personal_access_token_name is required' if personal_access_token_name.to_s.empty? && username.to_s.empty? + @personal_access_token_name = personal_access_token_name @username = username @password = password + @personal_access_token_secret = personal_access_token_secret + end + # rubocop:enable Metrics/ParameterLists + + def authentication_type + return AUTH_TYPE_PERSONAL_ACCESS_TOKEN unless @personal_access_token_name.to_s.empty? + return AUTH_TYPE_TRUSTED_TICKET if @password.to_s.empty? + + AUTH_TYPE_USERNAME_AND_PASSWORD end def connection diff --git a/lib/tableau_api/connection.rb b/lib/tableau_api/connection.rb index e370e3d..59d4f2a 100644 --- a/lib/tableau_api/connection.rb +++ b/lib/tableau_api/connection.rb @@ -1,6 +1,6 @@ module TableauApi class Connection - API_VERSION = '3.1'.freeze + API_VERSION = '3.6'.freeze include HTTParty headers 'User-Agent' => "tableau_api/#{::TableauApi::VERSION} Ruby/#{RUBY_VERSION}" diff --git a/lib/tableau_api/resources/auth.rb b/lib/tableau_api/resources/auth.rb index 9a2197a..c334b94 100644 --- a/lib/tableau_api/resources/auth.rb +++ b/lib/tableau_api/resources/auth.rb @@ -20,7 +20,7 @@ def sign_in return true if signed_in? request = Builder::XmlMarkup.new.tsRequest do |ts| - ts.credentials(name: @client.username, password: @client.password) do |cred| + ts.credentials(authentication_credentials) do |cred| cred.site(contentUrl: @client.site_name == 'Default' ? '' : @client.site_name) end end @@ -73,6 +73,19 @@ def trusted_ticket res.body end + + private + + def authentication_credentials + if @client.authentication_type == Client::AUTH_TYPE_PERSONAL_ACCESS_TOKEN + { + personalAccessTokenName: @client.personal_access_token_name, + personalAccessTokenSecret: @client.personal_access_token_secret + } + else + { name: @client.username, password: @client.password } + end + end end end end diff --git a/spec/client_spec.rb b/spec/client_spec.rb index 7a56a35..6caa608 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -7,14 +7,23 @@ expect(client).to be_an_instance_of(TableauApi::Client) end - it 'requires the host, site_name, and username' do + it 'requires the host, site_name, and one of username or personal_access_token_name' do expect { TableauApi.new(host: nil, site_name: 'bar', username: 'baz') }.to raise_error('host is required') expect { TableauApi.new(host: '', site_name: 'bar', username: 'baz') }.to raise_error('host is required') expect { TableauApi.new(host: 'foo', site_name: nil, username: 'baz') }.to raise_error('site_name is required') expect { TableauApi.new(host: 'foo', site_name: '', username: 'baz') }.to raise_error('site_name is required') - expect { TableauApi.new(host: 'foo', site_name: 'bar', username: nil) }.to raise_error('username is required') - expect { TableauApi.new(host: 'foo', site_name: 'bar', username: '') }.to raise_error('username is required') + expect { TableauApi.new(host: 'foo', site_name: 'bar', username: nil) }.to raise_error('username or personal_access_token_name is required') + expect { TableauApi.new(host: 'foo', site_name: 'bar', username: '') }.to raise_error('username or personal_access_token_name is required') + + expect { TableauApi.new(host: 'foo', site_name: 'bar', personal_access_token_name: nil) }.to raise_error('username or personal_access_token_name is required') + expect { TableauApi.new(host: 'foo', site_name: 'bar', personal_access_token_name: '') }.to raise_error('username or personal_access_token_name is required') + end + + it 'provides info about the authentication type based on params' do + expect(TableauApi.new(host: 'tableau.domain.tld', site_name: 'Default', personal_access_token_name: 'ExampleTokenName', personal_access_token_secret: 'ExampleTokenSecret').authentication_type).to eq(TableauApi::Client::AUTH_TYPE_PERSONAL_ACCESS_TOKEN) + expect(TableauApi.new(host: 'tableau.domain.tld', site_name: 'Default', username: 'ExampleUsername').authentication_type).to eq(TableauApi::Client::AUTH_TYPE_TRUSTED_TICKET) + expect(TableauApi.new(host: 'tableau.domain.tld', site_name: 'Default', username: 'ExampleUsername', password: 'ExamplePassword').authentication_type).to eq(TableauApi::Client::AUTH_TYPE_USERNAME_AND_PASSWORD) end end diff --git a/spec/resources/auth_spec.rb b/spec/resources/auth_spec.rb index f3d2468..dfbf9f0 100644 --- a/spec/resources/auth_spec.rb +++ b/spec/resources/auth_spec.rb @@ -10,48 +10,105 @@ ) end - # http://onlinehelp.tableau.com/v9.0/api/rest_api/en-us/help.htm#REST/rest_api_concepts_auth.htm%3FTocPath%3DConcepts%7C_____3 - # http://onlinehelp.tableau.com/v9.0/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Sign_In%3FTocPath%3DAPI%2520Reference%7C_____51 - it 'returns an instance of TableauApi::Resources::Auth' do - client = TableauApi.new(host: 'tableau.domain.tld', site_name: 'Default', username: 'ExampleUsername', password: 'ExamplePassword') - expect(client.auth).to be_an_instance_of(TableauApi::Resources::Auth) - end + describe 'username and password' do + # http://onlinehelp.tableau.com/v9.0/api/rest_api/en-us/help.htm#REST/rest_api_concepts_auth.htm%3FTocPath%3DConcepts%7C_____3 + # http://onlinehelp.tableau.com/v9.0/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Sign_In%3FTocPath%3DAPI%2520Reference%7C_____51 + it 'returns an instance of TableauApi::Resources::Auth' do + client = TableauApi.new(host: 'tableau.domain.tld', site_name: 'Default', username: 'ExampleUsername', password: 'ExamplePassword') + expect(client.auth).to be_an_instance_of(TableauApi::Resources::Auth) + end - it 'fails appropriately with a bad username or password' do - client = TableauApi.new(host: ENV['TABLEAU_HOST'], site_name: 'Default', username: 'foo', password: 'bar') - expect(client.auth.sign_in).to be false - end + it 'fails appropriately with a bad username or password' do + client = TableauApi.new(host: ENV['TABLEAU_HOST'], site_name: 'Default', username: 'foo', password: 'bar') + expect(client.auth.sign_in).to be false + end - it 'automatically signs in to get the token' do - expect(client.auth.token).to be_a_token - end + it 'automatically signs in to get the token' do + expect(client.auth.token).to be_a_token + end - it 'sucessfully signs in with a correct username and password' do - expect(client.auth.sign_in).to be true - expect(client.auth.token).to be_a_token - expect(client.auth.site_id).to be_a_tableau_id - expect(client.auth.user_id).to be_a_tableau_id - end + it 'sucessfully signs in with a correct username and password' do + expect(client.auth.sign_in).to be true + expect(client.auth.token).to be_a_token + expect(client.auth.site_id).to be_a_tableau_id + expect(client.auth.user_id).to be_a_tableau_id + end - it 'signs into a different site' do - client = TableauApi.new( - host: ENV['TABLEAU_HOST'], - site_name: 'TestSite', - username: ENV['TABLEAU_ADMIN_USERNAME'], - password: ENV['TABLEAU_ADMIN_PASSWORD'] - ) - expect(client.auth.sign_in).to be true - expect(client.auth.token).to be_a_token - expect(client.auth.site_id).to be_a_tableau_id - expect(client.auth.user_id).to be_a_tableau_id + it 'signs into a different site' do + client = TableauApi.new( + host: ENV['TABLEAU_HOST'], + site_name: 'TestSite', + username: ENV['TABLEAU_ADMIN_USERNAME'], + password: ENV['TABLEAU_ADMIN_PASSWORD'] + ) + expect(client.auth.sign_in).to be true + expect(client.auth.token).to be_a_token + expect(client.auth.site_id).to be_a_tableau_id + expect(client.auth.user_id).to be_a_tableau_id + end + + it 'does not sign in if already signed in' do + expect(client.auth.sign_in).to be true + token = client.auth.token + + expect(client.auth.sign_in).to be true + expect(client.auth.token).to eq token + end end - it 'does not sign in if already signed in' do - expect(client.auth.sign_in).to be true - token = client.auth.token + describe 'personal access token' do + let(:client) do + TableauApi.new( + host: ENV['TABLEAU_HOST'], + site_name: 'Default', + personal_access_token_name: ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_NAME'], + personal_access_token_secret: ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_SECRET'] + ) + end + + # http://onlinehelp.tableau.com/v9.0/api/rest_api/en-us/help.htm#REST/rest_api_concepts_auth.htm%3FTocPath%3DConcepts%7C_____3 + # http://onlinehelp.tableau.com/v9.0/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Sign_In%3FTocPath%3DAPI%2520Reference%7C_____51 + it 'returns an instance of TableauApi::Resources::Auth' do + client = TableauApi.new(host: 'tableau.domain.tld', site_name: 'Default', personal_access_token_name: 'ExamplePersonalAccessTokenName', personal_access_token_secret: 'ExamplePersonalAccessTokenSecret') + expect(client.auth).to be_an_instance_of(TableauApi::Resources::Auth) + end - expect(client.auth.sign_in).to be true - expect(client.auth.token).to eq token + it 'fails appropriately with a bad access token credentials' do + client = TableauApi.new(host: ENV['TABLEAU_HOST'], site_name: 'Default', personal_access_token_name: 'foo', personal_access_token_secret: 'bar') + expect(client.auth.sign_in).to be false + end + + it 'automatically signs in to get the token' do + expect(client.auth.token).to be_a_token + end + + it 'sucessfully signs in with a correct username and password' do + expect(client.auth.sign_in).to be true + expect(client.auth.token).to be_a_token + expect(client.auth.site_id).to be_a_tableau_id + expect(client.auth.user_id).to be_a_tableau_id + end + + it 'signs into a different site' do + client = TableauApi.new( + host: ENV['TABLEAU_HOST'], + site_name: 'TestSite', + personal_access_token_name: ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_NAME'], + personal_access_token_secret: ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_SECRET'] + ) + expect(client.auth.sign_in).to be true + expect(client.auth.token).to be_a_token + expect(client.auth.site_id).to be_a_tableau_id + expect(client.auth.user_id).to be_a_tableau_id + end + + it 'does not sign in if already signed in' do + expect(client.auth.sign_in).to be true + token = client.auth.token + + expect(client.auth.sign_in).to be true + expect(client.auth.token).to eq token + end end describe '.sign_out' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b27e9cd..3e5c157 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,11 +5,16 @@ require 'vcr' -if ENV['TABLEAU_ADMIN_USERNAME'].nil? || ENV['TABLEAU_ADMIN_PASSWORD'].nil? - puts 'TABLEAU_ADMIN_USERNAME and TABLEAU_ADMIN_PASSWORD must be set to record new VCR cassettes' +if ENV['TABLEAU_ADMIN_USERNAME'].nil? || + ENV['TABLEAU_ADMIN_PASSWORD'].nil? || + ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_NAME'].nil? || + ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_SECRET'].nil? + puts 'TABLEAU_ADMIN_USERNAME, TABLEAU_ADMIN_PASSWORD, TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_NAME, and TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_SECRET must be set to record new VCR cassettes' ENV['TABLEAU_ADMIN_USERNAME'] = 'FakeTableauAdminUsername' ENV['TABLEAU_ADMIN_PASSWORD'] = 'FakeTableauAdminPassword' + ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_NAME'] = 'FakeTableauAdminPersonalAccessTokenName' + ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_SECRET'] = 'FakeTableauAdminPersonalAccessTokenSecret' end ENV['TABLEAU_HOST'] = 'http://localhost:2000' if ENV['TABLEAU_HOST'].nil? @@ -22,6 +27,8 @@ config.filter_sensitive_data('TABLEAU_ADMIN_USERNAME') { ENV['TABLEAU_ADMIN_USERNAME'] } config.filter_sensitive_data('TABLEAU_ADMIN_PASSWORD') { ENV['TABLEAU_ADMIN_PASSWORD'].encode(xml: :text) } + config.filter_sensitive_data('TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_NAME') { ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_NAME'] } + config.filter_sensitive_data('TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_SECRET') { ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_SECRET'].encode(xml: :text) } config.filter_sensitive_data('http://TABLEAU_HOST') { ENV['TABLEAU_HOST'] } config.allow_http_connections_when_no_cassette = false @@ -30,7 +37,7 @@ config.before_record do |interaction| response = interaction.response elements = response.body.scan(/<(?:site|user)\s[^>]+name[^>]+>/) - sensitive_elements = elements.reject { |e| e.match(/"(Default|TestSite|Test Site 2|test|test_test|TABLEAU_ADMIN_USERNAME)"/) } + sensitive_elements = elements.reject { |e| e.match(/"(Default|TestSite|Test Site 2|test|test_test|TABLEAU_ADMIN_USERNAME|TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_NAME)"/) } unless sensitive_elements.empty? sensitive_elements.each { |e| response.body.gsub! e, '' } response.body.gsub!(/totalAvailable="\d+"/, "totalAvailable=\"#{elements.length - sensitive_elements.length}\"")