Skip to content

Commit 4766a7a

Browse files
authored
Implement basic user roles functionality (canada-ca#94)
* Add 'boilerplate' for adding user_claims during sign in process * Adds a test resolver to be able to view the user_claims of the current JWT * Add Roles column to User model. Currently a String, may switch to different type. * User role is pulled from DB column. Testable through the graphql using the dummy resolver that is protected. * Add update_role() function * Refactor resolvers for user_schema. Add update_role function for admin use. Todo: Test to ensure that mutations/queries are protected * Update docstrings * Ran DB-upgrade * Update sign_in_user.py
1 parent dbd4ec8 commit 4766a7a

10 files changed

Lines changed: 123 additions & 42 deletions

File tree

api/functions/error_messages.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ def error_password_not_updated():
3030
return str("Unable to update password, please try again")
3131

3232

33+
def error_role_not_updated():
34+
return str("Unable to update user's role, please try again")
35+
36+
37+
def error_not_an_admin():
38+
return str("You are not allowed to perform admin actions, please try again")
39+
40+
3341
def error_user_not_updated():
3442
return str("User not updated, please try again")
3543

api/functions/sign_in_user.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ def sign_in_user(email, password):
2424
password_match = bcrypt.check_password_hash(user.user_password, password)
2525

2626
if email_match and password_match:
27+
user_claims = {"roles": user.user_role}
2728
temp_dict = {
28-
'auth_token': create_access_token(user.id),
29+
'auth_token': create_access_token(user.id, user_claims=user_claims),
2930
'user': user
3031
}
3132

api/functions/update_user_role.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from functions.error_messages import *
2+
from db import db
3+
from models import Users as User
4+
from graphql import GraphQLError
5+
6+
from flask_graphql_auth import *
7+
8+
9+
def update_user_role(email, new_role):
10+
"""
11+
Updates the user role associate with the user given by email address
12+
13+
:param email: The email address associated with the user who's role will be updated.
14+
:param new_role: The new role that will be given to the user.
15+
16+
:returns user: The newly updated user object retrieved from the DB (after the update is committed).
17+
"""
18+
user = User.query.filter(User.user_email == email).first()
19+
20+
if user is None:
21+
# State that no such user exists using that email address
22+
raise GraphQLError(error_user_does_not_exist())
23+
24+
role = get_jwt_claims()['roles'] # Pulls the 'role' out of the JWT user claims associated with the token.
25+
26+
if role == "admin": # If an admin, update the user.
27+
user = User.query.filter(User.user_email == email)\
28+
.update({'user_role': new_role})
29+
db.session.commit()
30+
else:
31+
raise GraphQLError(error_not_an_admin())
32+
33+
if not user:
34+
raise GraphQLError(error_role_not_updated())
35+
else:
36+
user = User.query.filter(User.user_email == email).first()
37+
return user

api/manage.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
DB_NAME,
99
DB_HOST,
1010
DB_PASS,
11-
DB_USER
11+
DB_USER,
12+
DB_PORT,
1213
)
1314

1415
app = Flask(__name__)
15-
app.config['SQLALCHEMY_DATABASE_URI'] = f'postgresql+psycopg2://{DB_USER}:{DB_PASS}@{DB_HOST}/{DB_NAME}'
16+
app.config['SQLALCHEMY_DATABASE_URI'] = f'postgresql+psycopg2://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}'
1617
app.config['SQLALCHEMY_COMMIT_ON_TEARDOWN'] = True
1718
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True
1819

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
"""empty message
22
3-
Revision ID: 5cd6b0bb4549
3+
Revision ID: 210111cbd5ae
44
Revises:
5-
Create Date: 2020-02-07 13:12:26.620069
5+
Create Date: 2020-02-14 08:15:16.902318
66
77
"""
88
from alembic import op
99
import sqlalchemy as sa
1010
from sqlalchemy.dialects import postgresql
1111

1212
# revision identifiers, used by Alembic.
13-
revision = '5cd6b0bb4549'
13+
revision = '210111cbd5ae'
1414
down_revision = None
1515
branch_labels = None
1616
depends_on = None
@@ -58,6 +58,8 @@ def upgrade():
5858
sa.Column('user_password', sa.String(), nullable=True),
5959
sa.Column('preferred_lang', sa.String(), nullable=True),
6060
sa.Column('failed_login_attempts', sa.Integer(), nullable=True),
61+
sa.Column('tfa_validated', sa.Boolean(), nullable=True),
62+
sa.Column('user_role', sa.String(), nullable=True),
6163
sa.PrimaryKeyConstraint('id')
6264
)
6365
op.create_table('admin_affiliations',

api/migrations/versions/6e9ff94fcf09_.py

Lines changed: 0 additions & 28 deletions
This file was deleted.

api/models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ class Users(db.Model):
9090
user_password = Column(String)
9191
preferred_lang = Column(String)
9292
failed_login_attempts = Column(Integer, default=0)
93-
two_factor_auth = Column(Boolean, default=False)
93+
tfa_validated = Column(Boolean, default=False)
94+
user_role = Column(String, default="user")
9495
user_affiliation = relationship("User_affiliations", back_populates="user", cascade="all, delete")
9596

9697

api/queries.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import os
22
from graphene_sqlalchemy import SQLAlchemyConnectionField
3+
from graphene import String
4+
from flask_graphql_auth import *
5+
import pyotp
6+
37
import graphene
48
from sqlalchemy.orm import joinedload
59
from graphene import relay, String
@@ -26,6 +30,13 @@
2630
resolve_get_group_by_id,
2731
resolve_get_group_by_group,
2832
resolve_get_group_by_sector
33+
34+
)
35+
36+
37+
from resolvers.users import (
38+
resolve_test_user_claims,
39+
resolve_generate_otp_url,
2940
)
3041

