diff --git a/__tests__/utils/persmissions.test.ts b/__tests__/utils/persmissions.test.ts new file mode 100644 index 0000000..0b2d14d --- /dev/null +++ b/__tests__/utils/persmissions.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; + +// Import the functions and types +import { getPermissions, hasPermission } from '../../src/utils/permissions'; // Adjust the path accordingly + +describe('Permission Tests', () => { + const superAdmin = { role: 'SUPER_ADMIN' } as const; + const admin = { role: 'ADMIN' } as const; + const staff = { role: 'STAFF' } as const; + const user = { role: 'USER' } as const; + + it('should allow SUPER_ADMIN to perform all actions', () => { + const permissions = getPermissions(superAdmin, false); + + for (const resource in permissions) { + for (const action in permissions[resource as keyof typeof permissions]) { + expect(permissions[resource as keyof typeof permissions][action as keyof typeof permissions[keyof typeof permissions]]).toBe(true); + } + } + }); + + it('should allow ADMIN to perform certain actions', () => { + const permissions = getPermissions(admin, false); + + expect(permissions.Vouchers.UPDATE).toBe(true); + expect(permissions.Vouchers.DELETE).toBe(false); // Only OWNER or SUPER_ADMIN can delete + expect(permissions.Users.VIEW_PII).toBe(true); + expect(permissions.Gas.APPROVE).toBe(true); + }); + + it('should allow STAFF to approve gas', () => { + const permissions = getPermissions(staff, false); + + expect(permissions.Gas.APPROVE).toBe(true); + expect(permissions.Vouchers.UPDATE).toBe(false); + }); + + it('should not allow USER to perform any restricted actions', () => { + const permissions = getPermissions(user, false); + + for (const resource in permissions) { + for (const action in permissions[resource as keyof typeof permissions]) { + expect(permissions[resource as keyof typeof permissions][action as keyof typeof permissions[keyof typeof permissions]]).toBe(false); + } + } + }); + + it('should allow OWNER to delete if isOwner is true', () => { + const permissions = getPermissions(admin, true); // Here isOwner is true + + expect(permissions.Vouchers.DELETE).toBe(true); + expect(permissions.Users.DELETE).toBe(true); + }); + + it('should not allow ADMIN to delete if isOwner is false', () => { + const permissions = getPermissions(admin, false); + + expect(permissions.Vouchers.DELETE).toBe(false); + expect(permissions.Users.DELETE).toBe(false); + }); + + it('should return false if no user is provided', () => { + const result = hasPermission(null, false, 'Vouchers', 'DELETE'); + expect(result).toBe(false); + }); + + it('should return false if action is not defined', () => { + const permissions = getPermissions(admin, false); + expect(permissions.Users['NON_EXISTENT_ACTION' as keyof typeof permissions.Users]).toBeUndefined(); + }); +}); diff --git a/src/components/pools/forms/update-pool-form.tsx b/src/components/pools/forms/update-pool-form.tsx index d837d21..c274177 100644 --- a/src/components/pools/forms/update-pool-form.tsx +++ b/src/components/pools/forms/update-pool-form.tsx @@ -11,7 +11,8 @@ import { TextAreaField } from "~/components/forms/fields/textarea-field"; import { Loading } from "~/components/loading"; import { Button } from "~/components/ui/button"; import { Form } from "~/components/ui/form"; -import { useAuth } from "~/hooks/useAuth"; +import { Authorization } from "~/hooks/useAuth"; +import { useIsOwner } from "~/hooks/useIsOwner"; import { api } from "~/utils/api"; const updatePoolSchema = z.object({ @@ -41,6 +42,7 @@ export function UpdatePoolForm({ poolTags: poolTags ?? [], }, }); + const isOwner = useIsOwner(address); const utils = api.useUtils(); const router = useRouter(); const update = api.pool.update.useMutation({ @@ -59,7 +61,6 @@ export function UpdatePoolForm({ router.push("/pools").catch(console.error); }, }); - const auth = useAuth(); const { data: tags } = api.tags.list.useQuery(); const createTag = api.tags.create.useMutation(); const onSubmit = async (data: z.infer) => { @@ -115,14 +116,14 @@ export function UpdatePoolForm({ > {update.isPending || remove.isPending ? : "Update"} - {auth?.isAdmin && address && ( + remove.mutate(address)} /> - )} + diff --git a/src/components/products/product-form.tsx b/src/components/products/product-form.tsx index 9a607ef..393165e 100644 --- a/src/components/products/product-form.tsx +++ b/src/components/products/product-form.tsx @@ -1,5 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { FormProvider, useForm } from "react-hook-form"; +import { Authorization } from "~/hooks/useAuth"; import { type Override } from "~/utils/type-helpers"; import AreYouSureDialog from "../dialogs/are-you-sure"; import { InputField } from "../forms/fields/input-field"; @@ -13,6 +14,7 @@ import { } from "./schema"; interface ProductFormProps { + isOwner: boolean; loading: boolean; onCreate: (data: Omit) => Promise; onUpdate: (data: UpdateProductListingInput) => Promise; @@ -21,6 +23,7 @@ interface ProductFormProps { } export const ProductForm = ({ + isOwner, onCreate, onUpdate, onDelete, @@ -38,7 +41,6 @@ export const ProductForm = ({ const { handleSubmit } = form; const onSubmit = async (data: UpdateProductListingInput) => { - console.log("first"); if (product?.id) { await onUpdate({ ...data, id: product.id }); } else { @@ -100,14 +102,16 @@ export const ProductForm = ({ > {loading ? : product?.id ? "Update" : "Create"} - {product && ( - onDelete(product.id)} - /> - )} + + {product && ( + onDelete(product.id)} + /> + )} + diff --git a/src/components/products/product-list.tsx b/src/components/products/product-list.tsx index e92c0da..8e8a4b0 100644 --- a/src/components/products/product-list.tsx +++ b/src/components/products/product-list.tsx @@ -13,15 +13,16 @@ import { type InsertProductListingInput, type UpdateProductListingInput, } from "./schema"; +import { Authorization } from "~/hooks/useAuth"; export const ProductList = ({ voucher_id, className, - canEdit, + isOwner, }: { voucher_id: number; className?: string; - canEdit?: boolean; + isOwner: boolean; }) => { const [selectedProduct, setSelectedProduct] = useState< RouterOutput["voucher"]["commodities"][0] | null @@ -39,6 +40,7 @@ export const ProductList = ({ const deleteMutation = api.products.remove.useMutation(); const utils = api.useUtils(); + const handleDelete = async (id: number) => { try { await deleteMutation.mutateAsync({ id }); @@ -77,7 +79,7 @@ export const ProductList = ({

