Skip to content

Commit 455bd5a

Browse files
authored
Merge pull request #482 from Shopify/api-access
Introduce ApiAccess for representing access scopes
2 parents 22e7fcb + 227a56c commit 455bd5a

File tree

6 files changed

+351
-55
lines changed

6 files changed

+351
-55
lines changed

README.md

Lines changed: 18 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,24 @@ To easily install or upgrade to the latest release, use [pip](http://www.pip-ins
2121
pip install --upgrade ShopifyAPI
2222
```
2323

24+
### Table of Contents
25+
26+
- [Getting Started](#getting-started)
27+
- [Public and Custom Apps](#public-and-custom-apps)
28+
- [Private Apps](#private-apps)
29+
- [Billing](#billing)
30+
- [Session Tokens](docs/session-tokens)
31+
- [Handling Access Scope Operations](docs/api-access.md)
32+
- [Advanced Usage](#advanced-usage)
33+
- [Prefix Options](#prefix-options)
34+
- [Console](#console)
35+
- [GraphQL](#graphql)
36+
- [Using Development Version](#using-development-version)
37+
- [Relative Cursor Pagination](#relative-cursor-pagination)
38+
- [Limitations](#limitations)
39+
- [Additional Resources](#additional-resources)
40+
41+
2442
### Getting Started
2543
#### Public and Custom Apps
2644

@@ -120,61 +138,6 @@ _Note: Your application must be public to test the billing process. To test on a
120138
has_been_billed = activated_charge.status == 'active'
121139
```
122140

123-
### Session tokens
124-
125-
The Shopify Python API library provides helper methods to decode [session tokens](https://shopify.dev/concepts/apps/building-embedded-apps-using-session-tokens). You can use the `decode_from_header` function to extract and decode a session token from an HTTP Authorization header.
126-
127-
#### Basic usage
128-
129-
```python
130-
from shopify import session_token
131-
132-
decoded_payload = session_token.decode_from_header(
133-
authorization_header=your_auth_request_header,
134-
api_key=your_api_key,
135-
secret=your_api_secret,
136-
)
137-
```
138-
139-
#### Create a decorator using `session_token`
140-
141-
Here's a sample decorator that protects your app views/routes by requiring the presence of valid session tokens as part of a request's headers.
142-
143-
```python
144-
from shopify import session_token
145-
146-
147-
def session_token_required(func):
148-
def wrapper(*args, **kwargs):
149-
request = args[0] # Or flask.request if you use Flask
150-
try:
151-
decoded_session_token = session_token.decode_from_header(
152-
authorization_header = request.headers.get('Authorization'),
153-
api_key = SHOPIFY_API_KEY,
154-
secret = SHOPIFY_API_SECRET
155-
)
156-
with shopify_session(decoded_session_token):
157-
return func(*args, **kwargs)
158-
except session_token.SessionTokenError as e:
159-
# Log the error here
160-
return unauthorized_401_response()
161-
162-
return wrapper
163-
164-
165-
def shopify_session(decoded_session_token):
166-
shopify_domain = decoded_session_token.get("dest")
167-
access_token = get_offline_access_token_by_shop_domain(shopify_domain)
168-
169-
return shopify.Session.temp(shopify_domain, SHOPIFY_API_VERSION, access_token)
170-
171-
172-
@session_token_required # Requests to /products require session tokens
173-
def products(request):
174-
products = shopify.Product.find()
175-
...
176-
```
177-
178141
### Advanced Usage
179142
It is recommended to have at least a basic grasp on the principles of the [pyactiveresource](https://github.com/Shopify/pyactiveresource) library, which is a port of rails/ActiveResource to Python and upon which this package relies heavily.
180143

docs/api-access.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Handling access scope operations
2+
3+
#### Table of contents
4+
5+
- [Common ApiAccess operations](#common-apiaccess-operations)
6+
- [Using ApiAccess to handle changes in app access scopes](#using-apiaccess-to-handle-changes-in-app-access-scopes)
7+
8+
There are common operations that are used for managing [access scopes](https://shopify.dev/docs/admin-api/access-scopes) in apps. Such operations include serializing, deserializing and normalizing scopes. Other operations can include checking whether two sets of scopes grant the same API access or whether one set covers the access granted by another set.
9+
10+
To encapsulate the access granted by access scopes, you can use the `ApiAccess` value object.
11+
12+
## Common ApiAccess operations
13+
14+
### Constructing an ApiAccess
15+
16+
```python
17+
api_access = ApiAccess(["read_products", "write_orders"]) # List of access scopes
18+
another_api_access = ApiAccess("read_products, write_products, unauthenticated_read_themes") # String of comma-delimited access scopes
19+
```
20+
21+
### Serializing ApiAccess
22+
23+
```python
24+
api_access = ApiAccess(["read_products", "write_orders", "unauthenticated_read_themes"])
25+
26+
access_scopes_list = list(api_access) # ["read_products", "write_orders", "unauthenticated_read_themes"]
27+
comma_delmited_access_scopes = str(api_access) # "read_products,write_orders,unauthenticated_read_themes"
28+
```
29+
30+
### Comparing ApiAccess objects
31+
32+
#### Checking for API access equality
33+
34+
```python
35+
expected_api_access = ApiAccess(["read_products", "write_orders"])
36+
37+
actual_api_access = ApiAccess(["read_products", "read_orders", "write_orders"])
38+
non_equal_api_access = ApiAccess(["read_products", "write_orders", "read_themes"])
39+
40+
actual_api_access == expected_api_access # True
41+
non_equal_api_access == expected_api_access # False
42+
```
43+
44+
#### Checking if ApiAccess covers the access of another
45+
46+
```python
47+
superset_access = ApiAccess(["write_products", "write_orders", "read_themes"])
48+
subset_access = ApiAccess(["read_products", "write_orders"])
49+
50+
superset_access.covers(subset_access) # True
51+
```
52+
53+
## Using ApiAccess to handle changes in app access scopes
54+
55+
If your app has changes in the access scopes it requests, you can use the `ApiAccess` object to determine whether the merchant needs to go through OAuth based on the scopes currently granted. A sample decorator shows how this can be achieved when loading an app.
56+
57+
```python
58+
from shopify import ApiAccess
59+
60+
61+
def oauth_on_access_scopes_mismatch(func):
62+
def wrapper(*args, **kwargs):
63+
shop_domain = get_shop_query_paramer(request) # shop query param when loading app
64+
current_shop_scopes = ApiAccess(ShopStore.get_record(shopify_domain = shop_domain).access_scopes)
65+
expected_access_scopes = ApiAccess(SHOPIFY_API_SCOPES)
66+
67+
if current_shop_scopes != expected_access_scopes:
68+
return redirect_to_login() # redirect to OAuth to update access scopes granted
69+
70+
return func(*args, **kwargs)
71+
72+
return wrapper
73+
```

docs/session-tokens.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Session tokens
2+
3+
The Shopify Python API library provides helper methods to decode [session tokens](https://shopify.dev/concepts/apps/building-embedded-apps-using-session-tokens). You can use the `decode_from_header` function to extract and decode a session token from an HTTP Authorization header.
4+
5+
## Basic usage
6+
7+
```python
8+
from shopify import session_token
9+
10+
decoded_payload = session_token.decode_from_header(
11+
authorization_header=your_auth_request_header,
12+
api_key=your_api_key,
13+
secret=your_api_secret,
14+
)
15+
```
16+
17+
## Create a decorator using `session_token`
18+
19+
Here's a sample decorator that protects your app views/routes by requiring the presence of valid session tokens as part of a request's headers.
20+
21+
```python
22+
from shopify import session_token
23+
24+
25+
def session_token_required(func):
26+
def wrapper(*args, **kwargs):
27+
request = args[0] # Or flask.request if you use Flask
28+
try:
29+
decoded_session_token = session_token.decode_from_header(
30+
authorization_header = request.headers.get('Authorization'),
31+
api_key = SHOPIFY_API_KEY,
32+
secret = SHOPIFY_API_SECRET
33+
)
34+
with shopify_session(decoded_session_token):
35+
return func(*args, **kwargs)
36+
except session_token.SessionTokenError as e:
37+
# Log the error here
38+
return unauthorized_401_response()
39+
40+
return wrapper
41+
42+
43+
def shopify_session(decoded_session_token):
44+
shopify_domain = decoded_session_token.get("dest")
45+
access_token = get_offline_access_token_by_shop_domain(shopify_domain)
46+
47+
return shopify.Session.temp(shopify_domain, SHOPIFY_API_VERSION, access_token)
48+
49+
50+
@session_token_required # Requests to /products require session tokens
51+
def products(request):
52+
products = shopify.Product.find()
53+
...
54+
```

shopify/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
from shopify.resources import *
44
from shopify.limits import Limits
55
from shopify.api_version import *
6+
from shopify.api_access import *
67
from shopify.collection import PaginatedIterator

shopify/api_access.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import re
2+
3+
4+
class ApiAccessError(Exception):
5+
pass
6+
7+
8+
class ApiAccess:
9+
10+
SCOPE_DELIMITER = ","
11+
SCOPE_RE = re.compile(r"\A(?P<unauthenticated>unauthenticated_)?(write|read)_(?P<resource>.*)\Z")
12+
IMPLIED_SCOPE_RE = re.compile(r"\A(?P<unauthenticated>unauthenticated_)?write_(?P<resource>.*)\Z")
13+
14+
def __init__(self, scopes):
15+
if type(scopes) == str:
16+
scopes = scopes.split(self.SCOPE_DELIMITER)
17+
18+
self.__store_scopes(scopes)
19+
20+
def covers(self, api_access):
21+
return api_access._compressed_scopes <= self._expanded_scopes
22+
23+
def __str__(self):
24+
return self.SCOPE_DELIMITER.join(self._compressed_scopes)
25+
26+
def __iter__(self):
27+
return iter(self._compressed_scopes)
28+
29+
def __eq__(self, other):
30+
return type(self) == type(other) and self._compressed_scopes == other._compressed_scopes
31+
32+
def __store_scopes(self, scopes):
33+
sanitized_scopes = frozenset(filter(None, [scope.strip() for scope in scopes]))
34+
self.__validate_scopes(sanitized_scopes)
35+
36+
implied_scopes = frozenset(self.__implied_scope(scope) for scope in sanitized_scopes)
37+
self._compressed_scopes = sanitized_scopes - implied_scopes
38+
self._expanded_scopes = sanitized_scopes.union(implied_scopes)
39+
40+
def __validate_scopes(self, scopes):
41+
for scope in scopes:
42+
if not self.SCOPE_RE.match(scope):
43+
error_message = "'{s}' is not a valid access scope".format(s=scope)
44+
raise ApiAccessError(error_message)
45+
46+
def __implied_scope(self, scope):
47+
match = self.IMPLIED_SCOPE_RE.match(scope)
48+
if match:
49+
return "{unauthenticated}read_{resource}".format(
50+
unauthenticated=match.group("unauthenticated") or "",
51+
resource=match.group("resource"),
52+
)

0 commit comments

Comments
 (0)