diff --git a/server/routers/external.ts b/server/routers/external.ts index d7934f9e..19c57008 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -188,7 +188,7 @@ authenticated.get( ); authenticated.put( - "/resource/:resourceId/:ruleId", + "/resource/:resourceId/rule", verifyResourceAccess, verifyUserHasAction(ActionsEnum.createResourceRule), resource.createResourceRule @@ -200,14 +200,13 @@ authenticated.get( resource.listResourceRules ); authenticated.post( - "/resource/:resourceId/:ruleId", + "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResourceRule), resource.updateResourceRule ); - authenticated.delete( - "/resource/:resourceId/:ruleId", + "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResourceRule), resource.deleteResourceRule diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index 68578da2..f01ed115 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -11,13 +11,21 @@ import { fromError } from "zod-validation-error"; const createResourceRuleSchema = z .object({ - resourceId: z.number().int().positive(), action: z.enum(["ACCEPT", "DROP"]), match: z.enum(["CIDR", "PATH"]), value: z.string().min(1) }) .strict(); +const createResourceRuleParamsSchema = z + .object({ + resourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + export async function createResourceRule( req: Request, res: Response, @@ -34,7 +42,21 @@ export async function createResourceRule( ); } - const { resourceId, action, match, value } = parsedBody.data; + const { action, match, value } = parsedBody.data; + + const parsedParams = createResourceRuleParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; // Verify that the referenced resource exists const [resource] = await db @@ -76,4 +98,4 @@ export async function createResourceRule( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/routers/resource/deleteResourceRule.ts b/server/routers/resource/deleteResourceRule.ts index 9c19ff04..b562fc11 100644 --- a/server/routers/resource/deleteResourceRule.ts +++ b/server/routers/resource/deleteResourceRule.ts @@ -12,6 +12,10 @@ import { fromError } from "zod-validation-error"; const deleteResourceRuleSchema = z .object({ ruleId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()), + resourceId: z .string() .transform(Number) .pipe(z.number().int().positive()) diff --git a/server/routers/resource/listResourceRules.ts b/server/routers/resource/listResourceRules.ts index 02c774a6..3364aa4b 100644 --- a/server/routers/resource/listResourceRules.ts +++ b/server/routers/resource/listResourceRules.ts @@ -40,8 +40,7 @@ function queryResourceRules(resourceId: number) { resourceId: resourceRules.resourceId, action: resourceRules.action, match: resourceRules.match, - value: resourceRules.value, - resourceName: resources.name, + value: resourceRules.value }) .from(resourceRules) .leftJoin(resources, eq(resourceRules.resourceId, resources.resourceId)) diff --git a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx index 5506866e..51b147fe 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx @@ -99,6 +99,11 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { href: `/{orgId}/settings/resources/{resourceId}/authentication` // icon: , }); + sidebarNavItems.push({ + title: "Rules", + href: `/{orgId}/settings/resources/{resourceId}/rules` + // icon: , + }); } return ( diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx new file mode 100644 index 00000000..2398bf32 --- /dev/null +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -0,0 +1,452 @@ +"use client"; +import { useEffect, useState, use } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { AxiosResponse } from "axios"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { + ColumnDef, + getFilteredRowModel, + getSortedRowModel, + getPaginationRowModel, + getCoreRowModel, + useReactTable, + flexRender +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; +import { useToast } from "@app/hooks/useToast"; +import { useResourceContext } from "@app/hooks/useResourceContext"; +import { ArrayElement } from "@server/types/ArrayElement"; +import { formatAxiosError } from "@app/lib/api/formatAxiosError"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient } from "@app/lib/api"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionFooter +} from "@app/components/Settings"; +import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules"; + +// Schema for rule validation +const addRuleSchema = z.object({ + action: z.string(), + match: z.string(), + value: z.string() +}); + +type LocalRule = ArrayElement & { + new?: boolean; + updated?: boolean; +}; + +export default function ResourceRules(props: { + params: Promise<{ resourceId: number }>; +}) { + const params = use(props.params); + const { toast } = useToast(); + const { resource } = useResourceContext(); + const api = createApiClient(useEnvContext()); + const [rules, setRules] = useState([]); + const [rulesToRemove, setRulesToRemove] = useState([]); + const [loading, setLoading] = useState(false); + const [pageLoading, setPageLoading] = useState(true); + + const addRuleForm = useForm({ + resolver: zodResolver(addRuleSchema), + defaultValues: { + action: "ACCEPT", + match: "CIDR", + value: "" + } + }); + + useEffect(() => { + const fetchRules = async () => { + try { + const res = await api.get>( + `/resource/${params.resourceId}/rules` + ); + if (res.status === 200) { + setRules(res.data.data.rules); + } + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: "Failed to fetch rules", + description: formatAxiosError( + err, + "An error occurred while fetching rules" + ) + }); + } finally { + setPageLoading(false); + } + }; + fetchRules(); + }, []); + + async function addRule(data: z.infer) { + const isDuplicate = rules.some( + (rule) => + rule.action === data.action && + rule.match === data.match && + rule.value === data.value + ); + + if (isDuplicate) { + toast({ + variant: "destructive", + title: "Duplicate rule", + description: "A rule with these settings already exists" + }); + return; + } + + const newRule: LocalRule = { + ...data, + ruleId: new Date().getTime(), + new: true, + resourceId: resource.resourceId + }; + + setRules([...rules, newRule]); + addRuleForm.reset(); + } + + const removeRule = (ruleId: number) => { + setRules([...rules.filter((rule) => rule.ruleId !== ruleId)]); + if (!rules.find((rule) => rule.ruleId === ruleId)?.new) { + setRulesToRemove([...rulesToRemove, ruleId]); + } + }; + + async function updateRule(ruleId: number, data: Partial) { + setRules( + rules.map((rule) => + rule.ruleId === ruleId + ? { ...rule, ...data, updated: true } + : rule + ) + ); + } + + async function saveRules() { + try { + setLoading(true); + for (let rule of rules) { + const data = { + action: rule.action, + match: rule.match, + value: rule.value + }; + + if (rule.new) { + await api.put( + `/resource/${params.resourceId}/rule`, + data + ); + } else if (rule.updated) { + await api.post( + `/resource/${params.resourceId}/rule/${rule.ruleId}`, + data + ); + } + } + + for (const ruleId of rulesToRemove) { + await api.delete( + `/resource/${params.resourceId}/rule/${ruleId}` + ); + } + + setRules(rules.map(rule => ({ ...rule, new: false, updated: false }))); + setRulesToRemove([]); + + toast({ + title: "Rules updated", + description: "Rules updated successfully" + }); + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: "Operation failed", + description: formatAxiosError( + err, + "An error occurred during the save operation" + ) + }); + } + setLoading(false); + } + + const columns: ColumnDef[] = [ + { + accessorKey: "action", + header: "Action", + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "match", + header: "Match Type", + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "value", + header: "Value", + cell: ({ row }) => ( + + updateRule(row.original.ruleId, { + value: e.target.value + }) + } + /> + ) + }, + { + id: "actions", + cell: ({ row }) => ( +
+ +
+ ) + } + ]; + + const table = useReactTable({ + data: rules, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel() + }); + + if (pageLoading) { + return <>; + } + + return ( + + + + + Resource Rules Configuration + + + Configure rules to control access to your resource + + + +
+ +
+ ( + + Action + + + + + + )} + /> + ( + + Match Type + + + + + + )} + /> + ( + + Value + + + + + + Enter CIDR or path value based on match type + + + )} + /> +
+ +
+ + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No rules. Add a rule using the form. + + + )} + +
+
+
+ + + +
+
+ ); +} \ No newline at end of file