Skip to content

Commit 6f30aa3

Browse files
authored
Canvas Sync: Add SIS viewing permissions check (#610)
Closes #605 by introducing a permissions check right before users select a class to connect with Canvas. This check is introduced in `verify_access` under the `canvas` module, and is run twice: once when the user selects the class, and once when they save the class. If `missing_sso_ids` is true when syncing, the user will need to reconnect their account.
1 parent ff14605 commit 6f30aa3

6 files changed

Lines changed: 170 additions & 16 deletions

File tree

pingpong/canvas.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -466,8 +466,15 @@ async def _roster_access_check(self, course_id: str) -> bool:
466466
request_url = f"/api/v1/courses/{course_id}/users"
467467

468468
async for result in self._request_all_pages(request_url):
469-
return bool(result)
470-
469+
if not result:
470+
return False
471+
for user in result:
472+
if self.config.sso_target and not user.get(self.config.sso_target):
473+
raise CanvasException(
474+
code=403,
475+
detail="You do not have permission to access SIS information for this class. Please ask another privileged user to set up Canvas Sync. If you're still facing issues, contact your Canvas administrator.",
476+
)
477+
return True
471478
return False
472479

473480
async def verify_access(self, course_id: str) -> None:
@@ -562,12 +569,12 @@ def _process_users(
562569
if enrollment["type"] in ["TeacherEnrollment", "TaEnrollment"]:
563570
is_teacher = True
564571
break
565-
if not user.get(self.config.sso_target):
572+
if self.config.sso_target and not user.get(self.config.sso_target):
566573
logging.warning(
567-
f"User {user['email']} does not have an SSO ID in the Canvas response. Skipping."
574+
f"User {user['email']} does not have an SSO ID in the Canvas response. Marking class as having a sync error."
568575
)
569576
self.missing_sso_ids = True
570-
continue
577+
break
571578
yield CreateUserClassRole(
572579
email=user["email"],
573580
sso_id=user.get(self.config.sso_target),
@@ -625,8 +632,12 @@ async def sync_roster(self) -> None:
625632
lms_tenant=self.config.tenant,
626633
lms_type=self.config.type,
627634
sso_tenant=self.config.sso_tenant,
628-
missing_some_sso_ids=self.missing_sso_ids,
629635
)
636+
if self.missing_sso_ids:
637+
raise CanvasException(
638+
code=403,
639+
detail="Some users in the Canvas class do not have SIS information. Please ask another privileged user to set up Canvas Sync. If you're still facing issues, contact your Canvas administrator.",
640+
)
630641
await self._update_user_roles()
631642
await Class.update_last_synced(self.db, self.class_id)
632643

pingpong/schemas.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,6 @@ class CreateUserClassRoles(BaseModel):
536536
lms_tenant: str | None = None
537537
lms_type: LMSType | None = None
538538
sso_tenant: str | None = None
539-
missing_some_sso_ids: bool = False
540539

541540

542541
class LMSClass(BaseModel):

pingpong/server.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -951,6 +951,60 @@ async def get_canvas_classes(class_id: str, tenant: str, request: Request):
951951
) from e
952952

953953

954+
@v1.post(
955+
"/class/{class_id}/canvas/{tenant}/classes/{canvas_class_id}/verify",
956+
dependencies=[Depends(Authz("can_edit_info", "class:{class_id}"))],
957+
response_model=schemas.GenericStatus,
958+
)
959+
async def verify_canvas_class_permissions(
960+
class_id: str, tenant: str, canvas_class_id: str, request: Request
961+
):
962+
canvas_settings = get_canvas_config(tenant)
963+
async with LightweightCanvasClient(
964+
canvas_settings,
965+
int(class_id),
966+
request,
967+
) as client:
968+
try:
969+
await client.verify_access(canvas_class_id)
970+
return {"status": "ok"}
971+
except CanvasException as e:
972+
logger.exception(
973+
"verify_canvas_class_permissions: CanvasException occurred"
974+
)
975+
raise HTTPException(
976+
status_code=e.code or 500,
977+
detail=e.detail
978+
or "We faced an error while verifying your access to this Canvas class.",
979+
)
980+
except CanvasWarning as e:
981+
logger.warning(
982+
"verify_canvas_class_permissions: CanvasWarning occurred: %s", e.detail
983+
)
984+
raise HTTPException(
985+
status_code=e.code or 500,
986+
detail=e.detail
987+
or "We faced an error while verifying your access to this Canvas class.",
988+
) from e
989+
except ClientResponseError as e:
990+
# If we get a 401 error, mark the class as having a sync error.
991+
# Otherwise, just display an error message.
992+
if e.code == 401:
993+
await models.Class.mark_lms_sync_error(request.state.db, int(class_id))
994+
logger.exception(
995+
"verify_canvas_class_permissions: ClientResponseError occurred"
996+
)
997+
raise HTTPException(
998+
status_code=e.code, detail="Canvas returned an error: " + e.message
999+
)
1000+
except Exception:
1001+
logger.exception("verify_canvas_class_permissions: Exception occurred")
1002+
raise HTTPException(
1003+
status_code=500,
1004+
detail="We faced an internal error while verifying your access to this Canvas class.",
1005+
)
1006+
1007+
9541008
@v1.post(
9551009
"/class/{class_id}/canvas/{tenant}/classes/{canvas_class_id}",
9561010
dependencies=[Depends(Authz("can_edit_info", "class:{class_id}"))],
@@ -1025,6 +1079,8 @@ async def sync_canvas_class(
10251079
status_code=e.code, detail="Canvas returned an error: " + e.message
10261080
)
10271081
except (CanvasException, AddUserException) as e:
1082+
if e.code == 403:
1083+
await models.Class.mark_lms_sync_error(request.state.db, int(class_id))
10281084
logger.exception(
10291085
"sync_canvas_class: CanvasException or AddUserException occurred"
10301086
)

pingpong/users.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,8 +403,7 @@ async def add_new_users(self) -> schemas.CreateUserResults:
403403
self.send_invites()
404404

405405
if self.new_ucr.lms_tenant:
406-
if not self.new_ucr.missing_some_sso_ids:
407-
await self._remove_deleted_users()
406+
await self._remove_deleted_users()
408407
await self._merge_accounts()
409408

410409
await self.client.write_safe(grant=grants, revoke=self.revokes)

web/pingpong/src/lib/api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2043,6 +2043,16 @@ export const saveCanvasClass = async (
20432043
return await POST<never, GenericStatus>(f, url);
20442044
};
20452045

2046+
export const verifyCanvasClass = async (
2047+
f: Fetcher,
2048+
classId: number,
2049+
tenant: string,
2050+
canvasClassId: string
2051+
) => {
2052+
const url = `class/${classId}/canvas/${tenant}/classes/${canvasClassId}/verify`;
2053+
return await POST<never, GenericStatus>(f, url);
2054+
};
2055+
20462056
export const syncCanvasClass = async (f: Fetcher, classId: number, tenant: string) => {
20472057
const url = `class/${classId}/canvas/${tenant}/sync`;
20482058
return await POST<never, GenericStatus>(f, url);

web/pingpong/src/routes/group/[classId]/manage/+page.svelte

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@
5151
UserRemoveSolid,
5252
FileLinesOutline,
5353
ExclamationCircleOutline,
54-
LockSolid
54+
LockSolid,
55+
CheckCircleOutline
5556
} from 'flowbite-svelte-icons';
5657
import { sadToast, happyToast } from '$lib/toast';
5758
import { humanSize } from '$lib/size';
@@ -429,9 +430,13 @@
429430
}, {});
430431
$: selectedClassName = classNameDict[selectedClass] || 'Select a class...';
431432
432-
const updateSelectedClass = (classValue: string) => {
433+
const updateSelectedClass = async (classValue: string) => {
434+
canvasClassVerified = false;
435+
canvasClassBeingVerified = true;
436+
canvasClassVerificationError = '';
433437
classSelectDropdownOpen = false;
434438
selectedClass = classValue;
439+
await verifyCanvasClass();
435440
};
436441
437442
const saveSelectedClass = async () => {
@@ -448,6 +453,24 @@
448453
}
449454
};
450455
456+
let canvasClassVerified = false;
457+
let canvasClassBeingVerified = false;
458+
let canvasClassVerificationError = '';
459+
460+
const verifyCanvasClass = async () => {
461+
canvasClassBeingVerified = true;
462+
const result = await api.verifyCanvasClass(fetch, data.class.id, 'harvard', selectedClass);
463+
const response = api.expandResponse(result);
464+
if (response.error) {
465+
canvasClassVerificationError =
466+
response.error.detail ||
467+
'There was an issue while trying to verify your access to the class roster. Try again later.';
468+
} else {
469+
canvasClassVerified = true;
470+
}
471+
canvasClassBeingVerified = false;
472+
};
473+
451474
let syncingCanvasClass = false;
452475
const syncClass = async () => {
453476
syncingCanvasClass = true;
@@ -975,27 +998,67 @@
975998
/>
976999
</DropdownContainer>
9771000
<div class="flex gap-2 items-center">
1001+
{#if canvasClassBeingVerified}
1002+
<Spinner color="yellow" class="w-6 h-6" />
1003+
<Tooltip
1004+
defaultClass="text-wrap py-2 px-3 mr-5 text-sm font-light shadow-sm"
1005+
arrow={false}
1006+
>We're verifying your access to the class roster. This shouldn't take long.</Tooltip
1007+
>
1008+
{:else if canvasClassVerified}
1009+
<CheckCircleOutline class="w-6 h-6 text-amber-800" />
1010+
<Tooltip
1011+
defaultClass="text-wrap py-2 px-3 mr-5 text-sm font-light shadow-sm"
1012+
arrow={false}>Your access to the class roster has been verified.</Tooltip
1013+
>
1014+
{:else if canvasClassVerificationError}
1015+
<ExclamationCircleOutline class="w-6 h-6 text-amber-800" />
1016+
<Tooltip
1017+
defaultClass="text-wrap py-2 px-3 mr-5 text-sm font-light shadow-sm"
1018+
arrow={false}>{canvasClassVerificationError}</Tooltip
1019+
>
1020+
{:else if !canvasClassVerified}
1021+
<CheckCircleOutline class="w-6 h-6 text-amber-800 text-opacity-25" />
1022+
<Tooltip
1023+
defaultClass="text-wrap py-2 px-3 mr-5 text-sm font-light shadow-sm"
1024+
arrow={false}
1025+
>We'll verify your permissions to access the class roster. Select a class to
1026+
begin.</Tooltip
1027+
>
1028+
{/if}
9781029
<Button
9791030
pill
9801031
size="xs"
9811032
class="shrink-0 max-h-fit border border-amber-900 bg-gradient-to-t from-amber-900 to-amber-800 text-white hover:from-amber-800 hover:to-amber-700"
9821033
on:click={saveSelectedClass}
9831034
on:touchstart={saveSelectedClass}
984-
disabled={loadingCanvasClasses || !selectedClass}
1035+
disabled={loadingCanvasClasses ||
1036+
!selectedClass ||
1037+
canvasClassBeingVerified ||
1038+
!canvasClassVerified}
9851039
>
9861040
Save</Button
9871041
>
9881042
<Button
9891043
pill
9901044
size="xs"
9911045
class="shrink-0 max-h-fit border border-gray-400 bg-gradient-to-t from-gray-100 to-gray-100 text-gray-800 hover:from-gray-200 hover:to-gray-100"
1046+
disabled={loadingCanvasClasses || canvasClassBeingVerified}
9921047
on:click={() => {
9931048
$loadedCanvasClasses = [];
9941049
selectedClass = '';
1050+
canvasClassVerified = false;
1051+
canvasClassBeingVerified = false;
1052+
canvasClassVerificationError = '';
1053+
classSelectDropdownOpen = false;
9951054
}}
9961055
on:touchstart={() => {
9971056
$loadedCanvasClasses = [];
9981057
selectedClass = '';
1058+
canvasClassVerified = false;
1059+
canvasClassBeingVerified = false;
1060+
canvasClassVerificationError = '';
1061+
classSelectDropdownOpen = false;
9991062
}}
10001063
>
10011064
Cancel</Button
@@ -1188,16 +1251,16 @@
11881251
<span class="text-lg font-medium">Important: Reconnect your Canvas account</span>
11891252
</div>
11901253
<p class="mt-2 text-sm">
1191-
We faced an issue when trying to connect to your Canvas account. Use the
1192-
reconnection button below to reauthorize Pingpong to access your Canvas account and
1193-
ensure uninterrupted syncing of your class roster.
1254+
We faced an issue when trying to get the class roster from your Canvas account. Use
1255+
the reconnection button below to reauthorize Pingpong to access your Canvas account
1256+
and ensure uninterrupted syncing of your class roster.
11941257
</p>
11951258
<p class="mt-2 mb-4 text-sm">
11961259
Last sync: {data.class.lms_last_synced
11971260
? dayjs.utc(data.class.lms_last_synced).fromNow()
11981261
: 'never'}
11991262
</p>
1200-
<div class="flex gap-2">
1263+
<div class="flex flex-row justify-between items-center">
12011264
<Button
12021265
pill
12031266
size="xs"
@@ -1207,7 +1270,23 @@
12071270
>
12081271
<RefreshOutline class="w-4 h-4 me-2" />Reconnect Canvas account</Button
12091272
>
1273+
<Button
1274+
pill
1275+
size="xs"
1276+
class="border border-red-900 hover:bg-red-900 text-red-900 hover:bg-gradient-to-t hover:from-red-800 hover:to-red-700 hover:text-white"
1277+
on:click={() => (disconnectCanvas = true)}
1278+
on:touchstart={() => (disconnectCanvas = true)}
1279+
>
1280+
<UserRemoveSolid class="w-4 h-4 me-2" />Disconnect Canvas account</Button
1281+
>
12101282
</div>
1283+
<Modal bind:open={disconnectCanvas} size="sm" autoclose>
1284+
<CanvasDisconnectModal
1285+
canvasCourseCode={data.class.lms_class?.course_code || ''}
1286+
on:keep={() => removeCanvasConnection(true)}
1287+
on:remove={() => removeCanvasConnection(false)}
1288+
/>
1289+
</Modal>
12111290
</div>
12121291
</Alert>
12131292
{:else if data.class.lms_status === 'error'}

0 commit comments

Comments
 (0)