diff --git a/examples/account_management/invite_user_with_access_role.rb b/examples/account_management/invite_user_with_access_role.rb index d96de3e8f..3e66b2ccc 100755 --- a/examples/account_management/invite_user_with_access_role.rb +++ b/examples/account_management/invite_user_with_access_role.rb @@ -1,7 +1,7 @@ #!/usr/bin/env ruby # Encoding: utf-8 # -# Copyright 2021 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -21,11 +21,28 @@ require 'optparse' require 'google/ads/google_ads' +ACCESS_ROLES = %w[ + ADMIN + STANDARD + READ_ONLY + EMAIL_ONLY +].freeze + def invite_user_with_access_role(customer_id, email_address, access_role) # GoogleAdsClient will read a config file from # ENV['HOME']/google_ads_config.rb when called without parameters client = Google::Ads::GoogleAds::GoogleAdsClient.new + # Sanitizes the customer ID to handle hyphenated inputs. + customer_id = customer_id.to_s.tr('-', '') + + access_role_str = access_role.to_s.upcase + if !ACCESS_ROLES.include?(access_role_str) + raise ArgumentError, "Illegal access role specified. Expected one of " \ + "#{ACCESS_ROLES.join(' ')}" + end + access_role = access_role_str.to_sym + # [START invite_user_with_access_role] operation = client.operation.create_resource.customer_user_access_invitation do |inv| inv.email_address = email_address @@ -38,14 +55,23 @@ def invite_user_with_access_role(customer_id, email_address, access_role) operation: operation, ) - # Prints out information of the created invitation. - puts "Customer user access invitation was sent for customerId = #{customer_id} " \ - "email address = '#{email_address}', " \ - "access role = '#{access_role}'." + if !response.result&.multi_party_auth_review.to_s.empty? + puts "A multi-party auth review was triggered. The MPA review resource " \ + "name is #{response.result&.multi_party_auth_review}. Ask a second " \ + "administrator to approve this request to send the user access invitation. " \ + "See advanced_operations/fetch_and_approve_pending_multi_party_auth_reviews.rb " \ + "for an example on how to approve an MPA auth review using the API." + else + # Print out information of the created invitation. + puts "Customer user access invitation was sent for customerId = #{customer_id} " \ + "email address = '#{email_address}', " \ + "access role = '#{access_role}'. The invitation resource name is " \ + "#{response.result&.resource_name || 'N/A'}." + end # [END invite_user_with_access_role] end -if __FILE__ == $PROGRAM_NAME +if __FILE__ == $0 options = {} # The following parameter(s) should be provided to run the example. You can # either specify these by changing the INSERT_XXX_ID_HERE values below, or on @@ -59,8 +85,8 @@ def invite_user_with_access_role(customer_id, email_address, access_role) options[:email_address] = 'INSERT_EMAIL_ADDRESS_HERE' options[:access_role] = 'INSERT_ACCESS_ROLE_HERE' - OptionParser.new do |opts| - opts.banner = sprintf('Usage: ruby %s [options]', File.basename(__FILE__)) + parser = OptionParser.new do |opts| + opts.banner = sprintf('Usage: %s [options]', File.basename(__FILE__)) opts.separator '' opts.separator 'Options:' @@ -84,25 +110,41 @@ def invite_user_with_access_role(customer_id, email_address, access_role) puts opts exit end - end.parse! + end + parser.parse! + + if options[:customer_id].nil? || options[:customer_id] == 'INSERT_CUSTOMER_ID_HERE' || + options[:email_address].nil? || options[:email_address] == 'INSERT_EMAIL_ADDRESS_HERE' || + options[:access_role].nil? || options[:access_role] == 'INSERT_ACCESS_ROLE_HERE' + puts "Missing required arguments. Customer ID (-C), Email Address (-E), and Access Role (-R) are required.\n\n" + puts parser + exit 1 + end + + unless ACCESS_ROLES.include?(options[:access_role]&.upcase) + puts "Invalid access role. Must be one of: #{ACCESS_ROLES.join(', ')}" + exit 1 + end begin invite_user_with_access_role( - options.fetch(:customer_id).tr("-", ""), + options.fetch(:customer_id), options.fetch(:email_address), - options.fetch(:access_role).to_sym, + options.fetch(:access_role), ) rescue Google::Ads::GoogleAds::Errors::GoogleAdsError => e + STDERR.printf("Request with ID '%s' failed with status '%s' and includes the following errors:\n", + e.request_id, e.class.name) e.failure.errors.each do |error| - STDERR.printf("Error with message: %s\n", error.message) + STDERR.printf("\tError with message: %s\n", error.message) if error.location error.location.field_path_elements.each do |field_path_element| - STDERR.printf("\tOn field: %s\n", field_path_element.field_name) + STDERR.printf("\t\tOn field: %s\n", field_path_element.field_name) end end error.error_code.to_h.each do |k, v| next if v == :UNSPECIFIED - STDERR.printf("\tType: %s\n\tCode: %s\n", k, v) + STDERR.printf("\t\tType: %s, Code: %s\n", k, v) end end raise diff --git a/examples/account_management/update_user_access.rb b/examples/account_management/update_user_access.rb index 8770082b2..44cd022aa 100755 --- a/examples/account_management/update_user_access.rb +++ b/examples/account_management/update_user_access.rb @@ -1,7 +1,7 @@ #!/usr/bin/env ruby # Encoding: utf-8 # -# Copyright 2020 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -26,15 +26,27 @@ require 'optparse' require 'google/ads/google_ads' +ACCESS_ROLES = %w[ + ADMIN + STANDARD + READ_ONLY + EMAIL_ONLY +].freeze + def update_user_access(customer_id, email_address, access_role) # GoogleAdsClient will read a config file from # ENV['HOME']/google_ads_config.rb when called without parameters client = Google::Ads::GoogleAds::GoogleAdsClient.new - if !ACCESS_ROLES.include?(access_role) - raise "Illegal access role specified. Expected one of " \ - "#{ACCESS_ROLES.join(" ")}" + # Sanitizes the customer ID to handle hyphenated inputs. + customer_id = customer_id.to_s.tr('-', '') + + access_role_str = access_role.to_s.upcase + if !ACCESS_ROLES.include?(access_role_str) + raise ArgumentError, "Illegal access role specified. Expected one of " \ + "#{ACCESS_ROLES.join(' ')}" end + access_role = access_role_str.to_sym user_id = get_user_id(client, customer_id, email_address) @@ -52,6 +64,7 @@ def get_user_id(client, customer_id, email_address) customer_user_access.access_creation_date_time FROM customer_user_access WHERE customer_user_access.email_address LIKE "#{email_address}" + LIMIT 1 QUERY response = client.service.google_ads.search( @@ -59,7 +72,7 @@ def get_user_id(client, customer_id, email_address) query: query, ) - result = response.first.customer_user_access + result = response.first&.customer_user_access if result puts "Customer user access with User ID #{result.user_id}, Access Role " \ "#{result.access_role}, Creation Time #{result.access_creation_date_time}, " \ @@ -75,7 +88,7 @@ def get_user_id(client, customer_id, email_address) def modify_user_access(client, customer_id, user_id, access_role) operation = client.operation.update_resource.customer_user_access( client.path.customer_user_access(customer_id, user_id)) do |ua| - ua.access_role = access_role.to_sym + ua.access_role = access_role end response = client.service.customer_user_access.mutate_customer_user_access( @@ -83,18 +96,19 @@ def modify_user_access(client, customer_id, user_id, access_role) operation: operation, ) - puts "Successfully updated customer user access with resource name " \ - "#{response.result.resource_name}." + if !response.result&.multi_party_auth_review.to_s.empty? + puts "A multi-party auth review was triggered. The MPA review resource " \ + "name is #{response.result&.multi_party_auth_review}. Ask a second " \ + "administrator to approve this request to make the requested user " \ + "access changes. See advanced_operations/fetch_and_approve_pending_multi_party_auth_reviews.rb " \ + "for an example on how to approve an MPA auth review using the API." + else + puts "Successfully updated customer user access with resource name " \ + "#{response.result&.resource_name || 'N/A'}." + end end if __FILE__ == $0 - ACCESS_ROLES = %w[ - ADMIN - STANDARD - READ_ONLY - EMAIL_ONLY - ] - options = {} # The following parameter(s) should be provided to run the example. You can # either specify these by changing the INSERT_XXX_ID_HERE values below, or on @@ -108,7 +122,7 @@ def modify_user_access(client, customer_id, user_id, access_role) options[:email_address] = 'INSERT_EMAIL_ADDRESS_HERE' options[:access_role] = 'INSERT_ACCESS_ROLE_HERE' - OptionParser.new do |opts| + parser = OptionParser.new do |opts| opts.banner = sprintf('Usage: %s [options]', File.basename(__FILE__)) opts.separator '' @@ -118,11 +132,11 @@ def modify_user_access(client, customer_id, user_id, access_role) options[:customer_id] = v end - opts.on('-e', '--email-address EMAIL-ADDRESS', String, 'Email Address') do |v| + opts.on('-E', '--email-address EMAIL-ADDRESS', String, 'Email Address') do |v| options[:email_address] = v end - opts.on('-a', '--access-role ACCESS-ROLE', String, 'Access Role') do |v| + opts.on('-R', '--access-role ACCESS-ROLE', String, 'Access Role') do |v| options[:access_role] = v end @@ -133,25 +147,41 @@ def modify_user_access(client, customer_id, user_id, access_role) puts opts exit end - end.parse! + end + parser.parse! + + if options[:customer_id].nil? || options[:customer_id] == 'INSERT_CUSTOMER_ID_HERE' || + options[:email_address].nil? || options[:email_address] == 'INSERT_EMAIL_ADDRESS_HERE' || + options[:access_role].nil? || options[:access_role] == 'INSERT_ACCESS_ROLE_HERE' + puts "Missing required arguments. Customer ID (-C), Email Address (-E), and Access Role (-R) are required.\n\n" + puts parser + exit 1 + end + + unless ACCESS_ROLES.include?(options[:access_role]&.upcase) + puts "Invalid access role. Must be one of: #{ACCESS_ROLES.join(', ')}" + exit 1 + end begin update_user_access( - options.fetch(:customer_id).tr("-", ""), + options.fetch(:customer_id), options.fetch(:email_address), options.fetch(:access_role), ) rescue Google::Ads::GoogleAds::Errors::GoogleAdsError => e + STDERR.printf("Request with ID '%s' failed with status '%s' and includes the following errors:\n", + e.request_id, e.class.name) e.failure.errors.each do |error| - STDERR.printf("Error with message: %s\n", error.message) + STDERR.printf("\tError with message: %s\n", error.message) if error.location error.location.field_path_elements.each do |field_path_element| - STDERR.printf("\tOn field: %s\n", field_path_element.field_name) + STDERR.printf("\t\tOn field: %s\n", field_path_element.field_name) end end error.error_code.to_h.each do |k, v| next if v == :UNSPECIFIED - STDERR.printf("\tType: %s\n\tCode: %s\n", k, v) + STDERR.printf("\t\tType: %s, Code: %s\n", k, v) end end raise diff --git a/examples/advanced_operations/fetch_and_approve_pending_multi_party_auth_reviews.rb b/examples/advanced_operations/fetch_and_approve_pending_multi_party_auth_reviews.rb new file mode 100755 index 000000000..1065bf5af --- /dev/null +++ b/examples/advanced_operations/fetch_and_approve_pending_multi_party_auth_reviews.rb @@ -0,0 +1,216 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# This example fetches pending multi-party approvals and approves the first one. +# +# Multi-party authorization (MPA) reviews can only be resolved one at a time. +# In this code example, we illustrate approving the first pending review request. + +require 'optparse' +require 'google/ads/google_ads' + +def fetch_and_approve_pending_multi_party_auth_reviews(customer_id) + # GoogleAdsClient will read a config file from + # ENV['HOME']/google_ads_config.rb when called without parameters + client = Google::Ads::GoogleAds::GoogleAdsClient.new + + # Sanitizes the customer ID to handle hyphenated inputs. + customer_id = customer_id.to_s.tr('-', '') + + # Retrieve the list of pending MPA reviews. + pending_reviews = fetch_pending_mpa_reviews(client, customer_id) + + if !pending_reviews.empty? + # Multi-party auth reviews can only be resolved one at a time. In this + # code example, we illustrate approving the first pending review request. + approve_mpa_review(client, customer_id, pending_reviews.first) + else + puts "No pending multi-party auth reviews found for customer ID #{customer_id}." + end +end + +# [START fetch_mpa_review] +def fetch_pending_mpa_reviews(client, customer_id) + customer_id = customer_id.to_s.tr('-', '') + pending_reviews = [] + + # Create a query that will retrieve all the pending MPA reviews. + query = <<~QUERY + SELECT + multi_party_auth_review.resource_name, + multi_party_auth_review.multi_party_auth_review_id, + multi_party_auth_review.creation_date_time, + multi_party_auth_review.review_status, + multi_party_auth_review.request_user_email, + multi_party_auth_review.operation_type, + multi_party_auth_review.justification, + multi_party_auth_review.target_resource, + multi_party_auth_review.customer_user_access_review.old_customer_user_access, + multi_party_auth_review.customer_user_access_review.new_customer_user_access, + multi_party_auth_review.customer_user_access_invitation_review.new_customer_user_access_invitation + FROM multi_party_auth_review + WHERE multi_party_auth_review.review_status = 'PENDING' + ORDER BY multi_party_auth_review.creation_date_time ASC + QUERY + + responses = client.service.google_ads.search_stream( + customer_id: customer_id, + query: query, + ) + + responses.each do |response| + response.results.each do |row| + mpa_review = row.multi_party_auth_review + puts "#{mpa_review.request_user_email} created a pending " \ + "multi-party auth review with ID #{mpa_review.multi_party_auth_review_id} " \ + "at #{mpa_review.creation_date_time}. This request is for target " \ + "resource type = #{mpa_review.target_resource} and operation type = " \ + "#{mpa_review.operation_type}. The justification is " \ + "\"#{mpa_review.justification}\"." + + if mpa_review.target_resource == :CUSTOMER_USER_ACCESS + access_review = mpa_review.customer_user_access_review + if mpa_review.operation_type == :UPDATE + # When updating a customer user access, only the new access level + # is populated. + puts "\tOld resource name: #{access_review&.old_customer_user_access}, " \ + "new access role: #{access_review&.new_customer_user_access&.access_role}." + elsif mpa_review.operation_type == :REMOVE + puts "\tOld resource name: #{access_review&.old_customer_user_access}." + end + elsif mpa_review.target_resource == :CUSTOMER_USER_ACCESS_INVITATION + new_invite = mpa_review.customer_user_access_invitation_review&.new_customer_user_access_invitation + if new_invite + puts "\tInvitation email address: #{new_invite.email_address}, " \ + "Role: #{new_invite.access_role}." + end + end + + pending_reviews << mpa_review.resource_name + end + end + + pending_reviews +end +# [END fetch_mpa_review] + +# [START approve_mpa_review] +def approve_mpa_review(client, customer_id, pending_review) + customer_id = customer_id.to_s.tr('-', '') + # Currently, you can only approve one request at a time. In addition, the approvals + # can only be done by a second administrator. + operation = client.operation.resolve_multi_party_auth_review do |op| + op.multi_party_auth_review = pending_review + op.new_status = :APPROVED + end + + response = client.service.multi_party_auth_review.resolve_multi_party_auth_review( + customer_id: customer_id, + operations: [operation], + ) + + result_or_error = response.result_or_error&.first + + if result_or_error&.result + result = result_or_error.result + puts "Approved multi-party auth review: #{result.multi_party_auth_review}." + if !result.customer_user_access_invitation.to_s.empty? + puts "New user invitation created: #{result.customer_user_access_invitation}" + elsif !result.customer_user_access.to_s.empty? + puts "Affected customer user access resource: #{result.customer_user_access}" + end + elsif result_or_error&.partial_failure_error + # Partial failure error + errors_count = 0 + failures = client.decode_partial_failure_error(result_or_error.partial_failure_error) || [] + failures.each do |failure| + failure.errors.each do |error| + puts "\tError with message '#{error.message}'." + errors_count += 1 + end + end + if errors_count.zero? + puts "Partial failure error occurred: #{result_or_error.partial_failure_error.message}" + else + puts "#{errors_count} partial failure error(s) occurred" + end + else + puts "No result or error returned." + end +end +# [END approve_mpa_review] + +if __FILE__ == $0 + options = {} + # The following parameter(s) should be provided to run the example. You can + # either specify these by changing the INSERT_XXX_ID_HERE values below, or on + # the command line. + # + # Parameters passed on the command line will override any parameters set in + # code. + # + # Running the example with -h will print the command line usage. + options[:customer_id] = 'INSERT_CUSTOMER_ID_HERE' + + parser = OptionParser.new do |opts| + opts.banner = sprintf('Usage: %s [options]', File.basename(__FILE__)) + + opts.separator '' + opts.separator 'Options:' + + opts.on('-C', '--customer-id CUSTOMER-ID', String, 'Customer ID') do |v| + options[:customer_id] = v + end + + opts.separator '' + opts.separator 'Help:' + + opts.on_tail('-h', '--help', 'Show this message') do + puts opts + exit + end + end + parser.parse! + + if options[:customer_id].nil? || options[:customer_id] == 'INSERT_CUSTOMER_ID_HERE' + puts "Missing required argument: --customer-id (-C) is required.\n\n" + puts parser + exit 1 + end + + begin + fetch_and_approve_pending_multi_party_auth_reviews( + options.fetch(:customer_id) + ) + rescue Google::Ads::GoogleAds::Errors::GoogleAdsError => e + STDERR.printf("Request with ID '%s' failed with status '%s' and includes the following errors:\n", + e.request_id, e.class.name) + e.failure.errors.each do |error| + STDERR.printf("\tError with message: %s\n", error.message) + if error.location + error.location.field_path_elements.each do |field_path_element| + STDERR.printf("\t\tOn field: %s\n", field_path_element.field_name) + end + end + error.error_code.to_h.each do |k, v| + next if v == :UNSPECIFIED + STDERR.printf("\t\tType: %s, Code: %s\n", k, v) + end + end + raise + end +end