From 3c9575184bbb489e579a917e3e60bfbff72a59fd Mon Sep 17 00:00:00 2001 From: Thom Hickey Date: Thu, 31 Oct 2024 22:12:44 -0700 Subject: [PATCH] Builds out roles + role-specific landing UX + tighten up auth guards (#455) * feat: adding role to session, will be needed for lots of features * fix: refactor types into src/types/auth.ts * fix: move type to auth.ts types * fix: refactor trpc auth guard name + add two more * feat: migration to build out roles in db + one rename of auth guard * Add UserType enum and refactor hasMinimumRole * Do not display staff link on frontend unless user role is case manager or admin * Fix typescript types * Move UserType enum to auth types, only let Paras see Assigned link * feat: navitems are role-based, sorry page added, front-end error handling of 401 * feat: auth guards + type fix in trpc.ts * feat: all roles land on correct page + paras can see their tasks * Specify UNAUTHORIZED as error * Add specs to test authentication of each of case_maanger router endpoints * Only paras and up can upload files * Only allow case managers to access iep router routes, and add two api endpoint tests * Add tests for authenticated access to para controller routes * Add some specs to student router endpoints for controlled access * Finish adding specs to student router * fix: remove 401 hook as it doesn't work with all routes * feat: link accounts to compass app when they first log in if they were pre-added by case manager or admin * fix: some auth guards were wrong, removed a test. * Tweak misleading test * fix: migration collision * fix: type check --------- Co-authored-by: Vincent Shuali --- src/backend/auth/adapter.ts | 20 ++- src/backend/auth/options.ts | 60 +++++-- src/backend/context.ts | 7 +- src/backend/db/lib/seed.ts | 3 +- src/backend/db/migrations/3_rbac_roles.sql | 14 ++ src/backend/db/zapatos/schema.d.ts | 10 ++ src/backend/lib/db_helpers/case_manager.ts | 5 +- src/backend/routers/admin.test.ts | 13 +- src/backend/routers/admin.ts | 4 +- src/backend/routers/case_manager.test.ts | 158 ++++++++++++++++-- src/backend/routers/case_manager.ts | 22 +-- src/backend/routers/file.test.ts | 11 +- src/backend/routers/file.ts | 12 +- src/backend/routers/iep.test.ts | 43 ++++- src/backend/routers/iep.ts | 36 ++-- src/backend/routers/para.test.ts | 89 +++++++++- src/backend/routers/para.ts | 10 +- src/backend/routers/public.ts | 4 +- src/backend/routers/student.test.ts | 99 ++++++++++- src/backend/routers/student.ts | 14 +- src/backend/routers/user.test.ts | 7 +- src/backend/routers/user.ts | 6 +- src/backend/tests/fixtures/get-test-server.ts | 3 +- src/backend/tests/seed.ts | 7 +- src/backend/trpc.ts | 77 ++++++++- src/client/lib/protected-page.tsx | 5 +- src/components/navbar/NavBar.tsx | 107 +++++++++--- src/pages/api/auth/[...nextauth].ts | 29 +++- src/pages/index.tsx | 26 ++- src/pages/signInPage.tsx | 2 +- src/pages/sorry.tsx | 14 ++ src/types/auth.ts | 27 +++ tsconfig.json | 3 +- 33 files changed, 784 insertions(+), 163 deletions(-) create mode 100644 src/backend/db/migrations/3_rbac_roles.sql create mode 100644 src/pages/sorry.tsx create mode 100644 src/types/auth.ts diff --git a/src/backend/auth/adapter.ts b/src/backend/auth/adapter.ts index b89a97bb..7d659103 100644 --- a/src/backend/auth/adapter.ts +++ b/src/backend/auth/adapter.ts @@ -1,19 +1,27 @@ -import { Adapter, AdapterSession, AdapterUser } from "next-auth/adapters"; +import { Adapter, AdapterSession, AdapterAccount } from "next-auth/adapters"; import { KyselyDatabaseInstance, KyselySchema, ZapatosTableNameToKyselySchema, } from "../lib"; import { InsertObject, Selectable } from "kysely"; +import { CustomAdapterUser, UserType } from "@/types/auth"; + +// Extend the Adapter interface to include the implemented methods +export interface ExtendedAdapter extends Adapter { + getUserByEmail: (email: string) => Promise; + linkAccount: (account: AdapterAccount) => Promise; +} const mapStoredUserToAdapterUser = ( user: Selectable> -): AdapterUser => ({ +): CustomAdapterUser => ({ id: user.user_id, email: user.email, emailVerified: user.email_verified_at, name: `${user.first_name} ${user.last_name}`, image: user.image_url, + profile: { role: user.role as UserType }, // Add the role to the profile }); const mapStoredSessionToAdapterSession = ( @@ -33,16 +41,18 @@ const mapStoredSessionToAdapterSession = ( */ export const createPersistedAuthAdapter = ( db: KyselyDatabaseInstance -): Adapter => ({ +): ExtendedAdapter => ({ async createUser(user) { const numOfUsers = await db .selectFrom("user") .select((qb) => qb.fn.count("user_id").as("count")) .executeTakeFirstOrThrow(); - // First created user is an admin + // First created user is an admin, else make them a user. This is to ensure there is always an admin user, but also to ensure we don't grant + // para or case_manager to folks not pre-added to the system. // todo: this should be pulled from an invite or something else instead of defaulting to a para - currently devs signing in are being assigned as paras - const role = Number(numOfUsers.count) === 0 ? "admin" : "staff"; + const role = + Number(numOfUsers.count) === 0 ? UserType.Admin : UserType.User; const [first_name, last_name] = user.name?.split(" ") ?? [ user.email?.split("@")[0], diff --git a/src/backend/auth/options.ts b/src/backend/auth/options.ts index a08fa6b5..08f57ca5 100644 --- a/src/backend/auth/options.ts +++ b/src/backend/auth/options.ts @@ -2,18 +2,54 @@ import GoogleProvider from "next-auth/providers/google"; import { createPersistedAuthAdapter } from "@/backend/auth/adapter"; import { KyselyDatabaseInstance } from "../lib"; import type { NextAuthOptions } from "next-auth"; +import type { ExtendedAdapter } from "@/backend/auth/adapter"; export const getNextAuthOptions = ( db: KyselyDatabaseInstance -): NextAuthOptions => ({ - providers: [ - GoogleProvider({ - clientId: process.env.GOOGLE_CLIENT_ID as string, - clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, - }), - ], - adapter: createPersistedAuthAdapter(db), - pages: { - signIn: "/signInPage", - }, -}); +): NextAuthOptions => { + const adapter: ExtendedAdapter = createPersistedAuthAdapter(db); + + return { + providers: [ + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID as string, + clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, + }), + ], + adapter, + pages: { + signIn: "/signInPage", + }, + callbacks: { + // hook into the sign in process so we can link accounts if needed + async signIn({ user, account }) { + if (account?.provider === "google") { + const existingUser = await adapter.getUserByEmail(user.email!); + + if (existingUser) { + // user exists, check if account is linked + const linkedAccount = await db + .selectFrom("account") + .where("user_id", "=", existingUser.id) + .where("provider_name", "=", account.provider) + .where("provider_account_id", "=", account.providerAccountId) + .selectAll() + .executeTakeFirst(); + + if (!linkedAccount) { + // user was added by case manager or admin but hasn't logged in before so + // we need to link the user's google account + if (adapter.linkAccount) { + await adapter.linkAccount({ + ...account, + userId: existingUser.id, + }); + } + } + } + } + return true; + }, + }, + }; +}; diff --git a/src/backend/context.ts b/src/backend/context.ts index 8b996a08..72023fb3 100644 --- a/src/backend/context.ts +++ b/src/backend/context.ts @@ -4,8 +4,9 @@ import { S3Client } from "@aws-sdk/client-s3"; import { Session, getServerSession } from "next-auth"; import { Env } from "./lib/types"; import { getNextAuthOptions } from "./auth/options"; +import { UserType } from "@/types/auth"; -type Auth = +export type Auth = | { type: "none"; } @@ -13,7 +14,7 @@ type Auth = type: "session"; session: Session; userId: string; - role: string; + role: UserType; }; export type tRPCContext = ReturnType & { @@ -52,7 +53,7 @@ export const createContext = async ( type: "session", session, userId: user.user_id, - role: user.role, + role: user.role as UserType, }; } diff --git a/src/backend/db/lib/seed.ts b/src/backend/db/lib/seed.ts index 8dcb5dc2..c3d94aae 100644 --- a/src/backend/db/lib/seed.ts +++ b/src/backend/db/lib/seed.ts @@ -1,5 +1,6 @@ import { logger } from "@/backend/lib"; import { getDb } from "@/backend/db/lib/get-db"; +import { UserType } from "@/types/auth"; export const seedfile = async (databaseUrl: string) => { const { db } = getDb(databaseUrl); @@ -49,7 +50,7 @@ export const seedfile = async (databaseUrl: string) => { first_name: "Helen", last_name: "Parr", email: "elastic@example.com", - role: "staff", + role: UserType.Para, }) .returning("user_id") .executeTakeFirstOrThrow(); diff --git a/src/backend/db/migrations/3_rbac_roles.sql b/src/backend/db/migrations/3_rbac_roles.sql new file mode 100644 index 00000000..fe03bcba --- /dev/null +++ b/src/backend/db/migrations/3_rbac_roles.sql @@ -0,0 +1,14 @@ +-- Step 1: Drop the existing check constraint if it exists +ALTER TABLE "public"."user" DROP CONSTRAINT IF EXISTS user_role_check; + +-- Step 3: Update existing roles +UPDATE "public"."user" SET role = 'case_manager' WHERE role = 'admin'; +UPDATE "public"."user" SET role = 'para' WHERE role = 'staff'; + +-- Step 2: Add the new check constraint with the updated roles +ALTER TABLE "public"."user" ADD CONSTRAINT user_role_check +CHECK (role = ANY (ARRAY['user'::text, 'para'::text, 'case_manager'::text, 'admin'::text])); + + +-- Step 4: Add a comment to the table explaining the role values +COMMENT ON COLUMN "public"."user".role IS 'User role: user, para, case_manager, or admin'; diff --git a/src/backend/db/zapatos/schema.d.ts b/src/backend/db/zapatos/schema.d.ts index a5b4f275..8707e24b 100644 --- a/src/backend/db/zapatos/schema.d.ts +++ b/src/backend/db/zapatos/schema.d.ts @@ -2605,6 +2605,8 @@ declare module 'zapatos/schema' { last_name: string; /** * **user.role** + * + * User role: user, para, case_manager, or admin * - `text` in database * - `NOT NULL`, no default */ @@ -2649,6 +2651,8 @@ declare module 'zapatos/schema' { last_name: string; /** * **user.role** + * + * User role: user, para, case_manager, or admin * - `text` in database * - `NOT NULL`, no default */ @@ -2693,6 +2697,8 @@ declare module 'zapatos/schema' { last_name?: string | db.Parameter | db.SQLFragment | db.ParentColumn | db.SQLFragment | db.SQLFragment | db.ParentColumn>; /** * **user.role** + * + * User role: user, para, case_manager, or admin * - `text` in database * - `NOT NULL`, no default */ @@ -2737,6 +2743,8 @@ declare module 'zapatos/schema' { last_name: string | db.Parameter | db.SQLFragment; /** * **user.role** + * + * User role: user, para, case_manager, or admin * - `text` in database * - `NOT NULL`, no default */ @@ -2781,6 +2789,8 @@ declare module 'zapatos/schema' { last_name?: string | db.Parameter | db.SQLFragment | db.SQLFragment | db.SQLFragment>; /** * **user.role** + * + * User role: user, para, case_manager, or admin * - `text` in database * - `NOT NULL`, no default */ diff --git a/src/backend/lib/db_helpers/case_manager.ts b/src/backend/lib/db_helpers/case_manager.ts index c1f6f78c..a614c1cc 100644 --- a/src/backend/lib/db_helpers/case_manager.ts +++ b/src/backend/lib/db_helpers/case_manager.ts @@ -2,6 +2,7 @@ import { Env } from "@/backend/lib/types"; import { KyselyDatabaseInstance } from "@/backend/lib"; import { getTransporter } from "@/backend/lib/nodemailer"; import { user } from "zapatos/schema"; +import { UserType } from "@/types/auth"; interface paraInputProps { first_name: string; @@ -11,7 +12,7 @@ interface paraInputProps { /** * Checks for the existence of a user with the given email, if - * they do not exist, create the user with the role of "staff", + * they do not exist, create the user with the role of "para", * initiate email sending without awaiting result */ export async function createPara( @@ -37,7 +38,7 @@ export async function createPara( first_name, last_name, email: email.toLowerCase(), - role: "staff", + role: UserType.Para, }) .returningAll() .executeTakeFirstOrThrow(); diff --git a/src/backend/routers/admin.test.ts b/src/backend/routers/admin.test.ts index 8a07cb0d..b9475061 100644 --- a/src/backend/routers/admin.test.ts +++ b/src/backend/routers/admin.test.ts @@ -1,17 +1,24 @@ import test from "ava"; import { getTestServer } from "@/backend/tests"; +import { UserType } from "@/types/auth"; test("getPostgresInfo", async (t) => { - const { trpc } = await getTestServer(t, { authenticateAs: "admin" }); + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Admin }); const postgresInfo = await trpc.admin.getPostgresInfo.query(); t.true(postgresInfo.includes("PostgreSQL")); }); test("getPostgresInfo (throws if not admin)", async (t) => { - const { trpc } = await getTestServer(t, { authenticateAs: "para" }); + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); - await t.throwsAsync(async () => { + const error = await t.throwsAsync(async () => { await trpc.admin.getPostgresInfo.query(); }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); }); diff --git a/src/backend/routers/admin.ts b/src/backend/routers/admin.ts index 5c5099a8..80a2eb98 100644 --- a/src/backend/routers/admin.ts +++ b/src/backend/routers/admin.ts @@ -1,8 +1,8 @@ import { sql } from "kysely"; -import { adminProcedure, router } from "../trpc"; +import { hasAdmin, router } from "../trpc"; export const admin = router({ - getPostgresInfo: adminProcedure.query(async (req) => { + getPostgresInfo: hasAdmin.query(async (req) => { const result = await sql<{ version: string }>`SELECT version()`.execute( req.ctx.db ); diff --git a/src/backend/routers/case_manager.test.ts b/src/backend/routers/case_manager.test.ts index 0de75f9c..38efbad4 100644 --- a/src/backend/routers/case_manager.test.ts +++ b/src/backend/routers/case_manager.test.ts @@ -5,10 +5,11 @@ import { STUDENT_ASSIGNED_TO_YOU_ERR, STUDENT_ALREADY_ASSIGNED_ERR, } from "@/backend/lib/db_helpers/case_manager"; +import { UserType } from "@/types/auth"; -test("getMyStudents", async (t) => { +test("getMyStudents - can fetch students", async (t) => { const { trpc, db, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const { student_id } = await db @@ -28,9 +29,23 @@ test("getMyStudents", async (t) => { t.is(myStudents[0].student_id, student_id); }); +test("getMyStudents - paras do not have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + const error = await t.throwsAsync(async () => { + await trpc.case_manager.getMyStudents.query(); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); + test("getMyStudentsAndIepInfo - student does not have IEP", async (t) => { const { trpc, db, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const student = await db @@ -54,7 +69,7 @@ test("getMyStudentsAndIepInfo - student does not have IEP", async (t) => { test("getMyStudentsAndIepInfo - student has IEP", async (t) => { const { trpc, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); await trpc.case_manager.addStudent.mutate({ @@ -81,9 +96,23 @@ test("getMyStudentsAndIepInfo - student has IEP", async (t) => { t.deepEqual(myStudentsAfter[0].end_date, iep.end_date); }); +test("getMyStudentsAndIepInfo - paras do not have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + const error = await t.throwsAsync(async () => { + await trpc.case_manager.getMyStudentsAndIepInfo.query(); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); + test("addStudent - student doesn't exist in db", async (t) => { const { trpc, db } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const myStudentsBefore = await trpc.case_manager.getMyStudents.query(); @@ -109,7 +138,7 @@ test("addStudent - student doesn't exist in db", async (t) => { test("addStudent - student exists in db but is unassigned", async (t) => { const { trpc, db, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const before = await trpc.case_manager.getMyStudents.query(); @@ -144,7 +173,7 @@ test("addStudent - student exists in db but is unassigned", async (t) => { test("addStudent - student exists in db and is already assigned to user", async (t) => { const { trpc, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const studentsBefore = await trpc.case_manager.getMyStudents.query(); @@ -175,7 +204,7 @@ test("addStudent - student exists in db and is already assigned to user", async test("addStudent - student exists in db but is assigned to another case manager", async (t) => { const { trpc, db, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const studentsBefore = await trpc.case_manager.getMyStudents.query(); @@ -187,7 +216,7 @@ test("addStudent - student exists in db but is assigned to another case manager" .values({ first_name: "Fake", last_name: "CM", - role: "admin", + role: UserType.Admin, email: "fakecm@test.com", }) .returningAll() @@ -249,7 +278,7 @@ test("addStudent - student exists in db but is assigned to another case manager" test("addStudent - invalid email", async (t) => { const { trpc } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const err = await t.throwsAsync( @@ -277,9 +306,28 @@ test("addStudent - invalid email", async (t) => { } }); +test("addStudent - paras do not have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + const error = await t.throwsAsync(async () => { + await trpc.case_manager.addStudent.mutate({ + first_name: "Foo", + last_name: "Bar", + email: "foo@bar.com", + grade: 6, + }); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); + test("removeStudent", async (t) => { const { trpc, db, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const { student_id } = await db @@ -305,9 +353,25 @@ test("removeStudent", async (t) => { t.is(after.length, 0); }); +test("removeStudent - paras do not have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + const error = await t.throwsAsync(async () => { + await trpc.case_manager.removeStudent.mutate({ + student_id: "student_id", + }); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); + test("getMyParas", async (t) => { const { trpc, db, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); let myParas = await trpc.case_manager.getMyParas.query(); @@ -325,9 +389,23 @@ test("getMyParas", async (t) => { t.is(myParas.length, 1); }); +test("getMyParas - paras do not have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + const error = await t.throwsAsync(async () => { + await trpc.case_manager.getMyParas.query(); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); + test("addStaff", async (t) => { const { trpc } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const parasBeforeAdd = await trpc.case_manager.getMyParas.query(); @@ -350,9 +428,27 @@ test("addStaff", async (t) => { t.is(createdPara.email, newParaData.email); }); +test("addStaff - paras do not have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + const error = await t.throwsAsync(async () => { + await trpc.case_manager.addStaff.mutate({ + first_name: "Foo", + last_name: "Bar", + email: "foo@bar.com", + }); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); + test("addPara", async (t) => { const { trpc, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); let myParas = await trpc.case_manager.getMyParas.query(); @@ -366,9 +462,25 @@ test("addPara", async (t) => { t.is(myParas.length, 1); }); +test("addPara - paras do not have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + const error = await t.throwsAsync(async () => { + await trpc.case_manager.addPara.mutate({ + para_id: "para_id", + }); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); + test("removePara", async (t) => { const { trpc, db, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); await db @@ -389,3 +501,19 @@ test("removePara", async (t) => { myParas = await trpc.case_manager.getMyParas.query(); t.is(myParas.length, 0); }); + +test("removePara - paras do not have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + const error = await t.throwsAsync(async () => { + await trpc.case_manager.removePara.mutate({ + para_id: "para_id", + }); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); diff --git a/src/backend/routers/case_manager.ts b/src/backend/routers/case_manager.ts index ff61fc74..00fafda4 100644 --- a/src/backend/routers/case_manager.ts +++ b/src/backend/routers/case_manager.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { authenticatedProcedure, router } from "../trpc"; +import { hasCaseManager, router } from "../trpc"; import { createPara, assignParaToCaseManager, @@ -10,7 +10,7 @@ export const case_manager = router({ /** * Get all students assigned to the current user */ - getMyStudents: authenticatedProcedure.query(async (req) => { + getMyStudents: hasCaseManager.query(async (req) => { const { userId } = req.ctx.auth; const result = await req.ctx.db @@ -22,7 +22,7 @@ export const case_manager = router({ return result; }), - getMyStudentsAndIepInfo: authenticatedProcedure.query(async (req) => { + getMyStudentsAndIepInfo: hasCaseManager.query(async (req) => { const { userId } = req.ctx.auth; const studentData = await req.ctx.db @@ -50,7 +50,7 @@ export const case_manager = router({ * it doesn't already exist. Throws an error if the student is already * assigned to another CM. */ - addStudent: authenticatedProcedure + addStudent: hasCaseManager .input( z.object({ first_name: z.string(), @@ -72,7 +72,7 @@ export const case_manager = router({ /** * Edits the given student in the CM's roster. Throws an error if the student was not found in the db. */ - editStudent: authenticatedProcedure + editStudent: hasCaseManager .input( z.object({ student_id: z.string(), @@ -115,7 +115,7 @@ export const case_manager = router({ /** * Removes the case manager associated with this student. */ - removeStudent: authenticatedProcedure + removeStudent: hasCaseManager .input( z.object({ student_id: z.string(), @@ -131,7 +131,7 @@ export const case_manager = router({ .execute(); }), - getMyParas: authenticatedProcedure.query(async (req) => { + getMyParas: hasCaseManager.query(async (req) => { const { userId } = req.ctx.auth; const result = await req.ctx.db @@ -152,7 +152,7 @@ export const case_manager = router({ * Handles creation of para and assignment to user, attempts to send * email but does not await email success */ - addStaff: authenticatedProcedure + addStaff: hasCaseManager .input( z.object({ first_name: z.string(), @@ -180,7 +180,7 @@ export const case_manager = router({ /** * Deprecated: use addStaff instead */ - addPara: authenticatedProcedure + addPara: hasCaseManager .input( z.object({ para_id: z.string(), @@ -195,7 +195,7 @@ export const case_manager = router({ return; }), - editPara: authenticatedProcedure + editPara: hasCaseManager .input( z.object({ para_id: z.string(), @@ -236,7 +236,7 @@ export const case_manager = router({ .executeTakeFirstOrThrow(); }), - removePara: authenticatedProcedure + removePara: hasCaseManager .input( z.object({ para_id: z.string(), diff --git a/src/backend/routers/file.test.ts b/src/backend/routers/file.test.ts index 81a89a8f..ce228c33 100644 --- a/src/backend/routers/file.test.ts +++ b/src/backend/routers/file.test.ts @@ -2,9 +2,12 @@ import test from "ava"; import axios from "axios"; import fs from "node:fs/promises"; import { getTestServer } from "@/backend/tests"; +import { UserType } from "@/types/auth"; test("can upload files", async (t) => { - const { trpc, db } = await getTestServer(t, { authenticateAs: "para" }); + const { trpc, db } = await getTestServer(t, { + authenticateAs: UserType.Para, + }); const { url, key } = await trpc.file.getPresignedUrlForFileUpload.mutate({ type: "image/png", @@ -27,7 +30,7 @@ test("can upload files", async (t) => { }); test("finishFileUpload throws if file already exists", async (t) => { - const { trpc } = await getTestServer(t, { authenticateAs: "para" }); + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); const { url, key } = await trpc.file.getPresignedUrlForFileUpload.mutate({ type: "image/png", @@ -50,7 +53,7 @@ test("finishFileUpload throws if file already exists", async (t) => { }); test("finishFileUpload throws if invalid key is provided", async (t) => { - const { trpc } = await getTestServer(t, { authenticateAs: "para" }); + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); await t.throwsAsync(async () => { await trpc.file.finishFileUpload.mutate({ @@ -61,7 +64,7 @@ test("finishFileUpload throws if invalid key is provided", async (t) => { }); test("can download files", async (t) => { - const { trpc } = await getTestServer(t, { authenticateAs: "para" }); + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); const { url, key } = await trpc.file.getPresignedUrlForFileUpload.mutate({ type: "image/png", diff --git a/src/backend/routers/file.ts b/src/backend/routers/file.ts index cd99aed8..04d78b73 100644 --- a/src/backend/routers/file.ts +++ b/src/backend/routers/file.ts @@ -5,12 +5,12 @@ import { GetObjectCommand, } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -import { authenticatedProcedure, router } from "../trpc"; +import { hasPara, router } from "../trpc"; import { randomUUID } from "crypto"; import { deleteFile } from "../lib/files"; export const file = router({ - getMyFiles: authenticatedProcedure.query(async (req) => { + getMyFiles: hasPara.query(async (req) => { return req.ctx.db .selectFrom("file") .selectAll() @@ -18,7 +18,7 @@ export const file = router({ .execute(); }), - getPresignedUrlForFileDownload: authenticatedProcedure + getPresignedUrlForFileDownload: hasPara .input( z.object({ file_id: z.string().uuid(), @@ -50,7 +50,7 @@ export const file = router({ }; }), - getPresignedUrlForFileUpload: authenticatedProcedure + getPresignedUrlForFileUpload: hasPara .input( z.object({ type: z.string(), @@ -71,7 +71,7 @@ export const file = router({ return { url, key }; }), - finishFileUpload: authenticatedProcedure + finishFileUpload: hasPara .input( z.object({ filename: z.string(), @@ -99,7 +99,7 @@ export const file = router({ return file; }), - deleteFile: authenticatedProcedure + deleteFile: hasPara .input( z.object({ file_id: z.string().uuid(), diff --git a/src/backend/routers/iep.test.ts b/src/backend/routers/iep.test.ts index 5cf72230..48589dcd 100644 --- a/src/backend/routers/iep.test.ts +++ b/src/backend/routers/iep.test.ts @@ -1,10 +1,11 @@ import test from "ava"; import { getTestServer } from "@/backend/tests"; +import { UserType } from "@/types/auth"; // TODO: Write more tests test("basic flow - add/get goals, subgoals, tasks", async (t) => { const { trpc, db, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const para_id = seed.para.user_id; @@ -105,7 +106,7 @@ test("basic flow - add/get goals, subgoals, tasks", async (t) => { test("addTask - no duplicate subgoal_id + assigned_id combo", async (t) => { const { trpc, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const para_id = seed.para.user_id; @@ -161,7 +162,7 @@ test("addTask - no duplicate subgoal_id + assigned_id combo", async (t) => { test("assignTaskToParas - no duplicate subgoal_id + para_id combo", async (t) => { const { trpc, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const para_1 = seed.para; @@ -223,7 +224,7 @@ test("assignTaskToParas - no duplicate subgoal_id + para_id combo", async (t) => test("add benchmark - check full schema", async (t) => { const { trpc, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const iep = await trpc.student.addIep.mutate({ @@ -271,7 +272,7 @@ test("add benchmark - check full schema", async (t) => { test("edit goal", async (t) => { const { trpc, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); await trpc.case_manager.addStudent.mutate({ @@ -301,3 +302,35 @@ test("edit goal", async (t) => { t.is(modifiedGoal!.goal_id, goal!.goal_id); t.is(modifiedGoal?.description, "modified goal 1"); }); +test("editGoal - paras do not have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + const error = await t.throwsAsync(async () => { + await trpc.iep.editGoal.mutate({ + goal_id: "goal_id", + description: "description", + }); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); + +test("getGoal - paras do not have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + const error = await t.throwsAsync(async () => { + await trpc.iep.getGoal.query({ + goal_id: "goal_id", + }); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); diff --git a/src/backend/routers/iep.ts b/src/backend/routers/iep.ts index f54d9794..30d9d5e3 100644 --- a/src/backend/routers/iep.ts +++ b/src/backend/routers/iep.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { authenticatedProcedure, router } from "../trpc"; +import { hasCaseManager, hasPara, router } from "../trpc"; import { jsonArrayFrom } from "kysely/helpers/postgres"; import { deleteFile } from "../lib/files"; import { substituteTransactionOnContext } from "../lib/utils/context"; @@ -7,7 +7,7 @@ import { TRPCError } from "@trpc/server"; // TODO: define .output() schemas for all procedures export const iep = router({ - addGoal: authenticatedProcedure + addGoal: hasCaseManager .input( z.object({ iep_id: z.string(), @@ -31,7 +31,7 @@ export const iep = router({ return result; }), - editGoal: authenticatedProcedure + editGoal: hasCaseManager .input( z.object({ goal_id: z.string(), @@ -70,7 +70,7 @@ export const iep = router({ return result; }), - addSubgoal: authenticatedProcedure + addSubgoal: hasCaseManager .input( z.object({ // current_level not included, should be calculated as trial data is collected @@ -123,7 +123,7 @@ export const iep = router({ return result; }), - addTask: authenticatedProcedure + addTask: hasCaseManager .input( z.object({ subgoal_id: z.string(), @@ -161,7 +161,7 @@ export const iep = router({ return result; }), - assignTaskToParas: authenticatedProcedure + assignTaskToParas: hasCaseManager .input( z.object({ subgoal_id: z.string().uuid(), @@ -201,7 +201,7 @@ export const iep = router({ return result; }), //Temporary function to easily assign tasks to self for testing - tempAddTaskToSelf: authenticatedProcedure + tempAddTaskToSelf: hasCaseManager .input( z.object({ subgoal_id: z.string(), @@ -243,7 +243,7 @@ export const iep = router({ return result; }), - addTrialData: authenticatedProcedure + addTrialData: hasPara .input( z.object({ task_id: z.string(), @@ -272,7 +272,7 @@ export const iep = router({ return result; }), - updateTrialData: authenticatedProcedure + updateTrialData: hasPara .input( z.object({ trial_data_id: z.string(), @@ -297,7 +297,7 @@ export const iep = router({ .execute(); }), - getGoals: authenticatedProcedure + getGoals: hasCaseManager .input( z.object({ iep_id: z.string(), @@ -315,7 +315,7 @@ export const iep = router({ return result; }), - getGoal: authenticatedProcedure + getGoal: hasCaseManager .input( z.object({ goal_id: z.string(), @@ -333,7 +333,7 @@ export const iep = router({ return result; }), - getSubgoals: authenticatedProcedure + getSubgoals: hasCaseManager .input( z.object({ goal_id: z.string(), @@ -351,7 +351,7 @@ export const iep = router({ return result; }), - getSubgoal: authenticatedProcedure + getSubgoal: hasCaseManager .input( z.object({ subgoal_id: z.string(), @@ -368,7 +368,7 @@ export const iep = router({ return result; }), - getSubgoalsByAssignee: authenticatedProcedure + getSubgoalsByAssignee: hasCaseManager .input( z.object({ assignee_id: z.string(), @@ -387,7 +387,7 @@ export const iep = router({ return result; }), - getSubgoalAndTrialData: authenticatedProcedure + getSubgoalAndTrialData: hasPara .input( z.object({ task_id: z.string(), @@ -450,7 +450,7 @@ export const iep = router({ return result; }), - markAsSeen: authenticatedProcedure + markAsSeen: hasPara .input( z.object({ task_id: z.string(), @@ -468,7 +468,7 @@ export const iep = router({ .execute(); }), - attachFileToTrialData: authenticatedProcedure + attachFileToTrialData: hasCaseManager .input( z.object({ trial_data_id: z.string(), @@ -487,7 +487,7 @@ export const iep = router({ .execute(); }), - removeFileFromTrialDataAndDelete: authenticatedProcedure + removeFileFromTrialDataAndDelete: hasCaseManager .input( z.object({ trial_data_id: z.string(), diff --git a/src/backend/routers/para.test.ts b/src/backend/routers/para.test.ts index b4a014db..2ba0aa51 100644 --- a/src/backend/routers/para.test.ts +++ b/src/backend/routers/para.test.ts @@ -1,9 +1,10 @@ import test from "ava"; import { getTestServer } from "@/backend/tests"; +import { UserType } from "@/types/auth"; test("getParaById", async (t) => { const { trpc, db } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const { user_id } = await db @@ -12,7 +13,7 @@ test("getParaById", async (t) => { first_name: "Foo", last_name: "Bar", email: "foo.bar@email.com", - role: "staff", + role: UserType.Para, }) .returningAll() .executeTakeFirstOrThrow(); @@ -21,9 +22,25 @@ test("getParaById", async (t) => { t.is(para?.user_id, user_id); }); +test("getParaById - paras do not have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + const error = await t.throwsAsync(async () => { + await trpc.para.getParaById.query({ + user_id: "user_id", + }); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); + test("getParaByEmail", async (t) => { const { trpc, db } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const { email } = await db @@ -32,7 +49,7 @@ test("getParaByEmail", async (t) => { first_name: "Foo", last_name: "Bar", email: "foo.bar@email.com", - role: "staff", + role: UserType.Para, }) .returningAll() .executeTakeFirstOrThrow(); @@ -41,9 +58,25 @@ test("getParaByEmail", async (t) => { t.is(para?.email, email); }); +test("getParaByEmail - paras do not have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + const error = await t.throwsAsync(async () => { + await trpc.para.getParaByEmail.query({ + email: "foo@bar.com", + }); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); + test("createPara", async (t) => { const { trpc, db, nodemailerMock } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); await trpc.para.createPara.mutate({ @@ -67,9 +100,27 @@ test("createPara", async (t) => { ); }); +test("createPara - paras do not have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + const error = await t.throwsAsync(async () => { + await trpc.para.createPara.mutate({ + first_name: "Foo", + last_name: "Bar", + email: "foo.bar@email.com", + }); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); + test("paras are deduped by email", async (t) => { const { trpc, db } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); t.falsy(await trpc.para.getParaByEmail.query({ email: "foo.bar@email.com" })); @@ -97,7 +148,7 @@ test("paras are deduped by email", async (t) => { test("createPara - invalid email", async (t) => { const { trpc } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); await t.throwsAsync( @@ -111,7 +162,7 @@ test("createPara - invalid email", async (t) => { test("getMyTasks", async (t) => { const { trpc, db, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const FIRST_NAME = "Foo"; @@ -199,3 +250,25 @@ test("getMyTasks", async (t) => { t.is(task[0].instructions, INSTRUCTIONS); t.is(task[0].trial_count, TRIAL_COUNT); }); + +test("getMyTasks - paras do have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + await t.notThrowsAsync(async () => { + await trpc.para.getMyTasks.query(); + }); +}); + +test("getMyTasks - regular users don't have access", async (t) => { + const { trpc } = await getTestServer(t, {}); + + const error = await t.throwsAsync(async () => { + await trpc.para.getMyTasks.query(); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); diff --git a/src/backend/routers/para.ts b/src/backend/routers/para.ts index c23a796d..2800b402 100644 --- a/src/backend/routers/para.ts +++ b/src/backend/routers/para.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -import { authenticatedProcedure, router } from "../trpc"; +import { hasCaseManager, hasPara, router } from "../trpc"; import { createPara } from "../lib/db_helpers/case_manager"; export const para = router({ - getParaById: authenticatedProcedure + getParaById: hasCaseManager .input(z.object({ user_id: z.string().uuid() })) .query(async (req) => { const { user_id } = req.input; @@ -17,7 +17,7 @@ export const para = router({ return result; }), - getParaByEmail: authenticatedProcedure + getParaByEmail: hasCaseManager .input(z.object({ email: z.string() })) .query(async (req) => { const { email } = req.input; @@ -34,7 +34,7 @@ export const para = router({ /** * Deprecated: use case_manager.addStaff instead */ - createPara: authenticatedProcedure + createPara: hasCaseManager .input( z.object({ first_name: z.string(), @@ -60,7 +60,7 @@ export const para = router({ // TODO elsewhere: add "email_verified_at" timestamp when para first signs in with their email address (entered into db by cm) }), - getMyTasks: authenticatedProcedure.query(async (req) => { + getMyTasks: hasPara.query(async (req) => { const { userId } = req.ctx.auth; const result = await req.ctx.db diff --git a/src/backend/routers/public.ts b/src/backend/routers/public.ts index ae5de6b7..d7ab270d 100644 --- a/src/backend/routers/public.ts +++ b/src/backend/routers/public.ts @@ -1,7 +1,7 @@ -import { publicProcedure, router } from "../trpc"; +import { noAuth, router } from "../trpc"; export const publicRouter = router({ - healthCheck: publicProcedure.query(() => { + healthCheck: noAuth.query(() => { return "Ok"; }), }); diff --git a/src/backend/routers/student.test.ts b/src/backend/routers/student.test.ts index bf049d81..2c70127a 100644 --- a/src/backend/routers/student.test.ts +++ b/src/backend/routers/student.test.ts @@ -1,10 +1,11 @@ import test from "ava"; import { getTestServer } from "@/backend/tests"; import { parseISO } from "date-fns"; +import { UserType } from "@/types/auth"; -test("getStudentById", async (t) => { +test("getStudentById - can query by student id", async (t) => { const { trpc, db, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const { student_id } = await db @@ -23,11 +24,25 @@ test("getStudentById", async (t) => { t.is(student?.student_id, student_id); }); +test("getStudentById - paras do not have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + const error = await t.throwsAsync(async () => { + await trpc.student.getStudentById.query({ student_id: "student_id" }); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); + // TODO: This test looks to be testing the `UNIQUE` constraing on the schema. // Improve this test test("doNotAddDuplicateEmails", async (t) => { const { trpc, db, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); await db @@ -60,7 +75,7 @@ test("doNotAddDuplicateEmails", async (t) => { test("addIep and getIep", async (t) => { const { trpc, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const start_date = new Date("2023-01-01"); @@ -82,9 +97,43 @@ test("addIep and getIep", async (t) => { t.deepEqual(got[0].end_date, added.end_date); }); +test("addIep - paras do not have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + const error = await t.throwsAsync(async () => { + await trpc.student.addIep.mutate({ + student_id: "student_id", + start_date: new Date("2023-01-01"), + end_date: new Date("2023-01-01"), + }); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); + +test("getIeps - paras do not have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + const error = await t.throwsAsync(async () => { + await trpc.student.getIeps.query({ + student_id: "student_id", + }); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); + test("editIep", async (t) => { const { trpc, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); // * must add student this way to populate the assigned_case_manager_id @@ -134,9 +183,27 @@ test("editIep", async (t) => { t.deepEqual(got[0].end_date, updated_end_date); }); +test("editIep - paras do not have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + const error = await t.throwsAsync(async () => { + await trpc.student.editIep.mutate({ + student_id: "student_id", + start_date: new Date(parseISO("2023-03-02")), + end_date: new Date(parseISO("2023-03-02")), + }); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); + test("getActiveStudentIep - return only one iep object", async (t) => { const { trpc, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const start_date = new Date("2023-01-01"); @@ -158,9 +225,25 @@ test("getActiveStudentIep - return only one iep object", async (t) => { t.deepEqual(studentWithIep?.end_date, addedIep.end_date); }); +test("getActiveStudentIep - paras do not have access", async (t) => { + const { trpc } = await getTestServer(t, { authenticateAs: UserType.Para }); + + const error = await t.throwsAsync(async () => { + await trpc.student.getActiveStudentIep.query({ + student_id: "student_id", + }); + }); + + t.is( + error?.message, + "UNAUTHORIZED", + "Expected an 'unauthorized' error message" + ); +}); + test("checkAddedIEPEndDates", async (t) => { const { trpc, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); const start_date = new Date("2023-01-01"); const end_date = new Date("2022-01-01"); @@ -182,7 +265,7 @@ test("checkAddedIEPEndDates", async (t) => { test("checkEditedIEPEndDates", async (t) => { const { trpc, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); await trpc.case_manager.addStudent.mutate({ diff --git a/src/backend/routers/student.ts b/src/backend/routers/student.ts index 263a09d4..e21dfa09 100644 --- a/src/backend/routers/student.ts +++ b/src/backend/routers/student.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -import { authenticatedProcedure, router } from "../trpc"; +import { hasCaseManager, hasPara, router } from "../trpc"; // TODO: define .output() schemas for all procedures export const student = router({ - getStudentById: authenticatedProcedure + getStudentById: hasCaseManager .input(z.object({ student_id: z.string().uuid() })) .query(async (req) => { const { student_id } = req.input; @@ -17,7 +17,7 @@ export const student = router({ return result; }), - getStudentByTaskId: authenticatedProcedure + getStudentByTaskId: hasPara .input(z.object({ task_id: z.string().uuid() })) .query(async (req) => { const { task_id } = req.input; @@ -38,7 +38,7 @@ export const student = router({ /** * Adds a new IEP for the given student. */ - addIep: authenticatedProcedure + addIep: hasCaseManager .input( z.object({ student_id: z.string(), @@ -67,7 +67,7 @@ export const student = router({ /** * Adds a new IEP for the given student. */ - editIep: authenticatedProcedure + editIep: hasCaseManager .input( z.object({ student_id: z.string(), @@ -104,7 +104,7 @@ export const student = router({ /** * Returns all the IEPs associated with the given student. */ - getIeps: authenticatedProcedure + getIeps: hasCaseManager .input( z.object({ student_id: z.string(), @@ -130,7 +130,7 @@ export const student = router({ * per the MVP that there will only be one IEP per student, * but this should be revisited after the MVP. */ - getActiveStudentIep: authenticatedProcedure + getActiveStudentIep: hasCaseManager .input( z.object({ student_id: z.string().uuid(), diff --git a/src/backend/routers/user.test.ts b/src/backend/routers/user.test.ts index b9cedf51..2b845320 100644 --- a/src/backend/routers/user.test.ts +++ b/src/backend/routers/user.test.ts @@ -1,9 +1,10 @@ import test from "ava"; import { getTestServer } from "@/backend/tests"; +import { UserType } from "@/types/auth"; test("getMe", async (t) => { const { trpc, seed } = await getTestServer(t, { - authenticateAs: "para", + authenticateAs: UserType.Para, }); const me = await trpc.user.getMe.query(); @@ -20,7 +21,7 @@ test("getMe (throws if missing auth)", async (t) => { test("isCaseManager (user is case_manager)", async (t) => { const { trpc, db, seed } = await getTestServer(t, { - authenticateAs: "case_manager", + authenticateAs: UserType.CaseManager, }); // Assign a para to the case manager @@ -38,7 +39,7 @@ test("isCaseManager (user is case_manager)", async (t) => { test("isCaseManager (user is para)", async (t) => { const { trpc, db, seed } = await getTestServer(t, { - authenticateAs: "para", + authenticateAs: UserType.Para, }); // A user is not a case manager by default i.e. when paras_assigned_to_case_manager is empty diff --git a/src/backend/routers/user.ts b/src/backend/routers/user.ts index aa031aeb..0c0bfdb3 100644 --- a/src/backend/routers/user.ts +++ b/src/backend/routers/user.ts @@ -1,7 +1,7 @@ -import { authenticatedProcedure, router } from "../trpc"; +import { hasAuthenticated, router } from "../trpc"; export const user = router({ - getMe: authenticatedProcedure.query(async (req) => { + getMe: hasAuthenticated.query(async (req) => { const { userId } = req.ctx.auth; const user = await req.ctx.db @@ -23,7 +23,7 @@ export const user = router({ /** * @returns Whether the current user is a case manager */ - isCaseManager: authenticatedProcedure.query(async (req) => { + isCaseManager: hasAuthenticated.query(async (req) => { const { userId } = req.ctx.auth; const result = await req.ctx.db diff --git a/src/backend/tests/fixtures/get-test-server.ts b/src/backend/tests/fixtures/get-test-server.ts index b8184c48..677d7bdf 100644 --- a/src/backend/tests/fixtures/get-test-server.ts +++ b/src/backend/tests/fixtures/get-test-server.ts @@ -11,9 +11,10 @@ import ms from "ms"; import builtNextJsFixture from "../../../../.nsm"; import { getTestMinio } from "./get-test-minio"; import superjson from "superjson"; +import { UserType } from "@/types/auth"; export interface GetTestServerOptions { - authenticateAs?: "case_manager" | "para" | "admin"; + authenticateAs?: UserType.CaseManager | UserType.Para | UserType.Admin; } export const getTestServer = async ( diff --git a/src/backend/tests/seed.ts b/src/backend/tests/seed.ts index abbdf6d3..84dc8053 100644 --- a/src/backend/tests/seed.ts +++ b/src/backend/tests/seed.ts @@ -1,4 +1,5 @@ import { KyselyDatabaseInstance } from "@/backend/lib"; +import { UserType } from "@/types/auth"; export type SeedResult = Awaited>; @@ -9,7 +10,7 @@ export const seed = async (db: KyselyDatabaseInstance) => { first_name: "Admin", last_name: "One", email: "admin1@example.com", - role: "admin", + role: UserType.Admin, }) .returningAll() .executeTakeFirstOrThrow(); @@ -20,7 +21,7 @@ export const seed = async (db: KyselyDatabaseInstance) => { first_name: "CaseManager", last_name: "One", email: "case_manager1@example.com", - role: "staff", + role: UserType.CaseManager, }) .returningAll() .executeTakeFirstOrThrow(); @@ -31,7 +32,7 @@ export const seed = async (db: KyselyDatabaseInstance) => { first_name: "Para", last_name: "One", email: "para1@example.com", - role: "staff", + role: UserType.Para, }) .returningAll() .executeTakeFirstOrThrow(); diff --git a/src/backend/trpc.ts b/src/backend/trpc.ts index 58391158..443cfcf2 100644 --- a/src/backend/trpc.ts +++ b/src/backend/trpc.ts @@ -1,6 +1,34 @@ import { TRPCError, initTRPC } from "@trpc/server"; -import { createContext } from "./context"; +import { Auth, createContext } from "./context"; import superjson from "superjson"; +import { UserType } from "@/types/auth"; + +// Role-based access control type +type RoleLevel = { + user: 0; + para: 1; + case_manager: 2; + admin: 3; +}; + +const ROLE_LEVELS: RoleLevel = { + user: 0, + para: 1, + case_manager: 2, + admin: 3, +}; + +// Function to compare roles +function hasMinimumRole(auth: Auth, requiredRole: UserType): boolean { + const { type } = auth; + + return ( + type === "session" && ROLE_LEVELS[auth.role] >= ROLE_LEVELS[requiredRole] + ); +} + +// Add this type at the top of the file +type AuthenticatedAuth = Extract; // initialize tRPC exactly once per application: export const t = initTRPC.context().create({ @@ -22,21 +50,58 @@ const isAuthenticated = t.middleware(({ next, ctx }) => { }); }); +const atLeastPara = t.middleware(({ next, ctx }) => { + if (!hasMinimumRole(ctx.auth, UserType.Para)) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + return next({ + ctx: { + ...ctx, + auth: ctx.auth as AuthenticatedAuth, + }, + }); +}); + +const atLeastCaseManager = t.middleware(({ next, ctx }) => { + if (!hasMinimumRole(ctx.auth, UserType.CaseManager)) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + return next({ + ctx: { + ...ctx, + auth: ctx.auth as AuthenticatedAuth, + }, + }); +}); + const isAdmin = t.middleware(({ next, ctx }) => { - if (ctx.auth.type !== "session" || ctx.auth.role !== "admin") { + if (!hasMinimumRole(ctx.auth, UserType.Admin)) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, - auth: ctx.auth, + auth: ctx.auth as AuthenticatedAuth, }, }); }); // Define and export the tRPC router export const router = t.router; -export const publicProcedure = t.procedure; -export const authenticatedProcedure = t.procedure.use(isAuthenticated); -export const adminProcedure = t.procedure.use(isAuthenticated).use(isAdmin); + +// Define and export the tRPC procedures that can be used as auth guards inside routes +export const noAuth = t.procedure; // Can be used for public routes +export const hasAuthenticated = t.procedure // for routes that require authentication only, no specific role + .use(isAuthenticated); +export const hasPara = t.procedure // for routes that require at least para role + .use(isAuthenticated) + .use(atLeastPara); +export const hasCaseManager = t.procedure // for routes that require at least case manager role + .use(isAuthenticated) + .use(atLeastCaseManager); +export const hasAdmin = t.procedure // for routes that require admin role + .use(isAuthenticated) + .use(isAdmin); diff --git a/src/client/lib/protected-page.tsx b/src/client/lib/protected-page.tsx index 49a8eb09..9b8c81a7 100644 --- a/src/client/lib/protected-page.tsx +++ b/src/client/lib/protected-page.tsx @@ -1,6 +1,7 @@ import { trpc } from "./trpc"; import { useEffect } from "react"; import { useRouter } from "next/router"; +import { UserType } from "@/types/auth"; export const requiresAdminAuth = (WrappedPage: React.ComponentType) => @@ -10,7 +11,7 @@ export const requiresAdminAuth = const { data: me, error } = trpc.user.getMe.useQuery(); useEffect(() => { - if ((me && me.role !== "admin") || error) { + if ((me && me.role !== UserType.Admin) || error) { void router.push("/"); } }, [me, error, router]); @@ -19,7 +20,7 @@ export const requiresAdminAuth = return "Loading..."; } - if (me?.role === "admin") { + if (me?.role === UserType.Admin) { return ; } }; diff --git a/src/components/navbar/NavBar.tsx b/src/components/navbar/NavBar.tsx index aef88302..4bc04c20 100644 --- a/src/components/navbar/NavBar.tsx +++ b/src/components/navbar/NavBar.tsx @@ -21,6 +21,7 @@ import * as React from "react"; import { MouseEventHandler } from "react"; import $navbar from "./Navbar.module.css"; import BreadcrumbsNav from "../design_system/breadcrumbs/Breadcrumbs"; +import { ExtendedSession, UserType } from "@/types/auth"; interface NavItemProps { href?: string; @@ -34,7 +35,10 @@ export default function NavBar() { const desktop = useMediaQuery("(min-width: 992px)"); const router = useRouter(); - const { status } = useSession(); + const { status, data: session } = useSession() as { + data: ExtendedSession | null; + status: "loading" | "authenticated" | "unauthenticated"; + }; const handleDrawerToggle = () => { setMobileOpen(!mobileOpen); @@ -87,22 +91,87 @@ export default function NavBar() { ); }; - // Navigation Links - const drawer = ( -
- - } text="Assigned" /> - } text="Students" /> - } text="Staff" /> - } text="Settings" /> - } - text="Logout" - onClick={() => signOut({ callbackUrl: "/" })} - /> - -
- ); + const drawer = () => { + switch (session?.user.role) { + case UserType.Admin: + return ( + + } + text="Assigned" + /> + } + text="Students" + /> + } text="Staff" /> + } + text="Settings" + /> + } + text="Logout" + onClick={() => signOut({ callbackUrl: "/" })} + /> + + ); + case UserType.CaseManager: + return ( + + } + text="Students" + /> + } text="Staff" /> + } + text="Settings" + /> + } + text="Logout" + onClick={() => signOut({ callbackUrl: "/" })} + /> + + ); + case UserType.Para: + return ( + + } + text="Assigned" + /> + } + text="Settings" + /> + } + text="Logout" + onClick={() => signOut({ callbackUrl: "/" })} + /> + + ); + default: + return ( + + } + text="Logout" + onClick={() => signOut({ callbackUrl: "/" })} + /> + + ); + } + }; return ( <> @@ -113,7 +182,7 @@ export default function NavBar() { {/* Sidebar for screens & breadcrumbs > md size */} {logo} - {drawer} + {drawer()} @@ -147,7 +216,7 @@ export default function NavBar() { } /> - {drawer} + {drawer()} )} diff --git a/src/pages/api/auth/[...nextauth].ts b/src/pages/api/auth/[...nextauth].ts index c1e062d1..f900e890 100644 --- a/src/pages/api/auth/[...nextauth].ts +++ b/src/pages/api/auth/[...nextauth].ts @@ -1,7 +1,8 @@ import { getNextAuthOptions } from "@/backend/auth/options"; import { createContext } from "@/backend/context"; import { NextApiHandler } from "next"; -import NextAuth from "next-auth"; +import NextAuth, { Session, User } from "next-auth"; +import { ExtendedSession, UserWithRole } from "@/types/auth"; const handler: NextApiHandler = async (req, res) => { const { db } = await createContext({ @@ -9,8 +10,32 @@ const handler: NextApiHandler = async (req, res) => { res, }); + const authOptions = getNextAuthOptions(db); + + // Modify only the session callback + const modifiedAuthOptions = { + ...authOptions, + callbacks: { + ...authOptions.callbacks, + session({ + session, + user, + }: { + session: Session; + user: User; + }): ExtendedSession { + const extendedSession = session as ExtendedSession; + if (extendedSession.user) { + const userWithRole = user as UserWithRole; + extendedSession.user.role = userWithRole.profile.role; + } + return extendedSession; + }, + }, + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-call - return NextAuth(getNextAuthOptions(db))(req, res); + return NextAuth(modifiedAuthOptions)(req, res); }; export default handler; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index bbba146d..77bf1f86 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -2,16 +2,32 @@ import type { NextPage } from "next"; import { useSession } from "next-auth/react"; import { useRouter } from "next/router"; import React, { useEffect } from "react"; +import { ExtendedSession, UserType } from "@/types/auth"; const Home: NextPage = () => { const router = useRouter(); - const { status } = useSession(); + const { data: session, status } = useSession() as { + data: ExtendedSession | null; + status: "loading" | "authenticated" | "unauthenticated"; + }; useEffect(() => { - status === "authenticated" - ? void router.push("/students") - : void router.push("/signInPage"); - }, [status, router]); + if (status === "authenticated" && session) { + switch (session.user.role) { + case UserType.User: + void router.push("/sorry"); + break; + case UserType.Para: + void router.push("/benchmarks"); + break; + default: + void router.push("/students"); + break; + } + } else { + void router.push("/signInPage"); + } + }, [status, router, session]); return <>Redirecting...; }; diff --git a/src/pages/signInPage.tsx b/src/pages/signInPage.tsx index aca09591..d7b68d3b 100644 --- a/src/pages/signInPage.tsx +++ b/src/pages/signInPage.tsx @@ -32,7 +32,7 @@ const SignInPage = () => { className={$button.default} onClick={() => signIn("google", { - callbackUrl: "/students", + callbackUrl: "/", }) } > diff --git a/src/pages/sorry.tsx b/src/pages/sorry.tsx new file mode 100644 index 00000000..a3daf14a --- /dev/null +++ b/src/pages/sorry.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import $box from "@/styles/Box.module.css"; +import $typo from "@/styles/Typography.module.css"; + +const SorryPage: React.FC = () => { + return ( +
+

Access Denied

+

Your account is not authorized to use this app.

+
+ ); +}; + +export default SorryPage; diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 00000000..8d805845 --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,27 @@ +import { User, Session } from "next-auth"; +import { AdapterUser } from "next-auth/adapters"; + +export interface UserWithRole extends User { + profile: { + role: UserType; + }; +} + +export interface ExtendedSession extends Session { + user: Session["user"] & { + role: UserType; + }; +} + +export interface CustomAdapterUser extends AdapterUser { + profile?: { + role: UserType; + }; +} + +export enum UserType { + User = "user", + Para = "para", + CaseManager = "case_manager", + Admin = "admin", +} diff --git a/tsconfig.json b/tsconfig.json index ce18cd10..2200f4c1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,8 @@ "baseUrl": ".", "paths": { "@/*": ["src/*"] - } + }, + "sourceMap": true }, "include": ["next-env.d.ts", "references.d.ts", "**/*.ts", "**/*.tsx"], "exclude": ["node_modules"]