Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposed Enhancements to support Salesforce Single Sign On #47

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions demo/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'''

'''
__author__ = 'eb'

77 changes: 77 additions & 0 deletions demo/auth_demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# PyPardot4 Single Sign On Enhancements

The upcoming Spring '21 release of Salesforce requires that all access to Pardot
be authenticated via Single Sign On with Salesforce via OAuth2. This branch provides
an approach to enhancing PyPardot4 to extend it's functionality to be ready for
the new release. This branch is **NOT** meant to actually be merged, as is, into
the PyPardot4 project. Rather, it was committed to provide an example of an approach
and to discuss with the community if it would be a valuable addition, and if so, what changes
would be needed to prepare the code to be merged with the project.

## Approach

1. To show additional functionality through a subclass of PardotAPI, so as to highlight the
methodology for supporting SSO without changing existing code.
1. To separate authentication methodology from the api through the
use of a hierarchy of authenticator classes.

## Demonstration

There is demonstration code provided in `oauth_demo.py` that shows:
1. Retroactive support for Pardot-Only authentication.
1. Example of how to perform SSO via OAuth2 authentication using raw `requests` level construction.
1. Support for SSO via OAuth2 authentication via the AuthPardotAPI subclass of PardotAPI

The demonstration program requires a configuration file, `oauth_demo.ini` which by default
is sought in the users home directory. This can be changed at the top level of the
demo program. The content needed in that file is described in `pardot_demo.ini` next to
the demonstration program. Since the config file will have private credentials in it,
it is purposefully located outside of the project by default. The configuration file has sections
for different pardot and salesforce instances, both production and sand box. If you do not desire to
hit against any of these while running the demo, or you do not have access to any of these, you
can eleminate those sections. The demo program will skip code that requires the missing sections.

## Issues for Discussion

### Retroactive Support

Given that Pardot-Only authentication is fully going away, retroactive support for
the old style of authentication is not really necessary. However, for the short time that both exist, it
is useful for testing. Given this, it is not necessary to truly subclass the Pardot API class as shown here.
That architecture was used in this version to show a clean separation between what was and what can be.

### Separating Authentication from PardotAPI

