diff --git a/Gemfile.lock b/Gemfile.lock index eccc83aa..c84ef6cd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: zendesk_api (3.1.1) + base64 faraday (> 2.0.0) faraday-multipart hashie (>= 3.5.2) diff --git a/lib/zendesk_api/client.rb b/lib/zendesk_api/client.rb index ec5fed22..2b9775bc 100644 --- a/lib/zendesk_api/client.rb +++ b/lib/zendesk_api/client.rb @@ -9,6 +9,7 @@ require 'zendesk_api/middleware/request/raise_rate_limited' require 'zendesk_api/middleware/request/upload' require 'zendesk_api/middleware/request/encode_json' +require 'zendesk_api/middleware/request/api_token_impersonate' require 'zendesk_api/middleware/request/url_based_access_token' require 'zendesk_api/middleware/response/callback' require 'zendesk_api/middleware/response/deflate' @@ -104,6 +105,25 @@ def initialize add_warning_callback end + # token impersonation for the scope of the block + # @param [String] username The username (email) of the user to impersonate + # @yield The block to run while impersonating the user + # @example + # client.api_token_impersonate("otheruser@yourcompany.com") do + # client.tickets.create(:subject => "Help!") + # end + # + # # creates a ticket on behalf of otheruser + # @return + # yielded value + def api_token_impersonate(username) + avant = Thread.current[:zendesk_thread_local_username] + Thread.current[:zendesk_thread_local_username] = username + yield + ensure + Thread.current[:zendesk_thread_local_username] = avant + end + # Creates a connection if there is none, otherwise returns the existing connection. # # @return [Faraday::Connection] Faraday connection for the client @@ -180,6 +200,7 @@ def build_connection end builder.adapter(*adapter, &config.adapter_proc) + builder.use ZendeskAPI::Middleware::Request::ApiTokenImpersonate end end diff --git a/lib/zendesk_api/middleware/request/api_token_impersonate.rb b/lib/zendesk_api/middleware/request/api_token_impersonate.rb new file mode 100644 index 00000000..49f54e54 --- /dev/null +++ b/lib/zendesk_api/middleware/request/api_token_impersonate.rb @@ -0,0 +1,28 @@ +require 'base64' +module ZendeskAPI + # @private + module Middleware + # @private + module Request + # ApiTokenImpersonate + # If Thread.current[:zendesk_thread_local_username] is set, it will modify the Authorization header + # to impersonate that user using the API token from the current Authorization header. + class ApiTokenImpersonate < Faraday::Middleware + def call(env) + if Thread.current[:zendesk_thread_local_username] && env[:request_headers][:authorization] =~ /^Basic / + current_u_p_encoded = env[:request_headers][:authorization].split(/\s+/)[1] + current_u_p = Base64.urlsafe_decode64(current_u_p_encoded) + unless current_u_p.include?("/token:") && (parts = current_u_p.split(":")) && parts.length == 2 && parts[0].include?("/token") + warn "WARNING: ApiTokenImpersonate passed in invalid format. It should be in the format username/token:APITOKEN" + return @app.call(env) + end + + next_u_p = "#{Thread.current[:zendesk_thread_local_username]}/token:#{parts[1]}" + env[:request_headers][:authorization] = "Basic #{Base64.urlsafe_encode64(next_u_p)}" + end + @app.call(env) + end + end + end + end +end diff --git a/spec/core/client_spec.rb b/spec/core/client_spec.rb index d19914a0..46cbbd38 100644 --- a/spec/core/client_spec.rb +++ b/spec/core/client_spec.rb @@ -360,4 +360,40 @@ def url.to_str expect(client.greeting_categories.path).to match(/channels\/voice\/greeting_categories/) end end + + context "#api_token_impersonate" do + let(:impersonated_username) { "otheruser@yourcompany.com" } + let(:api_token) { "abc123" } + let(:client) do + ZendeskAPI::Client.new do |config| + config.url = "https://example.zendesk.com/api/v2" + config.username = "original@company.com" + config.token = api_token + config.adapter = :test + config.adapter_proc = proc do |stub| + stub.get "/api/v2/tickets" do |env| + [200, { 'content-type': "application/json", Authorization: env.request_headers["Authorization"] }, "null"] + end + end + end + end + + it "impersonates the user for the scope of the block" do + result = nil + client.api_token_impersonate(impersonated_username) do + response = client.connection.get("/api/v2/tickets") + auth_header = response.env.request_headers["Authorization"] + decoded = Base64.urlsafe_decode64(auth_header.split.last) + expect(decoded).to start_with("#{impersonated_username}/token:") + result = response + end + expect(result).not_to be_nil + end + + it "restores the previous username after the block" do + original = Thread.current[:zendesk_thread_local_username] + client.api_token_impersonate(impersonated_username) { 1 } + expect(Thread.current[:zendesk_thread_local_username]).to eq(original) + end + end end diff --git a/spec/core/middleware/request/api_token_impersonate_spec.rb b/spec/core/middleware/request/api_token_impersonate_spec.rb new file mode 100644 index 00000000..39c43afe --- /dev/null +++ b/spec/core/middleware/request/api_token_impersonate_spec.rb @@ -0,0 +1,63 @@ +require 'core/spec_helper' + +RSpec.describe ZendeskAPI::Middleware::Request::ApiTokenImpersonate do + let(:app) { ->(env) { env } } + let(:middleware) { described_class.new(app) } + let(:username) { 'impersonated_user' } + let(:token) { 'abc123' } + let(:original_username) { 'original_user/token' } + let(:encoded_auth) { Base64.urlsafe_encode64("#{original_username}:#{token}") } + let(:env) do + { + request_headers: { + authorization: "Basic #{encoded_auth}" + } + } + end + + after { Thread.current[:zendesk_thread_local_username] = nil } + + context 'when local_username is set and authorization is a valid API token' do + it 'impersonates the user by modifying the Authorization header' do + Thread.current[:zendesk_thread_local_username] = username + result = middleware.call(env) + new_auth = result[:request_headers][:authorization] + decoded = Base64.urlsafe_decode64(new_auth.split.last) + expect(decoded).to eq("#{username}/token:#{token}") + end + end + + context 'when local_username is not set' do + it 'does not modify the Authorization header' do + result = middleware.call(env) + expect(result[:request_headers][:authorization]).to eq(env[:request_headers][:authorization]) + end + end + + context 'when authorization header is not Basic' do + it 'does not modify the Authorization header' do + Thread.current[:zendesk_thread_local_username] = username + env[:request_headers][:authorization] = 'Bearer something' + result = middleware.call(env) + expect(result[:request_headers][:authorization]).to eq('Bearer something') + end + end + + context 'when authorization does not contain /token:' do + it 'raises an error' do + Thread.current[:zendesk_thread_local_username] = username + env[:request_headers][:authorization] = "Basic #{Base64.urlsafe_encode64('user:abc123')}" + result = middleware.call(env) + expect(result[:request_headers][:authorization]).to eq("Basic #{Base64.urlsafe_encode64('user:abc123')}") + end + end + + context 'when authorization is not in valid format' do + it 'raises an error' do + Thread.current[:zendesk_thread_local_username] = username + env[:request_headers][:authorization] = "Basic #{Base64.urlsafe_encode64('user/token:abc123:extra')}" + result = middleware.call(env) + expect(result[:request_headers][:authorization]).to eq("Basic #{Base64.urlsafe_encode64('user/token:abc123:extra')}") + end + end +end diff --git a/spec/core/middleware/request/retry_spec.rb b/spec/core/middleware/request/retry_spec.rb index 82b58551..5a542150 100644 --- a/spec/core/middleware/request/retry_spec.rb +++ b/spec/core/middleware/request/retry_spec.rb @@ -17,7 +17,7 @@ def runtime expect(client.connection.get("blergh").status).to eq(200) } - expect(seconds).to be_within(0.2).of(1) + expect(seconds).to be_within(0.3).of(1) end end diff --git a/zendesk_api.gemspec b/zendesk_api.gemspec index fec405c3..2903c2f8 100644 --- a/zendesk_api.gemspec +++ b/zendesk_api.gemspec @@ -33,4 +33,5 @@ Gem::Specification.new do |s| s.add_dependency "inflection" s.add_dependency "multipart-post", "~> 2.0" s.add_dependency "mini_mime" + s.add_dependency "base64" end