Skip to content
This repository has been archived by the owner on May 24, 2022. It is now read-only.

Use authentication in routes #157

Merged
merged 6 commits into from
Mar 24, 2022
Merged
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
12 changes: 7 additions & 5 deletions backend/src/app/routers/editions/editions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

# Don't add the "Editions" tag here, because then it gets applied
# to all child routes as well
from ...utils.dependencies import require_admin, require_auth, require_coach

editions_router = APIRouter(prefix="/editions")

# Register all child routers
Expand All @@ -31,20 +33,20 @@
editions_router.include_router(router, prefix="/{edition_id}")


@editions_router.get("/",response_model=EditionList, tags=[Tags.EDITIONS])
@editions_router.get("/", response_model=EditionList, tags=[Tags.EDITIONS], dependencies=[Depends(require_auth)])
async def get_editions(db: Session = Depends(get_session)):
"""Get a list of all editions.

Args:
db (Session, optional): connection with the database. Defaults to Depends(get_session).

Returns:
EditionList: an object with a list of all the editions.
"""
# TODO only return editions the user can see
return logic_editions.get_editions(db)


@editions_router.get("/{edition_id}", response_model=Edition, tags=[Tags.EDITIONS])
@editions_router.get("/{edition_id}", response_model=Edition, tags=[Tags.EDITIONS], dependencies=[Depends(require_coach)])
async def get_edition_by_id(edition_id: int, db: Session = Depends(get_session)):
"""Get a specific edition.

Expand All @@ -58,7 +60,7 @@ async def get_edition_by_id(edition_id: int, db: Session = Depends(get_session))
return logic_editions.get_edition_by_id(db, edition_id)


@editions_router.post("/", status_code=status.HTTP_201_CREATED, response_model=Edition, tags=[Tags.EDITIONS])
@editions_router.post("/", status_code=status.HTTP_201_CREATED, response_model=Edition, tags=[Tags.EDITIONS], dependencies=[Depends(require_admin)])
async def post_edition(edition: EditionBase, db: Session = Depends(get_session)):
""" Create a new edition.

Expand All @@ -71,7 +73,7 @@ async def post_edition(edition: EditionBase, db: Session = Depends(get_session))
return logic_editions.create_edition(db, edition)


@editions_router.delete("/{edition_id}", status_code=status.HTTP_204_NO_CONTENT, tags=[Tags.EDITIONS])
@editions_router.delete("/{edition_id}", status_code=status.HTTP_204_NO_CONTENT, tags=[Tags.EDITIONS], dependencies=[Depends(require_admin)])
async def delete_edition(edition_id: int, db: Session = Depends(get_session)):
"""Delete an existing edition.

Expand Down
13 changes: 8 additions & 5 deletions backend/src/app/routers/editions/invites/invites.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,33 @@
from src.app.logic.invites import create_mailto_link, delete_invite_link, get_pending_invites_list
from src.app.routers.tags import Tags
from src.app.schemas.invites import InvitesListResponse, EmailAddress, MailtoLink, InviteLink as InviteLinkModel
from src.app.utils.dependencies import get_edition, get_invite_link
from src.app.utils.dependencies import get_edition, get_invite_link, require_admin
from src.database.database import get_session
from src.database.models import Edition, InviteLink as InviteLinkDB

invites_router = APIRouter(prefix="/invites", tags=[Tags.INVITES])


@invites_router.get("/", response_model=InvitesListResponse)
@invites_router.get("/", response_model=InvitesListResponse, dependencies=[Depends(require_admin)])
async def get_invites(db: Session = Depends(get_session), edition: Edition = Depends(get_edition)):
"""
Get a list of all pending invitation links.
"""
return get_pending_invites_list(db, edition)


@invites_router.post("/", status_code=status.HTTP_201_CREATED, response_model=MailtoLink)
async def create_invite(email: EmailAddress, db: Session = Depends(get_session), edition: Edition = Depends(get_edition)):
@invites_router.post("/", status_code=status.HTTP_201_CREATED, response_model=MailtoLink,
dependencies=[Depends(require_admin)])
async def create_invite(email: EmailAddress, db: Session = Depends(get_session),
edition: Edition = Depends(get_edition)):
"""
Create a new invitation link for the current edition.
"""
return create_mailto_link(db, edition, email)


@invites_router.delete("/{invite_uuid}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
@invites_router.delete("/{invite_uuid}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response,
dependencies=[Depends(require_admin)])
async def delete_invite(invite_link: InviteLinkDB = Depends(get_invite_link), db: Session = Depends(get_session)):
"""
Delete an existing invitation link manually so that it can't be used anymore.
Expand Down
8 changes: 4 additions & 4 deletions backend/src/app/routers/editions/webhooks/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from src.database.crud.webhooks import get_webhook, create_webhook
from src.app.schemas.webhooks import WebhookEvent, WebhookUrlResponse
from src.database.models import Edition
from src.app.utils.dependencies import get_edition
from src.app.utils.dependencies import get_edition, require_admin
from src.app.routers.tags import Tags
from src.app.logic.webhooks import process_webhook
from starlette import status
Expand All @@ -18,10 +18,10 @@ def valid_uuid(uuid: str, database: Session = Depends(get_session)):
get_webhook(database, uuid)


