Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Builds out roles + role-specific landing UX + tighten up auth guards #455

Merged
merged 27 commits into from
Nov 1, 2024
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
08eae0c
feat: adding role to session, will be needed for lots of features
thomhickey Sep 30, 2024
69acc50
fix: refactor types into src/types/auth.ts
thomhickey Sep 30, 2024
c4607be
fix: move type to auth.ts types
thomhickey Sep 30, 2024
be24b11
fix: refactor trpc auth guard name + add two more
thomhickey Oct 7, 2024
473ba16
feat: migration to build out roles in db + one rename of auth guard
thomhickey Oct 7, 2024
46e7330
Add UserType enum and refactor hasMinimumRole
canjalal Oct 7, 2024
9c4040b
Do not display staff link on frontend unless user role is case manage…
canjalal Oct 7, 2024
c76a507
Fix typescript types
canjalal Oct 9, 2024
75ee0f4
Move UserType enum to auth types, only let Paras see Assigned link
canjalal Oct 9, 2024
774ab08
feat: navitems are role-based, sorry page added, front-end error hand…
thomhickey Oct 10, 2024
11dc2fb
feat: auth guards + type fix in trpc.ts
thomhickey Oct 12, 2024
5258bc4
feat: all roles land on correct page + paras can see their tasks
thomhickey Oct 16, 2024
013b29e
Specify UNAUTHORIZED as error
canjalal Oct 17, 2024
2c020f8
Add specs to test authentication of each of case_maanger router endpo…
canjalal Oct 17, 2024
b1e5c2f
Only paras and up can upload files
canjalal Oct 17, 2024
46ce087
Only allow case managers to access iep router routes, and add two api…
canjalal Oct 17, 2024
5b382d7
Add tests for authenticated access to para controller routes
canjalal Oct 17, 2024
d4f8622
Add some specs to student router endpoints for controlled access
canjalal Oct 17, 2024
cbe4152
Finish adding specs to student router
canjalal Oct 17, 2024
3e1f540
fix: remove 401 hook as it doesn't work with all routes
thomhickey Oct 17, 2024
38eb042
taking main
thomhickey Oct 18, 2024
642dad7
feat: link accounts to compass app when they first log in if they wer…
thomhickey Oct 18, 2024
6e1731e
fix: some auth guards were wrong, removed a test.
thomhickey Oct 18, 2024
8ec54a0
Tweak misleading test
canjalal Oct 28, 2024
3b95394
taking main
thomhickey Nov 1, 2024
df71b5b
fix: migration collision
thomhickey Nov 1, 2024
99fa308
fix: type check
thomhickey Nov 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/2_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()
.executeTakeFirst();
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
Loading