Skip to content

Commit f571eb3

Browse files
committed
refactor(permissions): rewrote permissions to integrate admin types
1 parent 5ab91f4 commit f571eb3

9 files changed

Lines changed: 118 additions & 129 deletions

File tree

src/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import dependencies

src/auth/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from auth import crud

src/dependencies.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from enum import Enum
2+
from typing import Annotated
3+
4+
from fastapi import Depends, HTTPException, Request, status
5+
6+
import auth
7+
import database
8+
import officers
9+
from officers.constants import OfficerPositionEnum
10+
from permission.types import WEBSITE_ADMIN_POSITIONS
11+
12+
13+
# Permissions are granted if the Enum value >= the level needed
14+
class AdminTypeEnum(Enum):
15+
Election = 1
16+
Full = 2
17+
18+
19+
async def is_user_website_admin(computing_id: str, db_session: database.DBSession) -> bool:
20+
for position in await officers.crud.current_officer_positions(db_session, computing_id):
21+
if position in WEBSITE_ADMIN_POSITIONS:
22+
return True
23+
24+
return False
25+
26+
27+
# TODO: Add an election admin version that checks the election attempting to be modified as well
28+
async def is_user_election_officer(computing_id: str, db_session: database.DBSession) -> bool:
29+
"""
30+
An current election officer has access to all election, prior election officers have no access.
31+
"""
32+
officer_terms = await officers.crud.get_current_terms_by_position(db_session, OfficerPositionEnum.ELECTIONS_OFFICER)
33+
for officer in officer_terms:
34+
if computing_id == officer.computing_id:
35+
return True
36+
37+
return False
38+
39+
40+
async def get_user(request: Request, db_session: database.DBSession) -> tuple[str, str]:
41+
"""gets the user's computing_id, or raises an exception if the current request is not logged in"""
42+
session_id = request.cookies.get("session_id", None)
43+
if session_id is None:
44+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="no session id")
45+
46+
session_computing_id = await auth.crud.get_computing_id(db_session, session_id)
47+
if session_computing_id is None:
48+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="no computing id")
49+
50+
return session_id, session_computing_id
51+
52+
53+
# Allows path functions to use this without having to add a bunch of checks
54+
SessionUser = Annotated[tuple[str, str], Depends(get_user)]
55+
56+
57+
async def get_admin(
58+
db_session: database.DBSession, session_user: SessionUser, admin_type: AdminTypeEnum
59+
) -> tuple[str, str]:
60+
session_id, computing_id = session_user
61+
# Website admins have full permissions
62+
if is_user_website_admin(computing_id, db_session):
63+
return (session_id, computing_id)
64+
65+
# Election officers have lower permissions
66+
if admin_type == AdminTypeEnum.Election and is_user_election_officer(computing_id, db_session):
67+
return (session_id, computing_id)
68+
69+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="must be an admin")
70+
71+
72+
# Allows path functions to use this without having to add a bunch of checks
73+
SessionAdmin = Annotated[tuple[str, str], Depends(get_admin)]