3142
from resolvers.organizations import (
@@ -97,12 +108,17 @@ class Query(graphene.ObjectType):
97108
description="Allows the selection of organizations from a given group"
98109
)
99110

100-
generate_otp_url = String(email=String(required=True))
111+
generate_otp_url = graphene.String(
112+
email=graphene.Argument(EmailAddress, required=True),
113+
resolver=resolve_generate_otp_url,
114+
description="An api endpoint used to generate a OTP url used for two factor authentication."
115+
)
101116

102-
@staticmethod
103-
def resolve_generate_otp_url(self, info, email):
104-
totp = pyotp.totp.TOTP(os.getenv('BASE32_SECRET')) # This needs to be a 16 char base32 secret key
105-
return totp.provisioning_uri(email, issuer_name="Tracker")
117+
test_user_claims = graphene.String(
118+
token=graphene.Argument(graphene.String, required=True),
119+
resolver=resolve_test_user_claims,
120+
description="An api endpoint to view a current user's claims -- Requires an active JWT."
121+
)
106122

107123

108124
class Mutation(graphene.ObjectType):
@@ -111,7 +127,7 @@ class Mutation(graphene.ObjectType):
111127
sign_in = SignInUser.Field()
112128
update_password = UpdateUserPassword.Field()
113129
authenticate_two_factor = ValidateTwoFactor.Field()
130+
update_user_role = UpdateUserRole.Field()
114131

115132

116-
schema = graphene.Schema(query=Query, mutation=Mutation)
117-
133+
schema = graphene.Schema(query=Query, mutation=Mutation)

api/resolvers/users.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from flask_graphql_auth import *
2+
import pyotp
3+
import os
4+
5+
6+
@query_jwt_required
7+
def resolve_test_user_claims(self, info):
8+
"""
9+
This resolver returns the user_claims array -- A utility for testing
10+
It requires that a JWT token be active, and that the user have an admin role
11+
"""
12+
role = get_jwt_claims()['roles']
13+
14+
if role == "admin":
15+
return str(get_jwt_claims())
16+
else:
17+
return str("Not an admin, please log in")
18+
19+
20+
def resolve_generate_otp_url(self, info, email):
21+
"""
22+
This resolver adds an api endpoint that returns a url for OTP validation
23+
:param email: The email address that will be associated with the URL generated
24+
"""
25+
totp = pyotp.totp.TOTP(os.getenv('BASE32_SECRET')) # This needs to be a 16 char base32 secret key
26+
return totp.provisioning_uri(email, issuer_name="Tracker")

api/schemas/user.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import graphene
22
from graphene import relay
33
from graphene_sqlalchemy import SQLAlchemyObjectType
4+
from flask_graphql_auth import *
45

56
from functions.create_user import create_user
67
from functions.sign_in_user import sign_in_user
78
from functions.update_user_password import update_password
89
from functions.validate_two_factor import validate_two_factor
10+
from functions.update_user_role import update_user_role
911

1012
from models import Users as User
1113
from scalars.email_address import *
@@ -69,6 +71,7 @@ def mutate(self, info, password, confirm_password, email):
6971
user = update_password(email=email, password=password, confirm_password=confirm_password)
7072
return UpdateUserPassword(user=user)
7173

74+
7275
class ValidateTwoFactor(graphene.Mutation):
7376
class Arguments:
7477
email = EmailAddress(required=True)
@@ -80,3 +83,17 @@ class Arguments:
8083
def mutate(self, info, email, otp_code):
8184
user_to_rtn = validate_two_factor(email=email, otp_code=otp_code)
8285
return ValidateTwoFactor(user=user_to_rtn)
86+
87+
88+
class UpdateUserRole(graphene.Mutation):
89+
class Arguments:
90+
token = graphene.String(required=True)
91+
email = EmailAddress(required=True)
92+
role = graphene.String(required=True)
93+
94+
user = graphene.Field(lambda: UserObject)
95+
96+
@mutation_jwt_required
97+
def mutate(self, info, email, role):
98+
user = update_user_role(email=email, new_role=role)
99+
return UpdateUserRole(user=user)

0 commit comments

Comments
 (0)