Scopes¶
In addition to protecting routes to authenticated users, they can be scoped to require one or more scopes by applying the @scoped()
decorator. This means that only users with a particular scope can access a particular endpoint.
Note
If you are using the @scoped
decorator, you do NOT also need the @protected
decorator. It is assumed that if you are scoping the endpoint, that it is also meant to be protected.
Requirements for a scoped¶
A scope is a string that consists of two parts:
- namespace
- actions
For example, it might look like this: user:read
.
Example Scopes¶
Example #1
scope: user
namespace: user
action: --
Example #2
scope: user:read
namespace: user
action: read
Example #3
scope: user:read:write
namespace: user
action: [read, write]
Example #4
scope: :read
namespace: --
action: read
Example #5
scope: :read:write
namespace: --
action: [read, write]
How are scopes accepted?¶
In defining a scoped route, you define one or more scopes that will be acceptable.
A scope is accepted if the payload contains a scope that is equal to or higher than what is required.
For sake of clarity in the below explanation, required_scope
means the scope that is required for access, and user_scope
is the scope that the access token has in its payload.
A scope is acceptable …
If the
required_scope
namespace and theuser_scope
namespace are equal# True required_scope = 'user' user_scope = 'user'
- If the
required_scope
has actions, then theuser_scope
must be:
- top level (no defined actions), or
- also has the same actions
# True required_scope = 'user:read' user_scope = 'user' # True required_scope = 'user:read' user_scope = 'user:read' # True required_scope = 'user:read' user_scope = 'user:read:write' # True required_scope = ':read' user_scope = ':read' # False required_scope = 'user:write' user_scope = 'user:read'
Examples¶
Here is a list of example scopes and whether they pass or not:
required scope user scope(s) outcome
============== ============= =======
'user' ['something'] False
'user' ['user'] True
'user:read' ['user'] True
'user:read' ['user:read'] True
'user:read' ['user:write'] False
'user:read' ['user:read:write'] True
'user' ['user:read'] False
'user:read:write' ['user:read'] False
'user:read:write' ['user:read:write'] True
'user:read:write' ['user:write:read'] True
'user' ['something', 'else'] False
'user' ['something', 'else', 'user'] True
'user:read' ['something:else', 'user:read'] True
'user:read' ['user:read', 'something:else'] True
':read' [':read'] True
':read' ['admin'] True
The @scoped decorator¶
Basics¶
In order to protect a route from being accessed by tokens without the appropriate scope(s), pass in one or more scopes:
@app.route("/protected/scoped/1")
@scoped('user')
async def protected_route1(request):
return json({"protected": True, "scoped": True})
In the above example, only an access token with a payload containing a scope for user
will be accepted (such as the payload below).
{
"user_id": 1,
"scopes: ["user"]
}
You can also define multiple scopes:
@scoped(['user', 'admin'])
In the above example with a ['user', 'admin']
scope, a payload MUST contain both user
and admin
.
But, what if we only want to require one of the scopes, and not both user
AND admin
? Easy:
@scoped(['user', 'admin'], False)
Now, having a scope of either user
OR admin
will be acceptable.
If you have initialized Sanic JWT on a Blueprint
, then you will need to pass the instance of that blueprint into the @scoped
decorator.
bp = Blueprint('Users')
Initialize(bp)
@bp.get('/users/<id>')
@scoped(['user', 'admin'], initialized_on=bp)
async def users(request, id):
...
Note
If you provide a False
or None
value to the @scoped
decorator, it will effectively remove all protection. This means all requests, whether authenticated or not, will be accepted.
Parameters¶
The @scoped()
decorator takes three parameters:
scopes
requires_all
- defaultTrue
require_all_actions
- defaultTrue
scopes
- Required¶
Either a single string
, or a list
of strings that are the defined scopes for the route. Or, a callable
or awaitable
that returns the same.
@scoped('user')
...
Or
@scoped(['user', 'admin'])
...
Or
def get_some_scopes(request, *args, **kwargs):
return ['user', 'admin']
@scoped(get_some_scopes)
...
Or
async def get_some_scopes(request, *args, **kwargs):
return await something_that_returns_scopes()
@scoped(get_some_scopes)
...
require_all
- Optional¶
A boolean
that determines whether all of the defined scopes, or just one must be satisfied. Defaults to True
.
@scoped(['user', 'admin'])
...
# A payload MUST have both 'user' and 'admin' scopes
@scoped(['user', 'admin'], require_all=False)
...
# A payload can have either 'user' or 'admin' scope
require_all_actions
- Optional¶
A boolean
that determines whether all of the actions on a defined scope, or just one must be satisfied. Defaults to True
.
@scoped(':read:write')
...
# A payload MUST have both the `:read` and `:write` actions in scope
@scoped(':read:write', require_all_actions=False)
...
# A payload can have either the `:read` or `:write` action in scope
Sample Code¶
from sanic import Sanic
from sanic.response import json
from sanic_jwt import exceptions
from sanic_jwt import initialize
from sanic_jwt.decorators import protected
from sanic_jwt.decorators import scoped
class User(object):
def __init__(self, id, username, password, scopes):
self.user_id = id
self.username = username
self.password = password
self.scopes = scopes
def __str__(self):
return "User(id='%s')" % self.id
users = [
User(1, 'user1', 'abcxyz', ['user']),
User(2, 'user2', 'abcxyz', ['user', 'admin']),
User(3, 'user3', 'abcxyz', ['user:read']),
User(4, 'user4', 'abcxyz', ['client1']),
]
username_table = {u.username: u for u in users}
userid_table = {u.user_id: u for u in users}
async def authenticate(request, *args, **kwargs):
username = request.json.get('username', None)
password = request.json.get('password', None)
if not username or not password:
raise exceptions.AuthenticationFailed("Missing username or password.")
user = username_table.get(username, None)
if user is None:
raise exceptions.AuthenticationFailed("User not found.")
if password != user.password:
raise exceptions.AuthenticationFailed("Password is incorrect.")
return user
async def my_scope_extender(user, *args, **kwargs):
return user.scopes
app = Sanic()
Initialize(
app,
authenticate=authenticate,
add_scopes_to_payload=my_scope_extender)
@app.route("/")
async def test(request):
return json({"hello": "world"})
@app.route("/protected")
@protected()
async def protected_route(request):
return json({"protected": True, "scoped": False})
@app.route("/protected/scoped/1")
@protected()
@scoped('user')
async def protected_route1(request):
return json({"protected": True, "scoped": True})
@app.route("/protected/scoped/2")
@protected()
@scoped('user:read')
async def protected_route2(request):
return json({"protected": True, "scoped": True})
@app.route("/protected/scoped/3")
@protected()
@scoped(['user', 'admin'])
async def protected_route3(request):
return json({"protected": True, "scoped": True})
@app.route("/protected/scoped/4")
@protected()
@scoped(['user', 'admin'], False)
async def protected_route4(request):
return json({"protected": True, "scoped": True})
@app.route("/protected/scoped/5")
@scoped('user')
async def protected_route5(request):
return json({"protected": True, "scoped": True})
@app.route("/protected/scoped/6/<id>")
@scoped(lambda *args, **kwargs: 'user')
async def protected_route6(request, id):
return json({"protected": True, "scoped": True})
def client_id_scope(request, *args, **kwargs):
return 'client' + kwargs.get('id')
@app.route("/protected/scoped/7/<id>")
@scoped(client_id_scope)
async def protected_route7(request, id):
return json({"protected": True, "scoped": True})
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8888)