diff --git a/Makefile b/Makefile index da0ce742..793a3481 100644 --- a/Makefile +++ b/Makefile @@ -12,9 +12,6 @@ build-arm: build-x86: docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest . -build-x86-ecr: - docker buildx build --platform linux/amd64 -t 216989133116.dkr.ecr.us-east-1.amazonaws.com/pangolin:latest --push . - build: docker build -t fosrl/pangolin:latest . diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 8bc7b823..001b9a6c 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -51,13 +51,17 @@ export enum ActionsEnum { // removeUserAction = "removeUserAction", // removeUserSite = "removeUserSite", getOrgUser = "getOrgUser", - "setResourcePassword" = "setResourcePassword", - "setResourcePincode" = "setResourcePincode", - "setResourceWhitelist" = "setResourceWhitelist", - "getResourceWhitelist" = "getResourceWhitelist", - "generateAccessToken" = "generateAccessToken", - "deleteAcessToken" = "deleteAcessToken", - "listAccessTokens" = "listAccessTokens" + setResourcePassword = "setResourcePassword", + setResourcePincode = "setResourcePincode", + setResourceWhitelist = "setResourceWhitelist", + getResourceWhitelist = "getResourceWhitelist", + generateAccessToken = "generateAccessToken", + deleteAcessToken = "deleteAcessToken", + listAccessTokens = "listAccessTokens", + createResourceRule = "createResourceRule", + deleteResourceRule = "deleteResourceRule", + listResourceRules = "listResourceRules", + updateResourceRule = "updateResourceRule", } export async function checkUserActionPermission( diff --git a/server/db/schema.ts b/server/db/schema.ts index f44873d1..16d8ada2 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -54,7 +54,8 @@ export const resources = sqliteTable("resources", { emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" }) .notNull() .default(false), - isBaseDomain: integer("isBaseDomain", { mode: "boolean" }) + isBaseDomain: integer("isBaseDomain", { mode: "boolean" }), + applyRules: integer("applyRules", { mode: "boolean" }).notNull().default(false) }); export const targets = sqliteTable("targets", { @@ -371,6 +372,16 @@ export const versionMigrations = sqliteTable("versionMigrations", { executedAt: integer("executedAt").notNull() }); +export const resourceRules = sqliteTable("resourceRules", { + ruleId: integer("ruleId").primaryKey({ autoIncrement: true }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }), + action: text("action").notNull(), // ACCEPT, DROP + match: text("match").notNull(), // CIDR, PATH, IP + value: text("value").notNull() +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -403,3 +414,4 @@ export type ResourceOtp = InferSelectModel; export type ResourceAccessToken = InferSelectModel; export type ResourceWhitelist = InferSelectModel; export type VersionMigration = InferSelectModel; +export type ResourceRule = InferSelectModel; \ No newline at end of file diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 2f505ae1..20376f8e 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.0.0-beta.12"; +export const APP_VERSION = "1.0.0-beta.13"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 5830d805..a4a2944a 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -6,6 +6,7 @@ import { fromError } from "zod-validation-error"; import { response } from "@server/lib/response"; import db from "@server/db"; import { + resourceRules, ResourceAccessToken, ResourcePassword, resourcePassword, @@ -14,7 +15,8 @@ import { resources, sessions, userOrgs, - users + users, + ResourceRule } from "@server/db/schema"; import { and, eq } from "drizzle-orm"; import config from "@server/lib/config"; @@ -28,6 +30,7 @@ import logger from "@server/logger"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import NodeCache from "node-cache"; import { generateSessionToken } from "@server/auth/sessions/app"; +import { isIpInCidr } from "@server/lib/ip"; // We'll see if this speeds anything up const cache = new NodeCache({ @@ -79,6 +82,7 @@ export async function verifyResourceSession( host, originalRequestURL, requestIp, + path, accessToken: token } = parsedBody.data; @@ -146,6 +150,25 @@ export async function verifyResourceSession( return allowed(res); } + // check the rules + if (resource.applyRules) { + const action = await checkRules( + resource.resourceId, + clientIp, + path + ); + + if (action == "ACCEPT") { + logger.debug("Resource allowed by rule"); + return allowed(res); + } else if (action == "DROP") { + logger.debug("Resource denied by rule"); + return notAllowed(res); + } + + // otherwise its undefined and we pass + } + const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`; // check for access token @@ -438,3 +461,93 @@ async function isUserAllowedToAccessResource( return false; } + +async function checkRules( + resourceId: number, + clientIp: string | undefined, + path: string | undefined +): Promise<"ACCEPT" | "DROP" | undefined> { + const ruleCacheKey = `rules:${resourceId}`; + + let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey); + + if (!rules) { + rules = await db + .select() + .from(resourceRules) + .where(eq(resourceRules.resourceId, resourceId)); + + cache.set(ruleCacheKey, rules); + } + + if (rules.length === 0) { + logger.debug("No rules found for resource", resourceId); + return; + } + + let hasAcceptRule = false; + + // First pass: look for DROP rules + for (const rule of rules) { + if ( + (clientIp && + rule.match == "CIDR" && + isIpInCidr(clientIp, rule.value) && + rule.action === "DROP") || + (clientIp && + rule.match == "IP" && + clientIp == rule.value && + rule.action === "DROP") || + (path && + rule.match == "PATH" && + urlGlobToRegex(rule.value).test(path) && + rule.action === "DROP") + ) { + return "DROP"; + } + // Track if we see any ACCEPT rules for the second pass + if (rule.action === "ACCEPT") { + hasAcceptRule = true; + } + } + + // Second pass: only check ACCEPT rules if we found one and didn't find a DROP + if (hasAcceptRule) { + for (const rule of rules) { + if (rule.action !== "ACCEPT") continue; + + if ( + (clientIp && + rule.match == "CIDR" && + isIpInCidr(clientIp, rule.value)) || + (clientIp && + rule.match == "IP" && + clientIp == rule.value) || + (path && + rule.match == "PATH" && + urlGlobToRegex(rule.value).test(path)) + ) { + return "ACCEPT"; + } + } + } + + return; +} + +function urlGlobToRegex(pattern: string): RegExp { + // Trim any leading or trailing slashes + pattern = pattern.replace(/^\/+|\/+$/g, ""); + + // Escape special regex characters except * + const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); + + // Replace * with regex pattern for any valid URL segment characters + const regexPattern = escapedPattern.replace(/\*/g, "[a-zA-Z0-9_-]+"); + + // Create the final pattern that: + // 1. Optionally matches leading slash + // 2. Matches the pattern + // 3. Optionally matches trailing slash + return new RegExp(`^/?${regexPattern}/?$`); +} \ No newline at end of file diff --git a/server/routers/external.ts b/server/routers/external.ts index e74e0472..19c57008 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -186,6 +186,32 @@ authenticated.get( verifyUserHasAction(ActionsEnum.listTargets), target.listTargets ); + +authenticated.put( + "/resource/:resourceId/rule", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.createResourceRule), + resource.createResourceRule +); +authenticated.get( + "/resource/:resourceId/rules", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.listResourceRules), + resource.listResourceRules +); +authenticated.post( + "/resource/:resourceId/rule/:ruleId", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.updateResourceRule), + resource.updateResourceRule +); +authenticated.delete( + "/resource/:resourceId/rule/:ruleId", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.deleteResourceRule), + resource.deleteResourceRule +); + authenticated.get( "/target/:targetId", verifyTargetAccess, @@ -205,6 +231,7 @@ authenticated.delete( target.deleteTarget ); + authenticated.put( "/org/:orgId/role", verifyOrgAccess, diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts new file mode 100644 index 00000000..24b08fc9 --- /dev/null +++ b/server/routers/resource/createResourceRule.ts @@ -0,0 +1,101 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resourceRules, resources } from "@server/db/schema"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const createResourceRuleSchema = z + .object({ + action: z.enum(["ACCEPT", "DROP"]), + match: z.enum(["CIDR", "IP", "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, + next: NextFunction +): Promise { + try { + const parsedBody = createResourceRuleSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + 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 + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + // Create the new resource rule + const [newRule] = await db + .insert(resourceRules) + .values({ + resourceId, + action, + match, + value + }) + .returning(); + + return response(res, { + data: newRule, + success: true, + error: false, + message: "Resource rule created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/deleteResourceRule.ts b/server/routers/resource/deleteResourceRule.ts new file mode 100644 index 00000000..b562fc11 --- /dev/null +++ b/server/routers/resource/deleteResourceRule.ts @@ -0,0 +1,71 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resourceRules, resources } from "@server/db/schema"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +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()) + }) + .strict(); + +export async function deleteResourceRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = deleteResourceRuleSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { ruleId } = parsedParams.data; + + // Delete the rule and return the deleted record + const [deletedRule] = await db + .delete(resourceRules) + .where(eq(resourceRules.ruleId, ruleId)) + .returning(); + + if (!deletedRule) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource rule with ID ${ruleId} not found` + ) + ); + } + + return response(res, { + data: null, + success: true, + error: false, + message: "Resource rule deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 187d23fe..03c9ffbe 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -18,3 +18,7 @@ export * from "./authWithWhitelist"; export * from "./authWithAccessToken"; export * from "./transferResource"; export * from "./getExchangeToken"; +export * from "./createResourceRule"; +export * from "./deleteResourceRule"; +export * from "./listResourceRules"; +export * from "./updateResourceRule"; \ No newline at end of file diff --git a/server/routers/resource/listResourceRules.ts b/server/routers/resource/listResourceRules.ts new file mode 100644 index 00000000..3364aa4b --- /dev/null +++ b/server/routers/resource/listResourceRules.ts @@ -0,0 +1,132 @@ +import { db } from "@server/db"; +import { resourceRules, resources } from "@server/db/schema"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { eq, sql } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; + +const listResourceRulesParamsSchema = z + .object({ + resourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +const listResourceRulesSchema = z.object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +function queryResourceRules(resourceId: number) { + let baseQuery = db + .select({ + ruleId: resourceRules.ruleId, + resourceId: resourceRules.resourceId, + action: resourceRules.action, + match: resourceRules.match, + value: resourceRules.value + }) + .from(resourceRules) + .leftJoin(resources, eq(resourceRules.resourceId, resources.resourceId)) + .where(eq(resourceRules.resourceId, resourceId)); + + return baseQuery; +} + +export type ListResourceRulesResponse = { + rules: Awaited>; + pagination: { total: number; limit: number; offset: number }; +}; + +export async function listResourceRules( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listResourceRulesSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const parsedParams = listResourceRulesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + const { resourceId } = parsedParams.data; + + // Verify the resource exists + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + const baseQuery = queryResourceRules(resourceId); + + let countQuery = db + .select({ count: sql`cast(count(*) as integer)` }) + .from(resourceRules) + .where(eq(resourceRules.resourceId, resourceId)); + + const rulesList = await baseQuery.limit(limit).offset(offset); + const totalCountResult = await countQuery; + const totalCount = totalCountResult[0].count; + + return response(res, { + data: { + rules: rulesList, + pagination: { + total: totalCount, + limit, + offset + } + }, + success: true, + error: false, + message: "Resource rules retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index f7dcf6ea..cc48894b 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -29,7 +29,8 @@ const updateResourceBodySchema = z blockAccess: z.boolean().optional(), proxyPort: z.number().int().min(1).max(65535).optional(), emailWhitelistEnabled: z.boolean().optional(), - isBaseDomain: z.boolean().optional() + isBaseDomain: z.boolean().optional(), + applyRules: z.boolean().optional(), }) .strict() .refine((data) => Object.keys(data).length > 0, { diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts new file mode 100644 index 00000000..0eaacc03 --- /dev/null +++ b/server/routers/resource/updateResourceRule.ts @@ -0,0 +1,130 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resourceRules, resources } from "@server/db/schema"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +// Define Zod schema for request parameters validation +const updateResourceRuleParamsSchema = z + .object({ + ruleId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()), + resourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +// Define Zod schema for request body validation +const updateResourceRuleSchema = z + .object({ + action: z.enum(["ACCEPT", "DROP"]).optional(), + match: z.enum(["CIDR", "IP", "PATH"]).optional(), + value: z.string().min(1).optional() + }) + .strict() + .refine((data) => Object.keys(data).length > 0, { + message: "At least one field must be provided for update" + }); + +export async function updateResourceRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + // Validate path parameters + const parsedParams = updateResourceRuleParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + // Validate request body + const parsedBody = updateResourceRuleSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { ruleId, resourceId } = parsedParams.data; + const updateData = parsedBody.data; + + // Verify that the resource exists + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + // Verify that the rule exists and belongs to the specified resource + const [existingRule] = await db + .select() + .from(resourceRules) + .where(eq(resourceRules.ruleId, ruleId)) + .limit(1); + + if (!existingRule) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource rule with ID ${ruleId} not found` + ) + ); + } + + if (existingRule.resourceId !== resourceId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `Resource rule ${ruleId} does not belong to resource ${resourceId}` + ) + ); + } + + // Update the rule + const [updatedRule] = await db + .update(resourceRules) + .set(updateData) + .where(eq(resourceRules.ruleId, ruleId)) + .returning(); + + return response(res, { + data: updatedRule, + success: true, + error: false, + message: "Resource rule updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index 5581fc24..52a82ad4 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -14,6 +14,7 @@ import m5 from "./scripts/1.0.0-beta6"; import m6 from "./scripts/1.0.0-beta9"; import m7 from "./scripts/1.0.0-beta10"; import m8 from "./scripts/1.0.0-beta12"; +import m13 from "./scripts/1.0.0-beta13"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -27,7 +28,8 @@ const migrations = [ { version: "1.0.0-beta.6", run: m5 }, { version: "1.0.0-beta.9", run: m6 }, { version: "1.0.0-beta.10", run: m7 }, - { version: "1.0.0-beta.12", run: m8 } + { version: "1.0.0-beta.12", run: m8 }, + { version: "1.0.0-beta.13", run: m13 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scripts/1.0.0-beta13.ts b/server/setup/scripts/1.0.0-beta13.ts new file mode 100644 index 00000000..26dac8f9 --- /dev/null +++ b/server/setup/scripts/1.0.0-beta13.ts @@ -0,0 +1,29 @@ +import db from "@server/db"; +import { sql } from "drizzle-orm"; + +export default async function migration() { + console.log("Running setup script 1.0.0-beta.13..."); + + try { + db.transaction((trx) => { + trx.run(sql`CREATE TABLE resourceRules ( + ruleId integer PRIMARY KEY AUTOINCREMENT NOT NULL, + resourceId integer NOT NULL, + action text NOT NULL, + match text NOT NULL, + value text NOT NULL, + FOREIGN KEY (resourceId) REFERENCES resources(resourceId) ON UPDATE no action ON DELETE cascade + );`); + trx.run( + sql`ALTER TABLE resources ADD applyRules integer DEFAULT false NOT NULL;` + ); + }); + + console.log(`Added new table and column: resourceRules, applyRules`); + } catch (e) { + console.log("Unable to add new table and column: resourceRules, applyRules"); + throw e; + } + + console.log("Done."); +} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index 7cc21914..b3b26a5a 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -131,7 +131,7 @@ export default function ReverseProxyTargets(props: { resolver: zodResolver(addTargetSchema), defaultValues: { ip: "", - method: resource.http ? "http" : null, + method: resource.http ? "http" : null // protocol: "TCP", } as z.infer }); @@ -269,7 +269,7 @@ export default function ReverseProxyTargets(props: { >(`/resource/${params.resourceId}/target`, data); target.targetId = res.data.data.targetId; } else if (target.updated) { - const res = await api.post( + await api.post( `/target/${target.targetId}`, data ); @@ -290,7 +290,7 @@ export default function ReverseProxyTargets(props: { for (const targetId of targetsToRemove) { await api.delete(`/target/${targetId}`); setTargets( - targets.filter((target) => target.targetId !== targetId) + targets.filter((t) => t.targetId !== targetId) ); } @@ -316,17 +316,31 @@ export default function ReverseProxyTargets(props: { } async function saveSsl(val: boolean) { - const res = await api.post(`/resource/${params.resourceId}`, { - ssl: val - }); + const res = await api + .post(`/resource/${params.resourceId}`, { + ssl: val + }) + .catch((err) => { + console.error(err); + toast({ + variant: "destructive", + title: "Failed to update SSL configuration", + description: formatAxiosError( + err, + "An error occurred while updating the SSL configuration" + ) + }); + }); - setSslEnabled(val); - updateResource({ ssl: val }); + if (res && res.status === 200) { + setSslEnabled(val); + updateResource({ ssl: val }); - toast({ - title: "SSL Configuration", - description: "SSL configuration updated successfully" - }); + toast({ + title: "SSL Configuration", + description: "SSL configuration updated successfully" + }); + } } const columns: ColumnDef[] = [ @@ -652,7 +666,8 @@ export default function ReverseProxyTargets(props: {

- Adding more than one target above will enable load balancing. + Adding more than one target above will enable load + balancing.

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..6a961a6a --- /dev/null +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -0,0 +1,730 @@ +"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"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Check, Info, InfoIcon, X } from "lucide-react"; +import { + InfoSection, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { Separator } from "@app/components/ui/separator"; +import { InfoPopup } from "@app/components/ui/info-popup"; + +// Schema for rule validation +const addRuleSchema = z.object({ + action: z.string(), + match: z.string(), + value: z.string() +}); + +type LocalRule = ArrayElement & { + new?: boolean; + updated?: boolean; +}; + +enum RuleAction { + ACCEPT = "Always Allow", + DROP = "Always Deny" +} + +export default function ResourceRules(props: { + params: Promise<{ resourceId: number }>; +}) { + const params = use(props.params); + const { toast } = useToast(); + const { resource, updateResource } = useResourceContext(); + const api = createApiClient(useEnvContext()); + const [rules, setRules] = useState([]); + const [rulesToRemove, setRulesToRemove] = useState([]); + const [loading, setLoading] = useState(false); + const [pageLoading, setPageLoading] = useState(true); + const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules); + + const addRuleForm = useForm({ + resolver: zodResolver(addRuleSchema), + defaultValues: { + action: "ACCEPT", + match: "IP", + value: "" + } + }); + + useEffect(() => { + const fetchRules = async () => { + try { + const res = await api.get< + AxiosResponse + >(`/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; + } + + if (data.match === "CIDR" && !isValidCIDR(data.value)) { + toast({ + variant: "destructive", + title: "Invalid CIDR", + description: "Please enter a valid CIDR value" + }); + setLoading(false); + return; + } + if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { + toast({ + variant: "destructive", + title: "Invalid URL path", + description: "Please enter a valid URL path value" + }); + setLoading(false); + return; + } + if (data.match === "IP" && !isValidIP(data.value)) { + toast({ + variant: "destructive", + title: "Invalid IP", + description: "Please enter a valid IP address" + }); + setLoading(false); + 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 saveApplyRules(val: boolean) { + const res = await api + .post(`/resource/${params.resourceId}`, { + applyRules: val + }) + .catch((err) => { + console.error(err); + toast({ + variant: "destructive", + title: "Failed to update rules", + description: formatAxiosError( + err, + "An error occurred while updating rules" + ) + }); + }); + + if (res && res.status === 200) { + setRulesEnabled(val); + updateResource({ applyRules: val }); + + toast({ + title: "Enable Rules", + description: "Rule evaluation has been updated" + }); + } + } + + async function saveRules() { + try { + setLoading(true); + for (let rule of rules) { + const data = { + action: rule.action, + match: rule.match, + value: rule.value + }; + + if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { + toast({ + variant: "destructive", + title: "Invalid CIDR", + description: "Please enter a valid CIDR value" + }); + setLoading(false); + return; + } + if ( + rule.match === "PATH" && + !isValidUrlGlobPattern(rule.value) + ) { + toast({ + variant: "destructive", + title: "Invalid URL path", + description: "Please enter a valid URL path value" + }); + setLoading(false); + return; + } + if (rule.match === "IP" && !isValidIP(rule.value)) { + toast({ + variant: "destructive", + title: "Invalid IP", + description: "Please enter a valid IP address" + }); + setLoading(false); + return; + } + + if (rule.new) { + const res = await api.put(`/resource/${params.resourceId}/rule`, data); + rule.ruleId = res.data.data.ruleId; + } else if (rule.updated) { + await api.post( + `/resource/${params.resourceId}/rule/${rule.ruleId}`, + data + ); + } + + setRules([ + ...rules.map((r) => { + let res = { + ...r, + new: false, + updated: false + }; + return res; + }) + ]); + } + + for (const ruleId of rulesToRemove) { + await api.delete( + `/resource/${params.resourceId}/rule/${ruleId}` + ); + setRules( + rules.filter((r) => r.ruleId !== ruleId) + ); + } + + toast({ + title: "Rules updated", + description: "Rules updated successfully" + }); + + setRulesToRemove([]); + } 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 ( + + + + About Rules + +

+ Rules allow you to control access to your resource based + on a set of criteria. You can create rules to allow or + deny access based on IP address or URL path. Deny rules + take precedence over allow rules. If a request matches + both an allow and a deny rule, the deny rule will be + applied. +

+ + + Actions +
    +
  • + + Always Allow: Bypass all authentication + methods +
  • +
  • + + Always Deny: Block all requests; no + authentication can be attempted +
  • +
+
+ + + + Matching Criteria + +
    +
  • + Match a specific IP address +
  • +
  • + Match a range of IP addresses in CIDR + notation +
  • +
  • + Match a URL path or pattern +
  • +
+
+
+
+
+ + + + Enable Rules + + Enable or disable rule evaluation for this resource + + + + { + await saveApplyRules(val); + }} + /> + + + + + + + Resource Rules Configuration + + + Configure rules to control access to your resource + + + +
+ +
+ ( + + Action + + + + + + )} + /> + ( + + 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. + + + )} + +
+
+
+ + + +
+
+ ); +} + +function isValidCIDR(cidr: string): boolean { + // Match CIDR pattern (e.g., "192.168.0.0/24") + const cidrPattern = + /^([0-9]{1,3}\.){3}[0-9]{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/; + + if (!cidrPattern.test(cidr)) { + return false; + } + + // Validate IP address part + const ipPart = cidr.split("/")[0]; + const octets = ipPart.split("."); + + return octets.every((octet) => { + const num = parseInt(octet, 10); + return num >= 0 && num <= 255; + }); +} + +function isValidIP(ip: string): boolean { + const ipPattern = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/; + + if (!ipPattern.test(ip)) { + return false; + } + + const octets = ip.split("."); + + return octets.every((octet) => { + const num = parseInt(octet, 10); + return num >= 0 && num <= 255; + }); +} + +function isValidUrlGlobPattern(pattern: string): boolean { + // Remove leading slash if present + pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern; + + // Empty string is not valid + if (!pattern) { + return false; + } + + // Split path into segments + const segments = pattern.split("/"); + + // Check each segment + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + // Empty segments are not allowed (double slashes) + if (!segment && i !== segments.length - 1) { + return false; + } + + // If segment contains *, it must be exactly * + if (segment.includes("*") && segment !== "*") { + return false; + } + + // Check for invalid characters + if (!/^[a-zA-Z0-9_*-]*$/.test(segment)) { + return false; + } + } + + return true; +}