src/elections/urls.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import elections.tables
99
import nominees.crud
1010
import registrations.crud
11+
from dependencies import SessionUser
1112
from elections.models import (
1213
ElectionParams,
1314
ElectionResponse,
@@ -18,7 +19,7 @@
1819
from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS, OfficerPositionEnum
1920
from permission.types import ElectionOfficer, WebsiteAdmin
2021
from utils.shared_models import DetailModel, SuccessResponse
21-
from utils.urls import get_current_user, slugify
22+
from utils.urls import slugify
2223

2324
router = APIRouter(
2425
prefix="/election",
@@ -27,10 +28,10 @@
2728

2829

2930
async def get_election_permissions(
30-
request: Request,
31+
session_user: SessionUser,
3132
db_session: database.DBSession,
3233
) -> tuple[bool, str | None, str | None]:
33-
session_id, computing_id = await get_current_user(request, db_session)
34+
session_id, computing_id = session_user
3435
if not session_id or not computing_id:
3536
return False, None, None
3637

@@ -90,14 +91,14 @@ def _raise_if_bad_election_data(
9091
"",
9192
description="Returns a list of all election & their status",
9293
response_model=list[ElectionResponse],
93-
responses={404: {"description": "No election found", "model": DetailModel}},
94+
responses={status.HTTP_404_NOT_FOUND: {"description": "No election found", "model": DetailModel}},
9495
operation_id="get_all_elections",
9596
)
9697
async def list_elections(
97-
request: Request,
98+
session_user: SessionUser,
9899
db_session: database.DBSession,
99100
):
100-
is_admin, _, _ = await get_election_permissions(request, db_session)
101+
is_admin, _, _ = await get_election_permissions(session_user, db_session)
101102
election_list = await elections.crud.get_all_elections(db_session)
102103
if election_list is None or len(election_list) == 0:
103104
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="no election found")

src/officers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import crud

src/officers/crud.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections.abc import Sequence
2-
from datetime import date, datetime
2+
from datetime import date
33

44
import sqlalchemy
55
from fastapi import HTTPException
@@ -11,7 +11,7 @@
1111
import utils
1212
from data import semesters
1313
from officers.constants import OfficerPosition
14-
from officers.models import OfficerInfoResponse, OfficerTermCreate
14+
from officers.models import OfficerInfoResponse
1515
from officers.tables import OfficerInfo, OfficerTerm
1616

1717
# NOTE: this module should not do any data validation; that should be done in the urls.py or higher layer
@@ -59,6 +59,29 @@ async def current_officers(
5959
return officer_list
6060

6161

62+
async def get_current_terms_by_position(db_session: database.DBSession, position: str) -> list[OfficerInfoResponse]:
63+
"""
64+
Get current officer that holds a position
65+
"""
66+
curr_time = date.today()
67+
query = (
68+
sqlalchemy.select(OfficerTerm)
69+
.join(OfficerInfo, OfficerTerm.computing_id)
70+
.where(
71+
(OfficerTerm.start_date <= curr_time) & (OfficerTerm.end_date >= curr_time) & OfficerTerm.position
72+
== position
73+
)
74+
.order_by(OfficerTerm.start_date.desc())
75+
)
76+
77+
result = (await db_session.execute(query)).all()
78+
officer_list = []
79+
for term in result:
80+
officer_list.append(OfficerTerm(*term))
81+
82+
return officer_list
83+
84+
6285
async def all_officers(db_session: AsyncSession, include_future_terms: bool) -> list[OfficerInfoResponse]:
6386
"""
6487
This could be a lot of data, so be careful

src/officers/models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from datetime import date
22

3-
from pydantic import BaseModel, ConfigDict, Field
3+
from pydantic import BaseModel, Field
44

5+
from constants import COMPUTING_ID_LEN
56
from officers.constants import OFFICER_LEGAL_NAME_MAX, OfficerPositionEnum
67

78
OFFICER_PRIVATE_INFO = {

src/permission/types.py

Lines changed: 8 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@
1010
from data.semesters import step_semesters
1111
from officers.constants import OfficerPositionEnum
1212

13+
WEBSITE_ADMIN_POSITIONS: list[OfficerPositionEnum] = [
14+
OfficerPositionEnum.PRESIDENT,
15+
OfficerPositionEnum.VICE_PRESIDENT,
16+
OfficerPositionEnum.DIRECTOR_OF_ARCHIVES,
17+
OfficerPositionEnum.SYSTEM_ADMINISTRATOR,
18+
OfficerPositionEnum.WEBMASTER,
19+
]
20+
1321

1422
class OfficerPrivateInfo:
1523
@staticmethod
@@ -29,47 +37,3 @@ async def has_permission(db_session: database.DBSession, computing_id: str) -> b
2937
return True
3038

3139
return False
32-
33-
34-
class ElectionOfficer:
35-
@staticmethod
36-
async def has_permission(db_session: database.DBSession, computing_id: str) -> bool:
37-
"""
38-
An current election officer has access to all election, prior election officers have no access.
39-
"""
40-
officer_terms = await officers.crud.current_officers(db_session, True)
41-
current_election_officer = officer_terms.get(officers.constants.OfficerPositionEnum.ELECTIONS_OFFICER)
42-
if current_election_officer is not None:
43-
for election_officer in current_election_officer[1]:
44-
if election_officer.private_data.computing_id == computing_id and election_officer.is_current_officer:
45-
return True
46-
47-
return False
48-
49-
50-
class WebsiteAdmin:
51-
WEBSITE_ADMIN_POSITIONS: ClassVar[list[OfficerPositionEnum]] = [
52-
OfficerPositionEnum.PRESIDENT,
53-
OfficerPositionEnum.VICE_PRESIDENT,
54-
OfficerPositionEnum.DIRECTOR_OF_ARCHIVES,
55-
OfficerPositionEnum.SYSTEM_ADMINISTRATOR,
56-
OfficerPositionEnum.WEBMASTER,
57-
]
58-
59-
@staticmethod
60-
async def has_permission(db_session: database.DBSession, computing_id: str) -> bool:
61-
"""
62-
A website admin has to be an active officer who has one of the above positions
63-
"""
64-
for position in await officers.crud.current_officer_positions(db_session, computing_id):
65-
if position in WebsiteAdmin.WEBSITE_ADMIN_POSITIONS:
66-
return True
67-
return False
68-
69-
@staticmethod
70-
async def has_permission_or_raise(
71-
db_session: database.DBSession, computing_id: str, errmsg: str = "must have website admin permissions"
72-
) -> bool:
73-
if not await WebsiteAdmin.has_permission(db_session, computing_id):
74-
raise HTTPException(status_code=403, detail=errmsg)
75-
return True

src/utils/urls.py

Lines changed: 0 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,7 @@
11
import re
2-
from enum import Enum
3-
4-
from fastapi import HTTPException, Request, status
5-
6-
import auth
7-
import auth.crud
8-
import database
9-
from permission.types import ElectionOfficer, WebsiteAdmin
10-
11-
12-
class AdminTypeEnum(Enum):
13-
Full = 1
14-
Election = 2
152

163

174
# TODO: move other utils into this module
185
def slugify(text: str) -> str:
196
"""Creates a unique slug based on text passed in. Assumes non-unicode text."""
207
return re.sub(r"[\W_]+", "-", text.strip().replace("/", "").replace("&", ""))
21-
22-
23-
async def logged_in_or_raise(request: Request, db_session: database.DBSession) -> tuple[str, str]:
24-
"""gets the user's computing_id, or raises an exception if the current request is not logged in"""
25-
session_id = request.cookies.get("session_id", None)
26-
if session_id is None:
27-
raise HTTPException(status_code=401, detail="no session id")
28-
29-
session_computing_id = await auth.crud.get_computing_id(db_session, session_id)
30-
if session_computing_id is None:
31-
raise HTTPException(status_code=401, detail="no computing id")
32-
33-
return session_id, session_computing_id
34-
35-
36-
async def get_current_user(request: Request, db_session: database.DBSession) -> tuple[str, str] | tuple[None, None]:
37-
"""
38-
Gets information about the currently logged in user.
39-
40-
Args:
41-
request: The request being checked
42-
db_session: The current database session
43-
44-
Returns:
45-
A tuple of either (None, None) if there is no logged in user or a tuple (session ID, computing ID)
46-
"""
47-
session_id = request.cookies.get("session_id", None)
48-
if session_id is None:
49-
return None, None
50-
51-
session_computing_id = await auth.crud.get_computing_id(db_session, session_id)
52-
if session_computing_id is None:
53-
return None, None
54-
55-
return session_id, session_computing_id
56-
57-
58-
# TODO: Add an election admin version that checks the election attempting to be modified as well
59-
async def admin_or_raise(
60-
request: Request, db_session: database.DBSession, admintype: AdminTypeEnum = AdminTypeEnum.Full
61-
) -> tuple[str, str]:
62-
session_id, computing_id = await get_current_user(request, db_session)
63-
if not session_id or not computing_id:
64-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="must be logged in")
65-
66-
# where valid means election officer or website admin
67-
if (await WebsiteAdmin.has_permission(db_session, computing_id)) or (
68-
admintype is AdminTypeEnum.Election and await ElectionOfficer.has_permission(db_session, computing_id)
69-
):
70-
return session_id, computing_id
71-
72-
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="must be an admin")
73-
74-
75-
async def is_website_admin(request: Request, db_session: database.DBSession) -> tuple[bool, str | None, str | None]:
76-
session_id, computing_id = await get_current_user(request, db_session)
77-
if session_id is None or computing_id is None:
78-
return False, session_id, computing_id
79-
80-
if await WebsiteAdmin.has_permission(db_session, computing_id):
81-
return True, session_id, computing_id
82-
83-
return False, session_id, computing_id

0 commit comments

Comments
 (0)