# TODO: check admin permission
@webhooks_router.post("/", response_model=WebhookUrlResponse, status_code=status.HTTP_201_CREATED)
@webhooks_router.post("/", response_model=WebhookUrlResponse, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)])
def new(edition: Edition = Depends(get_edition), database: Session = Depends(get_session)):
"""Create e new webhook for an edition"""
"""Create a new webhook for an edition"""
return create_webhook(database, edition)


Expand Down
14 changes: 6 additions & 8 deletions backend/src/app/routers/skills/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,16 @@
from sqlalchemy.orm import Session
from starlette import status

from src.database.database import get_session
from src.app.schemas.skills import SkillBase, Skill, SkillList
from src.app.logic import skills as logic_skills

from src.app.schemas.skills import SkillBase
from src.app.routers.tags import Tags

from src.app.schemas.skills import SkillBase, Skill, SkillList
from src.app.utils.dependencies import require_auth
from src.database.database import get_session

skills_router = APIRouter(prefix="/skills", tags=[Tags.SKILLS])


@skills_router.get("/", response_model=SkillList, tags=[Tags.SKILLS])
@skills_router.get("/", response_model=SkillList, tags=[Tags.SKILLS], dependencies=[Depends(require_auth)])
async def get_skills(db: Session = Depends(get_session)):
"""Get a list of all the base skills that can be added to a student or project.

Expand All @@ -26,7 +24,7 @@ async def get_skills(db: Session = Depends(get_session)):
return logic_skills.get_skills(db)


@skills_router.post("/",status_code=status.HTTP_201_CREATED, response_model=Skill, tags=[Tags.SKILLS])
@skills_router.post("/",status_code=status.HTTP_201_CREATED, response_model=Skill, tags=[Tags.SKILLS], dependencies=[Depends(require_auth)])
async def create_skill(skill: SkillBase, db: Session = Depends(get_session)):
"""Add a new skill into the database.

Expand All @@ -40,7 +38,7 @@ async def create_skill(skill: SkillBase, db: Session = Depends(get_session)):
return logic_skills.create_skill(db, skill)