That said, it is suggested strongly that authentication functionality be taken out of the
updated PardotAPI class and be shifted to a proper hierarchy of authentication classes.
A quick review of the
[`simple_salesforce`](https://github.com/simple-salesforce/simple-salesforce) package shows
three or more different ways in which one can authenticate with the Salesforce API.
The code in this branch demonstrates achieving SSO using only one of them. By separating
the authentication from the API class, it allows the PyPardot4 package to be easily extendable
to other methods of Salesforce authentication to be used for SSO if necessary.

### Context Managers

It would seem very appropriate to enhance the PardotAPI class to be a context manager, such
that it is authenticated as part of entering the context. This branch does not yet include
those extensions in the AuthPardotAPI sub-class to not confuse the primary objective of
accomplishing SSO. However, if this code is to be re-worked in order to be merged into
the project, it is strongly suggested that this enhancement be included.

## Caveats - IMPORTANT

### 3.7

This code was developed and tested under Python 3.7.4.
It was not tested under any other version.

### Relative Imports

This demonstration code made every effort to not change any of the existing code and demonstrate
the SSO functionality simply by adding code (for now). However, there were changes made
removing all relative imports from existing files to enable the code to execute in our 3.7.4 environment.
This is another good reason not to merge this code as is. If the community decides
that we should move ahead with this branch, the continued use of relative imports should
be discussed and if it is decided that they should continue to be utilized, we will need
assistance in figuring out how to get them to work in our environment.
6 changes: 6 additions & 0 deletions demo/auth_demo/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
'''

'''
__author__ = 'eb'


222 changes: 222 additions & 0 deletions demo/auth_demo/oauth_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
'''
A demonstration of connecting to Pardot using both
a pardot-only user and the existing api and
using a sf user utilizing the expanded api.

This demonstration requires a configuration file.
By default it looks for a file called
`oauth_demo.ini` in the users home directory
(see __main__ at the bottom of this file).

This file is used to supply the authentication data
needed to run this code. In this package is
a file `pardot_demo.ini` that contains the structure
of the config file and a description of what values
need to be provided.
'''
__author__ = 'eb'

import logging
from logging import Logger
from pathlib import Path
from typing import Tuple, Dict, List
from configparser import ConfigParser

import requests

from auth_pardot_api import AuthPardotAPI
from client import PardotAPI
from auth_handler import T as AuthHndlr, TraditionalAuthHandler, OAuthHandler


class PardotAuthenticationDemo(object):

def __init__(self, config_file: Path, logger: Logger = None) -> None:
super().__init__()
self.config_file = config_file
self.logger = logger
self.parser = ConfigParser()
self.parser.read(config_file)

def run(self):
# Demonstrate accessing pardot the traditional way using
# a Pardot-Only user
self.access_pardot_using_pardot_only_user()

# Demonstrate the formation of and responses to the low
# level requests needed to access pardot using
# a salesforce user via SSO using OAuth2
self.access_pardot_via_sso_using_raw_requests()

# Demonstrate accessing pardot using
# a salesforce user via SSO using OAuth2
# using the AuthPardotAPI sub-class of the existing PardotAPI class
self.access_pardot_via_sso_in_enhanced_api()

def access_pardot_using_pardot_only_user(self):
"""
Use the existing PardotAPI to fetch prospect data via a pardot-only user.
"""

# Using the traditional PyPardot4 PardotAPI class
self.logger and self.logger.info("\tAccess Pardot via PardotAPI using Pardot-Only User")
if self.has_sections(["test_data", "pardot"]):
auth_handler = self.get_auth_handler("pardot")
pd = PardotAPI(auth_handler.username, auth_handler.password, auth_handler.userkey)
pd.authenticate()
self.query_pardot_api(pd, "pardot")

# using a TraditionalAuthHandler via the enhanced AuthPardotAPI
self.logger and self.logger.info("\tAccess Pardot via AuthPardotAPI using Pardot-Only User")
if self.has_sections(["test_data", "pardot"]):
auth_handler = self.get_auth_handler("pardot")
pd = AuthPardotAPI(auth_handler, logger=self.logger)
pd.authenticate()
self.query_pardot_api(pd, "pardot")

# To a sandbox using a TraditionalAuthHandler via the enhanced AuthPardotAPI
self.logger and self.logger.info("\tAccess Pardot Sandbox via AuthPardotAPI using Pardot-Only User")
if self.has_sections(["test_data", "pardot_sandbox"]):
auth_handler = self.get_auth_handler("pardot_sandbox")
pd = AuthPardotAPI(auth_handler, logger=self.logger)
pd.authenticate()
self.query_pardot_api(pd, "pardot_sandbox")
self.logger and self.logger.info("\t\t...Success")

def access_pardot_via_sso_using_raw_requests(self):
"""
Demonstrate the formation of and responses to the low
level requests needed to access pardot using
a salesforce user via SSO using OAuth2
"""
self.logger and self.logger.info("\tAccess Pardot via SF SSO Using Raw Requests")
if self.has_sections(["test_data", "salesforce"]):
access_token, bus_unit_id = self.retrieve_access_token()
prospects = self.send_pardot_request(access_token, bus_unit_id)
self.logger and self.logger.info("\t\t...Success")

def access_pardot_via_sso_in_enhanced_api(self):
"""
Use the AuthPardotAPI to fetch prospect data via sso using OAuth2 authentication
"""

# Accessing a production pardot server using credentials from a production salesforce instance
self.logger and self.logger.info("\tAccess Pardot via SF SSO")
if self.has_sections(["test_data", "salesforce"]):
auth_handler = self.get_auth_handler("salesforce")
pd = AuthPardotAPI(auth_handler, logger=self.logger)
self.query_pardot_api(pd, "salesforce")
self.logger and self.logger.info("\t\t...Success")

# Accessing a pardot sandbox server using credentials from a salesforce sandbox instance
# Commented out because I can't test this, our pardot sandbox can not be authenticated using SSO.
# self.logger and self.logger.info("\tAccess Pardot Sandbox via SF Sandbox SSO")
# if self.has_sections(["test_data", "salesforce_sandbox"]):
# auth_handler = self.get_auth_handler("salesforce_sandbox")
# pd = AuthPardotAPI(auth_handler, logger=self.logger)
# self.query_pardot_api(pd, "salesforce_sandbox")
# self.logger and self.logger.info("\t\t...Success")

def query_pardot_api(self, pd: PardotAPI, section: str) -> Tuple[Dict, List]:
"""
Demonstrate and return prospect data fetched from pardot using the pardot api.
This code purposefully includes the use of the api read_by_email() and query() method,
because the former utilizes an http post() while the later utilizes an http get().
This ensures the demonstration includes both forms of interaction.
"""
prospect_email = self.parser.get("test_data", "prospect_email")
response = pd.prospects.read_by_email(email=prospect_email)
prospect = response["prospect"]
self.logger and self.logger.info(
f"\t\tFound Prospect in {section}: {prospect['first_name']} {prospect['last_name']} for email {prospect['email']}")

prospect_date_filter = self.parser.get("test_data", "prospect_date_filter", fallback="2021-01-01")
response = pd.prospects.query(created_after=prospect_date_filter)
prospects = response["prospect"]
self.logger and self.logger.info(
f"\t\tFound {len(prospects)} Prospects in {section} created after {prospect_date_filter}:")
for p in prospects:
self.logger and self.logger.debug(
f"\t\t\t{p['created_at']}: {p['first_name']} {p['last_name']} <{p['email']}>")

return prospect, prospects

def retrieve_access_token(self):
self.logger and self.logger.debug("\t\tAuthenticate Pardot with with OAuth2 parameters from 'salesforce'")
auth_handler = self.get_auth_handler("salesforce")
params = {
"grant_type": "password",
"client_id": auth_handler.consumer_key,
"client_secret": auth_handler.consumer_secret,
"username": auth_handler.username,
"password": auth_handler.password + auth_handler.token
}
url = "https://login.salesforce.com/services/oauth2/token"
r = requests.post(url, params=params)
access_token = r.json().get("access_token")
instance_url = r.json().get("instance_url")
self.logger and self.logger.debug(
f"\t\tRetrieved oauth access_token for {instance_url}")

return access_token, auth_handler.business_unit_id

def send_pardot_request(self, access_token, bus_unit_id) -> List:
prospect_date_filter = self.parser.get("test_data", "prospect_date_filter", fallback="2021-01-01")
params = {"format": "json",
"created_after": prospect_date_filter}
headers = {"Authorization": f"Bearer {access_token}",
"Pardot-Business-Unit-Id": bus_unit_id,
"Content-Type": "application/x-www-form-urlencoded"
}
url = "https://pi.pardot.com/api/prospect/version/4/do/query"
response = requests.post(url,
params=params,
headers=headers).json()
if '@attributes' not in response or 'stat' not in response['@attributes']:
raise ValueError(f"Pardot Request Failure: Corrupted Response")
if response['@attributes']['stat'] != "ok":
raise ValueError(f"Pardot Request Failure: {response['@attributes']['stat']}")

prospects = response["result"]["prospect"]
self.logger and self.logger.info(
f"\t\tFound {len(prospects)} Prospects in Production created after {prospect_date_filter}:")
for p in prospects:
self.logger and self.logger.debug(
f"\t\t\t{p['created_at']}: {p['first_name']} {p['last_name']} <{p['email']}>")
return prospects

def get_auth_handler(self, section_name: str) -> AuthHndlr:
if section_name.startswith("pardot"):
return TraditionalAuthHandler(self.parser.get(section_name, "username"),
self.parser.get(section_name, "password"),
self.parser.get(section_name, "userkey"),
logger=self.logger)
elif section_name.startswith("salesforce"):
return OAuthHandler(self.parser.get(section_name, "user"),
self.parser.get(section_name, "password"),
self.parser.get(section_name, "consumer_key"),
self.parser.get(section_name, "consumer_secret"),
self.parser.get(section_name, "business_unit_id"),
token=self.parser.get(section_name, "token"),
logger=self.logger)

def has_sections(self, sections: List[str]) -> bool:
missing = [s for s in sections if s not in self.parser.sections()]
valid = len(missing) == 0
if not valid:
self.logger and self.logger.info(f"\t\t...Skip, missing sections {missing} "
f"from config file {self.config_file}")
return valid


if __name__ == '__main__':
logger = logging.getLogger("OAUTH_DEMO")
logger.setLevel(logging.INFO) # Change to logging.DEBUG for more detailed output
ch = logging.StreamHandler()
formatter = logging.Formatter('[{levelname:>8s}] {asctime} {name:s}: {message:s}', style='{')
ch.setFormatter(formatter)
ch.setLevel(logging.NOTSET)
logger.addHandler(ch)
config_file = Path("~/oauth_demo.ini").expanduser()
demo = PardotAuthenticationDemo(config_file, logger=logger)
demo.run()
33 changes: 33 additions & 0 deletions demo/auth_demo/pardot_demo.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
###############################
[pardot]
username=<pardot only username>
password=<pardot only password>
userkeyn=<pardot API User Key>
prospect_email=<email of a valid prospect in pardot>
prospect_date_filter=<date (YYYY-MM-DD) after which a few prospects were created>

[pardot_sandbox]
username=<username>
password=<password>
userkeyn=<pardot API User Key>

[salesforce]
user=<sf login user email>
password=<sf password>
token=<SF security token>
consumer_key=<sf key associated with the connected app created in app manager>
consumer_secret=<sf secret associated with the connected app created in app manager>
business_unit_id=<id assigned to pardot by sf found in Pardot Account Setup in sf>

[salesforce_sandbox]
user=<sf login user email>
password=<sf password>
token=<SF security token>
consumer_key=<sf key associated with the connected app created in app manager>
consumer_secret=<sf secret associated with the connected app created in app manager>
business_unit_id=<id assigned to pardot by sf found in Pardot Account Setup in sf>

[test_data]
prospect_email=<email of a valid prospect in pardot>
prospect_date_filter=<date (YYYY-MM-DD) after which a few prospects were created>
###############################
Loading