Products

- {Boolean(canEdit) && ( + @@ -97,6 +99,7 @@ export const ProductList = ({ onCreate={handleCreate} onUpdate={handleUpdate} onDelete={handleDelete} + isOwner={isOwner} loading={ insertMutation.isPending || updateMutation.isPending || @@ -104,7 +107,7 @@ export const ProductList = ({ } /> - )} + {products && products.length === 0 ? ( diff --git a/src/components/users/forms/profile-form.tsx b/src/components/users/forms/profile-form.tsx index fa20750..43e2b0e 100644 --- a/src/components/users/forms/profile-form.tsx +++ b/src/components/users/forms/profile-form.tsx @@ -10,6 +10,9 @@ import { MapField } from "../../forms/fields/map-field"; import { Loading } from "../../loading"; import { Button } from "../../ui/button"; import { Form } from "../../ui/form"; +import { Authorization } from "~/hooks/useAuth"; +import { SelectField } from "~/components/forms/fields/select-field"; +import { AccountRoleType } from "~/server/enums"; const VPA_PATTERN = /^[a-zA-Z0-9]+@[a-zA-Z]+$/; @@ -28,6 +31,7 @@ export const UserProfileFormSchema = z.object({ given_names: z.string().trim().nullable(), location_name: z.string().trim().max(64).nullable(), default_voucher: z.string().nullable(), + account_role: z.nativeEnum(AccountRoleType).nullable(), geo: z .object({ x: z.number(), @@ -138,6 +142,17 @@ export const ProfileForm = (props: ProfileFormProps) => { label="Year of Birth" disabled={props.viewOnly} /> + + ({ + value: value, + label: value, + }))} + /> +
{ search: "", interfaceType: [], gasGiftStatus: [], + accountRole: [], }, }); const onValid = (data: UsersFilterFormData) => { @@ -71,6 +73,16 @@ export const UserFilterForm = (props: UsersFilterFormProps) => { }))} className="flex-1 min-w-[200px]" /> + ({ + value: value, + label: value, + }))} + className="flex-1 min-w-[200px]" + /> { const { data: status, isLoading } = api.gas.get.useQuery({ address, @@ -35,25 +37,29 @@ const StaffGasApproval = ({ address }: { address: `0x${string}` }) => { )}
-
- - -
+ +
+ + +
+
); }; diff --git a/src/components/users/tables/staff-users-table.tsx b/src/components/users/tables/staff-users-table.tsx index 3db3ed5..2740514 100644 --- a/src/components/users/tables/staff-users-table.tsx +++ b/src/components/users/tables/staff-users-table.tsx @@ -82,6 +82,10 @@ export function StaffUsersTable() { info.getValue() ), }, + { + header: "Role", + accessorKey: "account_role", + }, { header: "Given Names", accessorKey: "given_names", diff --git a/src/components/voucher/forms/update-voucher-form.tsx b/src/components/voucher/forms/update-voucher-form.tsx index 84511e4..0d18884 100644 --- a/src/components/voucher/forms/update-voucher-form.tsx +++ b/src/components/voucher/forms/update-voucher-form.tsx @@ -17,6 +17,7 @@ import { useIsOwner } from "~/hooks/useIsOwner"; import { type RouterOutput } from "~/server/api/root"; import { type UpdateVoucherInput } from "~/server/api/routers/voucher"; import { api } from "~/utils/api"; +import { hasPermission } from "~/utils/permissions"; // Form validation schema const formSchema = z.object({ @@ -50,8 +51,8 @@ const UpdateVoucherForm = ({ onSuccess, voucher }: UpdateFormProps) => { const isPending = update.isPending || remove.isPending; const isOwner = useIsOwner(voucher?.voucher_address as string); - const canUpdate = isOwner || auth?.isStaff; - const canDelete = isOwner || auth?.isAdmin; + const canUpdate = hasPermission(auth?.user, isOwner, "Vouchers", "UPDATE"); + const canDelete = hasPermission(auth?.user, isOwner, "Vouchers", "DELETE"); const form = useForm>({ resolver: zodResolver(formSchema), diff --git a/src/contracts/helpers.tsx b/src/contracts/helpers.tsx index 23a1484..8a5e0fe 100644 --- a/src/contracts/helpers.tsx +++ b/src/contracts/helpers.tsx @@ -1,7 +1,7 @@ import { publicClient } from "~/lib/web3"; import { abi } from "./erc20-demurrage-token/contract"; -export const isOwner = async ( +export const getIsOwner = async ( address: `0x${string}`, voucherAddress: `0x${string}` ) => { diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index ac64ec2..d63369e 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -16,14 +16,23 @@ import { import { createSiweMessage, parseSiweMessage } from "viem/siwe"; import { useAccount, type Config, type UseAccountReturnType } from "wagmi"; import { type SessionData } from "~/lib/session"; -import { AccountRoleType } from "~/server/enums"; import { api } from "~/utils/api"; import { useSession } from "./useSession"; +import React, { type ReactNode } from "react"; +import { + hasPermission as checkPermission, + isAdmin, + isStaff, + isSuperAdmin, + type Permissions, +} from "~/utils/permissions"; + export type AuthContextType = { user: SessionData["user"]; adapter: ReturnType>; loading: boolean; + isSuperAdmin: boolean; isAdmin: boolean; isStaff: boolean; gasStatus: "APPROVED" | "REQUESTED" | "REJECTED" | "NONE" | undefined; @@ -150,12 +159,14 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { user: session.user, gasStatus: gasStatus, account: account, - isAdmin: session.user?.role === AccountRoleType.ADMIN, + isSuperAdmin: isSuperAdmin(session.user), + isAdmin: isAdmin(session.user) || isSuperAdmin(session.user), isStaff: - session.user?.role === AccountRoleType.STAFF || - session.user?.role === AccountRoleType.ADMIN, - adapter, + isStaff(session.user) || + isAdmin(session.user) || + isSuperAdmin(session.user), loading: session.isLoading, + adapter, }), [account, adapter, gasStatus, session.isLoading, session.user] ); @@ -171,6 +182,13 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { ); }; + +interface AuthorizationProps { + resource: T; + action: keyof Permissions[T]; + children: ReactNode; + isOwner?: boolean; +} export const useAuth = () => { const context = useContext(AuthContext); @@ -182,3 +200,17 @@ export const useAuth = () => { return context; }; + +export function Authorization({ + resource, + action, + children, + isOwner = false, +}: AuthorizationProps) { + const auth = useAuth(); + if (!auth?.user) return null; + if (!checkPermission(auth.user, isOwner, resource, action)) { + return null; + } + return <>{children}; +} diff --git a/src/pages/pools/[address].tsx b/src/pages/pools/[address].tsx index 8a20a03..ecaad07 100644 --- a/src/pages/pools/[address].tsx +++ b/src/pages/pools/[address].tsx @@ -30,7 +30,7 @@ import { PoolVoucherTable } from "~/components/pools/tables/pool-voucher-table"; import { Button } from "~/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { env } from "~/env"; -import { useAuth } from "~/hooks/useAuth"; +import { Authorization, useAuth } from "~/hooks/useAuth"; import { appRouter } from "~/server/api/root"; import { graphDB, indexerDB } from "~/server/db"; import { api } from "~/utils/api"; @@ -182,11 +182,11 @@ export default function PoolPage( Swaps Deposits Data - {(isOwner || auth?.isAdmin || auth?.isStaff) && ( + - )} +
diff --git a/src/pages/vouchers/[address]/index.tsx b/src/pages/vouchers/[address]/index.tsx index 3a6adfe..5d4a65f 100644 --- a/src/pages/vouchers/[address]/index.tsx +++ b/src/pages/vouchers/[address]/index.tsx @@ -35,7 +35,7 @@ import UpdateVoucherForm from "~/components/voucher/forms/update-voucher-form"; import { VoucherContractFunctions } from "~/components/voucher/voucher-contract-functions"; import { VoucherHoldersTable } from "~/components/voucher/voucher-holders-table"; import { env } from "~/env"; -import { useAuth } from "~/hooks/useAuth"; +import { Authorization } from "~/hooks/useAuth"; import { useIsMounted } from "~/hooks/useIsMounted"; import { useIsOwner } from "~/hooks/useIsOwner"; import { graphDB, indexerDB } from "~/server/db"; @@ -105,7 +105,6 @@ const to = new Date(); const VoucherPage = () => { const router = useRouter(); const voucher_address = router.query.address as `0x${string}`; - const auth = useAuth(); const { data: poolsRegistry } = useContractIndex( env.NEXT_PUBLIC_SWAP_POOL_INDEX_ADDRESS ); @@ -136,7 +135,6 @@ const VoucherPage = () => { to: to, }, }); - const canEdit = Boolean(auth && (auth.isAdmin || auth.isStaff || isOwner)); if (!voucher) return
Voucher not Found
; return ( @@ -183,11 +181,11 @@ const VoucherPage = () => { Data Transactions Holders - {canEdit && isMounted && ( + - )} +
@@ -232,9 +230,9 @@ const VoucherPage = () => {

Pool Memberships diff --git a/src/server/api/auth.ts b/src/server/api/auth.ts index 20e5e2f..e69de29 100644 --- a/src/server/api/auth.ts +++ b/src/server/api/auth.ts @@ -1,9 +0,0 @@ -import { type User } from "~/lib/session"; -import { AccountRoleType } from "../enums"; - -export const isAdmin = (user?: User) => { - return user?.role === AccountRoleType.ADMIN; -}; -export const isStaff = (user?: User) => { - return user?.role === AccountRoleType.STAFF; -}; diff --git a/src/server/api/routers/me.ts b/src/server/api/routers/me.ts index 861de84..9015fb4 100644 --- a/src/server/api/routers/me.ts +++ b/src/server/api/routers/me.ts @@ -4,6 +4,8 @@ import { UserProfileFormSchema } from "~/components/users/forms/profile-form"; import { authenticatedProcedure, createTRPCRouter } from "~/server/api/trpc"; import { GasGiftStatus } from "~/server/enums"; import { sendGasRequestedEmbed } from "../../discord"; +import { sql } from "kysely"; +import {type AccountRoleType } from "~/server/enums"; export const meRouter = createTRPCRouter({ get: authenticatedProcedure.query(async ({ ctx }) => { @@ -20,6 +22,7 @@ export const meRouter = createTRPCRouter({ ) .where("accounts.blockchain_address", "=", address) .select([ + sql`accounts.account_role`.as("account_role"), "personal_information.given_names", "personal_information.family_name", "personal_information.gender", @@ -41,44 +44,56 @@ export const meRouter = createTRPCRouter({ update: authenticatedProcedure .input(UserProfileFormSchema) - .mutation(async ({ ctx, input: { vpa, default_voucher, ...pi } }) => { - const address = ctx.session?.user?.account.blockchain_address; - if (!address) throw new Error("No user found"); - const user = await ctx.graphDB - .selectFrom("users") - .innerJoin("accounts", "users.id", "accounts.user_identifier") - .leftJoin("vpa", "accounts.id", "vpa.linked_account") - .where("accounts.blockchain_address", "=", address) - .select(["users.id as userId", "accounts.id as accountId", "vpa"]) - .executeTakeFirst(); - if (!user) throw new Error("No user found"); - await ctx.graphDB - .updateTable("personal_information") - .set(pi) - .where("user_identifier", "=", user.userId) - .execute(); - if (vpa && user.vpa) { + .mutation( + async ({ + ctx, + input: { vpa, default_voucher, account_role: _account_role, ...pi }, + }) => { + const address = ctx.session?.user?.account.blockchain_address; + if (!address) throw new Error("No user found"); + const user = await ctx.graphDB + .selectFrom("users") + .innerJoin("accounts", "users.id", "accounts.user_identifier") + .leftJoin("vpa", "accounts.id", "vpa.linked_account") + .where("accounts.blockchain_address", "=", address) + .select(["users.id as userId", "accounts.id as accountId", "vpa"]) + .executeTakeFirst(); + if (!user) throw new Error("No user found"); await ctx.graphDB - .updateTable("vpa") - .set({ vpa }) - .where("linked_account", "=", user.accountId) - .execute(); - } - if (user.accountId && default_voucher) { - await ctx.graphDB - .updateTable("accounts") - .set({ default_voucher }) - .where("id", "=", user.accountId) - .execute(); - } - if (vpa && !user.vpa) { - await ctx.graphDB - .insertInto("vpa") - .values({ vpa, linked_account: user.accountId }) + .updateTable("personal_information") + .set({ + year_of_birth: pi.year_of_birth, + family_name: pi.family_name, + given_names: pi.given_names, + location_name: pi.location_name, + geo: pi.geo, + }) + .where("user_identifier", "=", user.userId) .execute(); + if (vpa && user.vpa) { + await ctx.graphDB + .updateTable("vpa") + .set({ vpa }) + .where("linked_account", "=", user.accountId) + .execute(); + } + + if (user.accountId && default_voucher) { + await ctx.graphDB + .updateTable("accounts") + .set({ default_voucher }) + .where("id", "=", user.accountId) + .execute(); + } + if (vpa && !user.vpa) { + await ctx.graphDB + .insertInto("vpa") + .values({ vpa, linked_account: user.accountId }) + .execute(); + } + return true; } - return true; - }), + ), vouchers: authenticatedProcedure.query(async ({ ctx }) => { const address = ctx.session?.user?.account.blockchain_address; if (!address || !isAddress(address)) { diff --git a/src/server/api/routers/pool.ts b/src/server/api/routers/pool.ts index 181c1f1..892ffec 100644 --- a/src/server/api/routers/pool.ts +++ b/src/server/api/routers/pool.ts @@ -3,18 +3,18 @@ import { isAddress } from "viem"; import { z } from "zod"; import { PoolIndex } from "~/contracts"; import { TokenIndex } from "~/contracts/erc20-token-index"; +import { getIsOwner } from "~/contracts/helpers"; import { Limiter } from "~/contracts/limiter"; import { PriceIndexQuote } from "~/contracts/price-index-quote"; import { SwapPool } from "~/contracts/swap-pool"; import { publicClient } from "~/lib/web3"; import { - adminProcedure, authenticatedProcedure, createTRPCRouter, publicProcedure, } from "~/server/api/trpc"; import { sendNewPoolEmbed } from "~/server/discord"; -import { isAdmin, isStaff } from "../auth"; +import { hasPermission } from "~/utils/permissions"; export type GeneratorYieldType = { message: string; @@ -127,9 +127,20 @@ export const poolRouter = createTRPCRouter({ }; } }), - remove: adminProcedure + remove: authenticatedProcedure .input(z.string().refine(isAddress)) - .mutation(async ({ input }) => { + .mutation(async ({ ctx, input }) => { + const isContractOwner = await getIsOwner( + ctx.user.account.blockchain_address, + input + ); + const canDelete = hasPermission(ctx.user, isContractOwner, "Pools", "DELETE"); + if (!canDelete) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to delete this pool", + }); + } await PoolIndex.remove(input); return { message: "Pool removed successfully" }; }), @@ -223,13 +234,16 @@ export const poolRouter = createTRPCRouter({ }) ) .mutation(async ({ ctx, input }) => { - const pool = new SwapPool(input.address, publicClient); - const owner = await pool.getOwner(); - if ( - owner !== ctx.user.account.blockchain_address && - !(isAdmin(ctx.user) || isStaff(ctx.user)) - ) { - throw new Error("You are not allowed to update this pool"); + const isContractOwner = await getIsOwner( + ctx.user.account.blockchain_address, + input.address + ); + const canUpdate = hasPermission(ctx.user, isContractOwner, "Pools", "UPDATE"); + if (!canUpdate) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to update this pool", + }); } let db_pool = await ctx.graphDB .updateTable("swap_pools") diff --git a/src/server/api/routers/products.ts b/src/server/api/routers/products.ts index 770bda2..c0e731b 100644 --- a/src/server/api/routers/products.ts +++ b/src/server/api/routers/products.ts @@ -4,13 +4,13 @@ import { insertProductListingInput, updateProductListingInput, } from "~/components/products/schema"; +import { getIsOwner } from "~/contracts/helpers"; import { - adminProcedure, authenticatedProcedure, createTRPCRouter, publicProcedure, } from "~/server/api/trpc"; -import { isAdmin, isStaff } from "../auth"; +import { hasPermission } from "~/utils/permissions"; export const productsRouter = createTRPCRouter({ list: publicProcedure.query(({ ctx }) => { @@ -57,10 +57,28 @@ export const productsRouter = createTRPCRouter({ }); return productListing; }), - update: adminProcedure + update: authenticatedProcedure .input(updateProductListingInput) .mutation(async ({ ctx, input }) => { - const productListing = await ctx.graphDB + const { voucher_address } = await ctx.graphDB + .selectFrom("product_listings") + .leftJoin("vouchers", "product_listings.voucher", "vouchers.id") + .select("voucher_address") + .where("product_listings.id", "=", input.id) + .executeTakeFirstOrThrow(); + + const isContractOwner = await getIsOwner( + ctx.user.account.blockchain_address, + voucher_address as `0x${string}` + ); + if (!hasPermission(ctx.user, isContractOwner, "Products", "UPDATE")) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to update this product listing", + }); + } + + const updatedProductListing = await ctx.graphDB .updateTable("product_listings") .set({ commodity_name: input.commodity_name, @@ -80,7 +98,7 @@ export const productsRouter = createTRPCRouter({ cause: error, }); }); - return productListing; + return updatedProductListing; }), remove: authenticatedProcedure .input( @@ -89,20 +107,25 @@ export const productsRouter = createTRPCRouter({ }) ) .mutation(async ({ ctx, input }) => { - const productListing = await ctx.graphDB + const { voucher_address } = await ctx.graphDB .selectFrom("product_listings") - .select("account") - .where("id", "=", input.id) + .leftJoin("vouchers", "product_listings.voucher", "vouchers.id") + .select("voucher_address") + .where("product_listings.id", "=", input.id) .executeTakeFirstOrThrow(); - if ( - productListing.account !== ctx.user.account.id || - !(isStaff(ctx.user) || isAdmin(ctx.user)) - ) { + + const isContractOwner = await getIsOwner( + ctx.user.account.blockchain_address, + voucher_address as `0x${string}` + ); + + if (!hasPermission(ctx.user, isContractOwner, "Products", "DELETE")) { throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not authorized to delete this product listing", }); } + const transactionResult = await ctx.graphDB .transaction() .execute(async (trx) => { diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 3d7b5c4..d576896 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -6,7 +6,8 @@ import { createTRPCRouter, staffProcedure, } from "~/server/api/trpc"; -import { GasGiftStatus, InterfaceType } from "~/server/enums"; +import { AccountRoleType, GasGiftStatus, InterfaceType } from "~/server/enums"; +import { hasPermission } from "~/utils/permissions"; import { isPhoneNumber, normalizePhoneNumber } from "~/utils/phone-number"; export const userRouter = createTRPCRouter({ @@ -25,6 +26,7 @@ export const userRouter = createTRPCRouter({ "users.id as userId", "accounts.id as accountId", "default_voucher", + "account_role", ]) .executeTakeFirst(); if (!user) throw new Error("No user found"); @@ -45,7 +47,12 @@ export const userRouter = createTRPCRouter({ .where("linked_account", "=", user.accountId) .select("vpa") .executeTakeFirst(); - return { ...vpa, ...info, default_voucher: user.default_voucher }; + return { + ...vpa, + ...info, + default_voucher: user.default_voucher, + account_role: user.account_role as keyof typeof AccountRoleType, + }; }), update: staffProcedure .input( @@ -58,7 +65,7 @@ export const userRouter = createTRPCRouter({ async ({ ctx, input: { - data: { vpa: _vpa, default_voucher, ...pi }, + data: { vpa: _vpa, default_voucher, account_role, ...pi }, address, }, }) => { @@ -74,6 +81,16 @@ export const userRouter = createTRPCRouter({ .set(pi) .where("user_identifier", "=", user.userId) .execute(); + if ( + account_role && + hasPermission(ctx.session?.user, false, "Users", "UPDATE_ROLE") + ) { + await ctx.graphDB + .updateTable("accounts") + .set({ account_role: account_role }) + .where("id", "=", user.accountId) + .execute(); + } await ctx.graphDB .updateTable("accounts") .set({ default_voucher: default_voucher }) @@ -88,6 +105,7 @@ export const userRouter = createTRPCRouter({ search: z.string().nullish(), interfaceType: z.array(z.nativeEnum(InterfaceType)).nullish(), gasGiftStatus: z.array(z.nativeEnum(GasGiftStatus)).nullish(), + accountRole: z.array(z.nativeEnum(AccountRoleType)).nullish(), limit: z.number().min(1).nullish(), cursor: z.number().nullish(), }) @@ -117,6 +135,9 @@ export const userRouter = createTRPCRouter({ ]) ); } + if (input?.accountRole && input.accountRole.length > 0) { + query = query.where("accounts.account_role", "in", input.accountRole); + } if (input?.gasGiftStatus && input.gasGiftStatus.length > 0) { query = query.where( "accounts.gas_gift_status", diff --git a/src/server/api/routers/voucher.ts b/src/server/api/routers/voucher.ts index 41b5bbd..75c5679 100644 --- a/src/server/api/routers/voucher.ts +++ b/src/server/api/routers/voucher.ts @@ -3,7 +3,7 @@ import { getAddress, isAddress } from "viem"; import { z } from "zod"; import { schemas } from "~/components/voucher/forms/create-voucher-form/schemas"; import { VoucherIndex } from "~/contracts"; -import { isOwner } from "~/contracts/helpers"; +import { getIsOwner } from "~/contracts/helpers"; import { authenticatedProcedure, createTRPCRouter, @@ -11,7 +11,7 @@ import { } from "~/server/api/trpc"; import { sendVoucherEmbed } from "~/server/discord"; import { AccountRoleType, CommodityType, VoucherType } from "~/server/enums"; -import { isAdmin, isStaff } from "../auth"; +import { getPermissions } from "~/utils/permissions"; const insertVoucherInput = z.object({ ...schemas, @@ -52,11 +52,12 @@ export const voucherRouter = createTRPCRouter({ }) ) .mutation(async ({ ctx, input }) => { - const isContractOwner = await isOwner( + const isContractOwner = await getIsOwner( ctx.user.account.blockchain_address, input.voucherAddress ); - const canDelete = isAdmin(ctx.user) || isContractOwner; + const canDelete = getPermissions(ctx.user, isContractOwner).Vouchers + .DELETE; if (!canDelete) { throw new Error("You are not allowed to remove this voucher"); } @@ -230,7 +231,7 @@ export const voucherRouter = createTRPCRouter({ message: `You must be logged in to deploy a voucher`, }); } - const internal = ctx.session.user.role === AccountRoleType.ADMIN; + const internal = ctx.session.user.role !== AccountRoleType.USER; const voucher = await ctx.graphDB.transaction().execute(async (trx) => { // Create Voucher in DB @@ -325,12 +326,13 @@ export const voucherRouter = createTRPCRouter({ update: authenticatedProcedure .input(updateVoucherInput) .mutation(async ({ ctx, input }) => { - const isContractOwner = await isOwner( + const isContractOwner = await getIsOwner( ctx.user.account.blockchain_address, input.voucherAddress ); - const canEdit = isAdmin(ctx.user) || isStaff(ctx.user) || isContractOwner; - if (!canEdit) { + const canUpdate = getPermissions(ctx.user, isContractOwner).Vouchers + .UPDATE; + if (!canUpdate) { throw new Error("You are not allowed to update this voucher"); } const voucher = await ctx.graphDB diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 7ffea80..2e8411b 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -12,8 +12,8 @@ import { getIronSession, type IronSession } from "iron-session"; import { ZodError } from "zod"; import { sessionOptions, type SessionData } from "~/lib/session"; import { graphDB, indexerDB } from "~/server/db"; +import { isAdmin, isStaff, isSuperAdmin } from "~/utils/permissions"; import SuperJson from "~/utils/trpc-transformer"; -import { isAdmin, isStaff } from "./auth"; /** * 1. CONTEXT * @@ -113,23 +113,13 @@ export const publicProcedure = t.procedure; export const middleware = t.middleware; -const isAdminMiddleware = middleware(async (opts) => { - const { ctx } = opts; - if (isAdmin(ctx.session?.user)) { - return opts.next({ - ctx: { - ...ctx, - user: ctx.session!.user, - }, - }); - } else { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } -}); - const isStaffMiddleware = middleware(async (opts) => { const { ctx } = opts; - if (isAdmin(ctx.session?.user) || isStaff(ctx.session?.user)) { + if ( + isAdmin(ctx.session?.user) || + isStaff(ctx.session?.user) || + isSuperAdmin(ctx.session?.user) + ) { return opts.next({ ctx: { ...ctx, @@ -154,9 +144,7 @@ const isAuthenticatedMiddleware = middleware(async (opts) => { }, }); }); -export const adminProcedure = publicProcedure.use(isAdminMiddleware); +export const staffProcedure = publicProcedure.use(isStaffMiddleware); export const authenticatedProcedure = publicProcedure.use( isAuthenticatedMiddleware ); - -export const staffProcedure = publicProcedure.use(isStaffMiddleware); diff --git a/src/server/enums.ts b/src/server/enums.ts index 2229018..fe92e47 100644 --- a/src/server/enums.ts +++ b/src/server/enums.ts @@ -7,6 +7,7 @@ export const VoucherType = { GIFTABLE: "GIFTABLE", }; export const AccountRoleType = { + SUPER_ADMIN: "SUPER_ADMIN", ADMIN: "ADMIN", STAFF: "STAFF", USER: "USER", diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts new file mode 100644 index 0000000..ff940f0 --- /dev/null +++ b/src/utils/permissions.ts @@ -0,0 +1,106 @@ +import { AccountRoleType } from "~/server/enums"; +type Role = "SUPER_ADMIN" | "ADMIN" | "OWNER" | "STAFF" | "USER"; + +interface User { + role: "SUPER_ADMIN" | "ADMIN" | "STAFF" | "USER"; +} + +export const permissions = { + Vouchers: { + UPDATE: ["SUPER_ADMIN", "ADMIN", "OWNER"], + DELETE: ["SUPER_ADMIN", "OWNER"], + }, + Pools: { + UPDATE: ["SUPER_ADMIN", "ADMIN", "OWNER"], + DELETE: ["SUPER_ADMIN", "OWNER"], + }, + Users: { + UPDATE: ["SUPER_ADMIN", "ADMIN", "OWNER"], + DELETE: ["SUPER_ADMIN", "OWNER"], + UPDATE_ROLE: ["SUPER_ADMIN"], + VIEW_PII: ["SUPER_ADMIN", "ADMIN", "OWNER"], + }, + Products: { + DELETE: ["SUPER_ADMIN", "OWNER"], + UPDATE: ["SUPER_ADMIN", "ADMIN", "STAFF", "OWNER"], + }, + Gas: { + APPROVE: ["SUPER_ADMIN", "ADMIN", "STAFF"], + }, +} as const; + +export type Permissions = typeof permissions; + +export function getPermissions( + user: User | null, + isOwner: boolean +): { + [K in keyof Permissions]: { + [A in keyof Permissions[K]]: boolean; + }; +} { + const resources = Object.keys(permissions) as (keyof typeof permissions)[]; + + const userPermissions = resources.reduce( + (acc, resource) => { + const actions = Object.keys( + permissions[resource] + ) as (keyof (typeof permissions)[typeof resource])[]; + + const resourcePermissions = actions.reduce( + (resourceAcc, action) => { + return { + ...resourceAcc, + [action]: hasPermission(user, isOwner, resource, action), + }; + }, + {} as Record + ); + + return { + ...acc, + [resource]: resourcePermissions, + }; + }, + {} as { + [K in keyof Permissions]: { + [A in keyof Permissions[K]]: boolean; + }; + } + ); + + return userPermissions; +} + +export function hasPermission( + user: User | null | undefined, + isOwner: boolean, + resource: T, + action: keyof (typeof permissions)[T] +): boolean { + // Return false if no user is provided + if (!user) return false; + + // Retrieve the allowed roles for the given resource and action + const allowedRoles = permissions[resource]?.[action] as Role[] | undefined; + + // If no roles are defined for the action, return false + if (!allowedRoles) return false; + + // Check if the user's role or the owner status allows the action + return ( + allowedRoles.includes(user.role) || + (isOwner && allowedRoles.includes("OWNER")) + ); +} + +export const isAdmin = (user?: User) => { + return user?.role === AccountRoleType.ADMIN; +}; +export const isStaff = (user?: User) => { + return user?.role === AccountRoleType.STAFF; +}; + +export const isSuperAdmin = (user?: User) => { + return user?.role === AccountRoleType.SUPER_ADMIN; +};