@skills_router.delete("/{skill_id}", status_code=status.HTTP_204_NO_CONTENT, tags=[Tags.SKILLS])
@skills_router.delete("/{skill_id}", status_code=status.HTTP_204_NO_CONTENT, tags=[Tags.SKILLS], dependencies=[Depends(require_auth)])
async def delete_skill(skill_id: int, db: Session = Depends(get_session)):
"""Delete an existing skill.

Expand Down
15 changes: 8 additions & 7 deletions backend/src/app/routers/users/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
from src.app.routers.tags import Tags
import src.app.logic.users as logic
from src.app.schemas.users import UsersListResponse, AdminPatch, UserRequestsResponse
from src.app.utils.dependencies import require_admin
from src.database.database import get_session

users_router = APIRouter(prefix="/users", tags=[Tags.USERS])


@users_router.get("/", response_model=UsersListResponse)
@users_router.get("/", response_model=UsersListResponse, dependencies=[Depends(require_admin)])
async def get_users(admin: bool = Query(False), edition: int | None = Query(None), db: Session = Depends(get_session)):
"""
Get users
Expand All @@ -18,7 +19,7 @@ async def get_users(admin: bool = Query(False), edition: int | None = Query(None
return logic.get_users_list(db, admin, edition)


@users_router.patch("/{user_id}", status_code=204)
@users_router.patch("/{user_id}", status_code=204, dependencies=[Depends(require_admin)])
async def patch_admin_status(user_id: int, admin: AdminPatch, db: Session = Depends(get_session)):
"""
Set admin-status of user
Expand All @@ -27,7 +28,7 @@ async def patch_admin_status(user_id: int, admin: AdminPatch, db: Session = Depe
logic.edit_admin_status(db, user_id, admin)


@users_router.post("/{user_id}/editions/{edition_id}", status_code=204)
@users_router.post("/{user_id}/editions/{edition_id}", status_code=204, dependencies=[Depends(require_admin)])
async def add_to_edition(user_id: int, edition_id: int, db: Session = Depends(get_session)):
"""
Add user as coach of the given edition
Expand All @@ -36,7 +37,7 @@ async def add_to_edition(user_id: int, edition_id: int, db: Session = Depends(ge
logic.add_coach(db, user_id, edition_id)


@users_router.delete("/{user_id}/editions/{edition_id}", status_code=204)
@users_router.delete("/{user_id}/editions/{edition_id}", status_code=204, dependencies=[Depends(require_admin)])
async def remove_from_edition(user_id: int, edition_id: int, db: Session = Depends(get_session)):
"""
Remove user as coach of the given edition
Expand All @@ -45,7 +46,7 @@ async def remove_from_edition(user_id: int, edition_id: int, db: Session = Depen
logic.remove_coach(db, user_id, edition_id)


@users_router.get("/requests", response_model=UserRequestsResponse)
@users_router.get("/requests", response_model=UserRequestsResponse, dependencies=[Depends(require_admin)])
async def get_requests(edition: int | None = Query(None), db: Session = Depends(get_session)):
"""
Get pending userrequests
Expand All @@ -54,7 +55,7 @@ async def get_requests(edition: int | None = Query(None), db: Session = Depends(
return logic.get_request_list(db, edition)


@users_router.post("/requests/{request_id}/accept", status_code=204)
@users_router.post("/requests/{request_id}/accept", status_code=204, dependencies=[Depends(require_admin)])
async def accept_request(request_id: int, db: Session = Depends(get_session)):
"""
Accept a coach request
Expand All @@ -63,7 +64,7 @@ async def accept_request(request_id: int, db: Session = Depends(get_session)):
logic.accept_request(db, request_id)


@users_router.post("/requests/{request_id}/reject", status_code=204)
@users_router.post("/requests/{request_id}/reject", status_code=204, dependencies=[Depends(require_admin)])
async def reject_request(request_id: int, db: Session = Depends(get_session)):
"""
Reject a coach request
Expand Down
39 changes: 32 additions & 7 deletions backend/src/app/utils/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ def get_edition(edition_id: int, database: Session = Depends(get_session)) -> Ed
async def get_current_active_user(db: Session = Depends(get_session), token: str = Depends(oauth2_scheme)) -> User:
"""Check which user is making a request by decoding its token
This function is used as a dependency for other functions
TODO check if user has any pending coach requests
requires coach request logic to be done
"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
Expand All @@ -47,11 +45,23 @@ async def get_current_active_user(db: Session = Depends(get_session), token: str
raise InvalidCredentialsException() from jwt_err


# Alias that is easier to read in the dependency list when
# the return value isn't required
# Require the user to be authorized, coach or admin doesn't matter
require_authorization = get_current_active_user
require_auth = get_current_active_user
async def require_auth(user: User = Depends(get_current_active_user)) -> User:
"""Dependency to check if a user is at least a coach
This dependency should be used to check for resources that aren't linked to
editions

The function checks if the user is either an admin, or a coach with at least
one UserRole (meaning they have been accepted for at least one edition)
"""
# Admins can see everything
if user.admin:
return user

# Coach is not in any editions (yet)
if len(user.editions) == 0:
raise MissingPermissionsException()

return user


async def require_admin(user: User = Depends(get_current_active_user)) -> User:
Expand All @@ -62,6 +72,21 @@ async def require_admin(user: User = Depends(get_current_active_user)) -> User:
return user


async def require_coach(edition: Edition = Depends(get_edition), user: User = Depends(get_current_active_user)) -> User:
"""Dependency to check if a user can see a given resource
This comes down to checking if a coach is linked to an edition or not
"""
# Admins can see everything in any edition
if user.admin:
return user

# Coach is not part of this edition
if edition not in user.editions:
raise MissingPermissionsException()

return user


def get_invite_link(invite_uuid: str, db: Session = Depends(get_session)) -> InviteLink:
"""Get an invite link from the database, given the id in the path"""
return get_invite_link_by_uuid(db, invite_uuid)
17 changes: 17 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from src.database.database import get_session
from src.database.engine import engine

from tests.utils.authorization import AuthClient


@pytest.fixture(scope="session")
def tables():
Expand Down Expand Up @@ -55,3 +57,18 @@ def override_get_session() -> Generator[Session, None, None]:
# Replace get_session with a call to this method instead
app.dependency_overrides[get_session] = override_get_session
return TestClient(app)


@pytest.fixture
def auth_client(database_session: Session) -> AuthClient:
"""Fixture to get a TestClient that handles authentication"""

def override_get_session() -> Generator[Session, None, None]:
"""Inner function to override the Session used in the app
A session provided by a fixture will be used instead
"""
yield database_session

# Replace get_session with a call to this method instead
app.dependency_overrides[get_session] = override_get_session
return AuthClient(database_session, app)
Loading