Skip to content

Commit

Permalink
Builds out roles + role-specific landing UX + tighten up auth guards (#…
Browse files Browse the repository at this point in the history
…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 <vincent.shuali@gmail.com>
  • Loading branch information
thomhickey and canjalal authored Nov 1, 2024
1 parent 637cbc2 commit 3c95751
Show file tree
Hide file tree
Showing 33 changed files with 784 additions and 163 deletions.
20 changes: 15 additions & 5 deletions src/backend/auth/adapter.ts
Original file line number Diff line number Diff line change
@@ -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<CustomAdapterUser | null>;
linkAccount: (account: AdapterAccount) => Promise<void>;
}

const mapStoredUserToAdapterUser = (
user: Selectable<ZapatosTableNameToKyselySchema<"user">>
): 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 = (
Expand All @@ -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],
Expand Down
60 changes: 48 additions & 12 deletions src/backend/auth/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
},
};
};
7 changes: 4 additions & 3 deletions src/backend/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@ 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";
}
| {
type: "session";
session: Session;
userId: string;
role: string;
role: UserType;
};

export type tRPCContext = ReturnType<typeof getDb> & {
Expand Down Expand Up @@ -52,7 +53,7 @@ export const createContext = async (
type: "session",
session,
userId: user.user_id,
role: user.role,
role: user.role as UserType,
};
}

Expand Down
3 changes: 2 additions & 1 deletion src/backend/db/lib/seed.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions src/backend/db/migrations/3_rbac_roles.sql
Original file line number Diff line number Diff line change
@@ -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';
10 changes: 10 additions & 0 deletions src/backend/db/zapatos/schema.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions src/backend/lib/db_helpers/case_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand All @@ -37,7 +38,7 @@ export async function createPara(
first_name,
last_name,
email: email.toLowerCase(),
role: "staff",
role: UserType.Para,
})
.returningAll()
.executeTakeFirstOrThrow();
Expand Down
13 changes: 10 additions & 3 deletions src/backend/routers/admin.test.ts
Original file line number Diff line number Diff line change
@@ -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"
);
});
4 changes: 2 additions & 2 deletions src/backend/routers/admin.ts
Original file line number Diff line number Diff line change
@@ -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
);
Expand Down
Loading

0 comments on commit 3c95751

Please sign in to comment.