diff --git a/server/db/schema.ts b/server/db/schema.ts index 16d8ada2..3380cdbf 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -377,6 +377,8 @@ export const resourceRules = sqliteTable("resourceRules", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + priority: integer("priority").notNull(), action: text("action").notNull(), // ACCEPT, DROP match: text("match").notNull(), // CIDR, PATH, IP value: text("value").notNull() @@ -414,4 +416,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 +export type ResourceRule = InferSelectModel; diff --git a/server/schemas/subdomainSchema.ts b/server/lib/schemas.ts similarity index 99% rename from server/schemas/subdomainSchema.ts rename to server/lib/schemas.ts index 30ba2ddd..f4b7daf3 100644 --- a/server/schemas/subdomainSchema.ts +++ b/server/lib/schemas.ts @@ -8,3 +8,4 @@ export const subdomainSchema = z ) .min(1, "Subdomain must be at least 1 character long") .transform((val) => val.toLowerCase()); + diff --git a/server/lib/validators.ts b/server/lib/validators.ts new file mode 100644 index 00000000..ffe471bb --- /dev/null +++ b/server/lib/validators.ts @@ -0,0 +1,68 @@ +export 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; + }); +} + +export 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; + }); +} + +export 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; +} diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index a4a2944a..fc1c85f5 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1,36 +1,38 @@ -import HttpCode from "@server/types/HttpCode"; -import { NextFunction, Request, Response } from "express"; -import createHttpError from "http-errors"; -import { z } from "zod"; -import { fromError } from "zod-validation-error"; -import { response } from "@server/lib/response"; -import db from "@server/db"; -import { - resourceRules, - ResourceAccessToken, - ResourcePassword, - resourcePassword, - ResourcePincode, - resourcePincode, - resources, - sessions, - userOrgs, - users, - ResourceRule -} from "@server/db/schema"; -import { and, eq } from "drizzle-orm"; -import config from "@server/lib/config"; +import { generateSessionToken } from "@server/auth/sessions/app"; import { createResourceSession, serializeResourceSessionCookie, validateResourceSessionToken } from "@server/auth/sessions/resource"; -import { Resource, roleResources, userResources } from "@server/db/schema"; -import logger from "@server/logger"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; -import NodeCache from "node-cache"; -import { generateSessionToken } from "@server/auth/sessions/app"; +import db from "@server/db"; +import { + Resource, + ResourceAccessToken, + ResourcePassword, + resourcePassword, + ResourcePincode, + resourcePincode, + ResourceRule, + resourceRules, + resources, + roleResources, + sessions, + userOrgs, + userResources, + users +} from "@server/db/schema"; +import config from "@server/lib/config"; import { isIpInCidr } from "@server/lib/ip"; +import { response } from "@server/lib/response"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import NodeCache from "node-cache"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; // We'll see if this speeds anything up const cache = new NodeCache({ @@ -169,18 +171,16 @@ export async function verifyResourceSession( // otherwise its undefined and we pass } - const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`; + const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent( + resource.resourceId + )}?redirect=${encodeURIComponent(originalRequestURL)}`; // check for access token let validAccessToken: ResourceAccessToken | undefined; if (token) { const [accessTokenId, accessToken] = token.split("."); const { valid, error, tokenItem } = await verifyResourceAccessToken( - { - resource, - accessTokenId, - accessToken - } + { resource, accessTokenId, accessToken } ); if (error) { @@ -190,7 +190,9 @@ export async function verifyResourceSession( if (!valid) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( - `Resource access token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + `Resource access token is invalid. Resource ID: ${ + resource.resourceId + }. IP: ${clientIp}.` ); } } @@ -211,7 +213,9 @@ export async function verifyResourceSession( if (!sessions) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( - `Missing resource sessions. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + `Missing resource sessions. Resource ID: ${ + resource.resourceId + }. IP: ${clientIp}.` ); } return notAllowed(res); @@ -219,7 +223,9 @@ export async function verifyResourceSession( const resourceSessionToken = sessions[ - `${config.getRawConfig().server.session_cookie_name}${resource.ssl ? "_s" : ""}` + `${config.getRawConfig().server.session_cookie_name}${ + resource.ssl ? "_s" : "" + }` ]; if (resourceSessionToken) { @@ -242,7 +248,9 @@ export async function verifyResourceSession( ); if (config.getRawConfig().app.log_failed_attempts) { logger.info( - `Resource session is an exchange token. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + `Resource session is an exchange token. Resource ID: ${ + resource.resourceId + }. IP: ${clientIp}.` ); } return notAllowed(res); @@ -281,7 +289,9 @@ export async function verifyResourceSession( } if (resourceSession.userSessionId && sso) { - const userAccessCacheKey = `userAccess:${resourceSession.userSessionId}:${resource.resourceId}`; + const userAccessCacheKey = `userAccess:${ + resourceSession.userSessionId + }:${resource.resourceId}`; let isAllowed: boolean | undefined = cache.get(userAccessCacheKey); @@ -305,8 +315,8 @@ export async function verifyResourceSession( } } - // At this point we have checked all sessions, but since the access token is valid, we should allow access - // and create a new session. + // At this point we have checked all sessions, but since the access token is + // valid, we should allow access and create a new session. if (validAccessToken) { return await createAccessTokenSession( res, @@ -319,7 +329,9 @@ export async function verifyResourceSession( if (config.getRawConfig().app.log_failed_attempts) { logger.info( - `Resource access not allowed. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + `Resource access not allowed. Resource ID: ${ + resource.resourceId + }. IP: ${clientIp}.` ); } return notAllowed(res, redirectUrl); @@ -485,69 +497,123 @@ async function checkRules( return; } - let hasAcceptRule = false; + // sort rules by priority in ascending order + rules = rules.sort((a, b) => a.priority - b.priority); - // First pass: look for DROP rules for (const rule of rules) { + if (!rule.enabled) { + continue; + } + 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") + clientIp && + rule.match == "CIDR" && + isIpInCidr(clientIp, rule.value) ) { - 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 rule.action as any; + } else if (clientIp && rule.match == "IP" && clientIp == rule.value) { + return rule.action as any; + } else if ( + path && + rule.match == "PATH" && + isPathAllowed(rule.value, path) + ) { + return rule.action as any; } } return; } -function urlGlobToRegex(pattern: string): RegExp { - // Trim any leading or trailing slashes - pattern = pattern.replace(/^\/+|\/+$/g, ""); +function isPathAllowed(pattern: string, path: string): boolean { + logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`); - // Escape special regex characters except * - const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); + // Normalize and split paths into segments + const normalize = (p: string) => p.split("/").filter(Boolean); + const patternParts = normalize(pattern); + const pathParts = normalize(path); - // Replace * with regex pattern for any valid URL segment characters - const regexPattern = escapedPattern.replace(/\*/g, "[a-zA-Z0-9_-]+"); + logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`); + logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`); - // 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 + // Recursive function to try different wildcard matches + function matchSegments(patternIndex: number, pathIndex: number): boolean { + const indent = " ".repeat(pathIndex); // Indent based on recursion depth + const currentPatternPart = patternParts[patternIndex]; + const currentPathPart = pathParts[pathIndex]; + + logger.debug( + `${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"})` + ); + + // If we've consumed all pattern parts, we should have consumed all path parts + if (patternIndex >= patternParts.length) { + const result = pathIndex >= pathParts.length; + logger.debug( + `${indent}Reached end of pattern, remaining path: ${pathParts.slice(pathIndex).join("/")} -> ${result}` + ); + return result; + } + + // If we've consumed all path parts but still have pattern parts + if (pathIndex >= pathParts.length) { + // The only way this can match is if all remaining pattern parts are wildcards + const remainingPattern = patternParts.slice(patternIndex); + const result = remainingPattern.every((p) => p === "*"); + logger.debug( + `${indent}Reached end of path, remaining pattern: ${remainingPattern.join("/")} -> ${result}` + ); + return result; + } + + // For wildcards, try consuming different numbers of path segments + if (currentPatternPart === "*") { + logger.debug( + `${indent}Found wildcard at pattern index ${patternIndex}` + ); + + // Try consuming 0 segments (skip the wildcard) + logger.debug( + `${indent}Trying to skip wildcard (consume 0 segments)` + ); + if (matchSegments(patternIndex + 1, pathIndex)) { + logger.debug( + `${indent}Successfully matched by skipping wildcard` + ); + return true; + } + + // Try consuming current segment and recursively try rest + logger.debug( + `${indent}Trying to consume segment "${currentPathPart}" for wildcard` + ); + if (matchSegments(patternIndex, pathIndex + 1)) { + logger.debug( + `${indent}Successfully matched by consuming segment for wildcard` + ); + return true; + } + + logger.debug(`${indent}Failed to match wildcard`); + return false; + } + + // For regular segments, they must match exactly + if (currentPatternPart !== currentPathPart) { + logger.debug( + `${indent}Segment mismatch: "${currentPatternPart}" != "${currentPathPart}"` + ); + return false; + } + + logger.debug( + `${indent}Segments match: "${currentPatternPart}" = "${currentPathPart}"` + ); + // Move to next segments in both pattern and path + return matchSegments(patternIndex + 1, pathIndex + 1); + } + + const result = matchSegments(0, 0); + logger.debug(`Final result: ${result}`); + return result; +} diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 9f7fa1fb..39b07a57 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -17,7 +17,7 @@ import { eq, and } from "drizzle-orm"; import stoi from "@server/lib/stoi"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; -import { subdomainSchema } from "@server/schemas/subdomainSchema"; +import { subdomainSchema } from "@server/lib/schemas"; import config from "@server/lib/config"; const createResourceParamsSchema = z diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index 24b08fc9..304f4bd0 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -8,12 +8,19 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; const createResourceRuleSchema = z .object({ action: z.enum(["ACCEPT", "DROP"]), match: z.enum(["CIDR", "IP", "PATH"]), - value: z.string().min(1) + value: z.string().min(1), + priority: z.number().int(), + enabled: z.boolean().optional() }) .strict(); @@ -42,7 +49,7 @@ export async function createResourceRule( ); } - const { action, match, value } = parsedBody.data; + const { action, match, value, priority, enabled } = parsedBody.data; const parsedParams = createResourceRuleParamsSchema.safeParse( req.params @@ -74,6 +81,41 @@ export async function createResourceRule( ); } + if (!resource.http) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot create rule for non-http resource" + ) + ); + } + + if (match === "CIDR") { + if (!isValidCIDR(value)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid CIDR provided" + ) + ); + } + } else if (match === "IP") { + if (!isValidIP(value)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided") + ); + } + } else if (match === "PATH") { + if (!isValidUrlGlobPattern(value)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid URL glob pattern provided" + ) + ); + } + } + // Create the new resource rule const [newRule] = await db .insert(resourceRules) @@ -81,7 +123,9 @@ export async function createResourceRule( resourceId, action, match, - value + value, + priority, + enabled }) .returning(); diff --git a/server/routers/resource/listResourceRules.ts b/server/routers/resource/listResourceRules.ts index 3364aa4b..0d29bd99 100644 --- a/server/routers/resource/listResourceRules.ts +++ b/server/routers/resource/listResourceRules.ts @@ -40,12 +40,14 @@ function queryResourceRules(resourceId: number) { resourceId: resourceRules.resourceId, action: resourceRules.action, match: resourceRules.match, - value: resourceRules.value + value: resourceRules.value, + priority: resourceRules.priority, + enabled: resourceRules.enabled }) .from(resourceRules) .leftJoin(resources, eq(resourceRules.resourceId, resources.resourceId)) .where(eq(resourceRules.resourceId, resourceId)); - + return baseQuery; } @@ -71,7 +73,9 @@ export async function listResourceRules( } const { limit, offset } = parsedQuery.data; - const parsedParams = listResourceRulesParamsSchema.safeParse(req.params); + const parsedParams = listResourceRulesParamsSchema.safeParse( + req.params + ); if (!parsedParams.success) { return next( createHttpError( @@ -99,16 +103,19 @@ export async function listResourceRules( } 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); + let rulesList = await baseQuery.limit(limit).offset(offset); const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; + // sort rules list by the priority in ascending order + rulesList = rulesList.sort((a, b) => a.priority - b.priority); + return response(res, { data: { rules: rulesList, @@ -129,4 +136,4 @@ export async function listResourceRules( 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 cc48894b..e464b4c5 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -8,8 +8,8 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { subdomainSchema } from "@server/schemas/subdomainSchema"; import config from "@server/lib/config"; +import { subdomainSchema } from "@server/lib/schemas"; const updateResourceParamsSchema = z .object({ diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index 0eaacc03..ef23b318 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -8,14 +8,16 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; // Define Zod schema for request parameters validation const updateResourceRuleParamsSchema = z .object({ - ruleId: z - .string() - .transform(Number) - .pipe(z.number().int().positive()), + ruleId: z.string().transform(Number).pipe(z.number().int().positive()), resourceId: z .string() .transform(Number) @@ -28,7 +30,9 @@ const updateResourceRuleSchema = z .object({ action: z.enum(["ACCEPT", "DROP"]).optional(), match: z.enum(["CIDR", "IP", "PATH"]).optional(), - value: z.string().min(1).optional() + value: z.string().min(1).optional(), + priority: z.number().int(), + enabled: z.boolean().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -42,7 +46,9 @@ export async function updateResourceRule( ): Promise { try { // Validate path parameters - const parsedParams = updateResourceRuleParamsSchema.safeParse(req.params); + const parsedParams = updateResourceRuleParamsSchema.safeParse( + req.params + ); if (!parsedParams.success) { return next( createHttpError( @@ -82,6 +88,15 @@ export async function updateResourceRule( ); } + if (!resource.http) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot create rule for non-http resource" + ) + ); + } + // Verify that the rule exists and belongs to the specified resource const [existingRule] = await db .select() @@ -107,6 +122,40 @@ export async function updateResourceRule( ); } + const match = updateData.match || existingRule.match; + const { value } = updateData; + + if (value !== undefined) { + if (match === "CIDR") { + if (!isValidCIDR(value)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid CIDR provided" + ) + ); + } + } else if (match === "IP") { + if (!isValidIP(value)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid IP provided" + ) + ); + } + } else if (match === "PATH") { + if (!isValidUrlGlobPattern(value)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid URL glob pattern provided" + ) + ); + } + } + } + // Update the rule const [updatedRule] = await db .update(resourceRules) @@ -127,4 +176,4 @@ export async function updateResourceRule( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/setup/scripts/1.0.0-beta13.ts b/server/setup/scripts/1.0.0-beta13.ts index 26dac8f9..ea47db57 100644 --- a/server/setup/scripts/1.0.0-beta13.ts +++ b/server/setup/scripts/1.0.0-beta13.ts @@ -9,6 +9,8 @@ export default async function migration() { trx.run(sql`CREATE TABLE resourceRules ( ruleId integer PRIMARY KEY AUTOINCREMENT NOT NULL, resourceId integer NOT NULL, + priority integer NOT NULL, + enabled integer DEFAULT true NOT NULL, action text NOT NULL, match text NOT NULL, value text NOT NULL, diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index f62bf8fe..d27f8831 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -59,7 +59,7 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; -import { subdomainSchema } from "@server/schemas/subdomainSchema"; +import { subdomainSchema } from "@server/lib/schemas"; import Link from "next/link"; import { SquareArrowOutUpRight } from "lucide-react"; import CopyTextBox from "@app/components/CopyTextBox"; diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 43624e3f..301354a3 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -49,9 +49,8 @@ import { useOrgContext } from "@app/hooks/useOrgContext"; import CustomDomainInput from "../CustomDomainInput"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { subdomainSchema } from "@server/schemas/subdomainSchema"; +import { subdomainSchema } from "@server/lib/schemas"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; -import { pullEnv } from "@app/lib/pullEnv"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { Label } from "@app/components/ui/label"; diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index 5ee16461..7fc16b81 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -57,7 +57,7 @@ import { import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules"; import { SwitchInput } from "@app/components/SwitchInput"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { Check, InfoIcon, X } from "lucide-react"; +import { ArrowUpDown, Check, InfoIcon, X } from "lucide-react"; import { InfoSection, InfoSections, @@ -65,12 +65,19 @@ import { } from "@app/components/InfoSection"; import { Separator } from "@app/components/ui/separator"; import { InfoPopup } from "@app/components/ui/info-popup"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; +import { Switch } from "@app/components/ui/switch"; // Schema for rule validation const addRuleSchema = z.object({ action: z.string(), match: z.string(), - value: z.string() + value: z.string(), + priority: z.coerce.number().int().optional() }); type LocalRule = ArrayElement & { @@ -181,11 +188,23 @@ export default function ResourceRules(props: { return; } + // find the highest priority and add one + let priority = data.priority; + if (priority === undefined) { + priority = rules.reduce( + (acc, rule) => (rule.priority > acc ? rule.priority : acc), + 0 + ); + priority++; + } + const newRule: LocalRule = { ...data, ruleId: new Date().getTime(), new: true, - resourceId: resource.resourceId + resourceId: resource.resourceId, + priority, + enabled: true }; setRules([...rules, newRule]); @@ -255,7 +274,9 @@ export default function ResourceRules(props: { const data = { action: rule.action, match: rule.match, - value: rule.value + value: rule.value, + priority: rule.priority, + enabled: rule.enabled }; if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { @@ -289,6 +310,28 @@ export default function ResourceRules(props: { return; } + if (rule.priority === undefined) { + toast({ + variant: "destructive", + title: "Invalid Priority", + description: "Please enter a valid priority" + }); + setLoading(false); + return; + } + + // make sure no duplicate priorities + const priorities = rules.map((r) => r.priority); + if (priorities.length !== new Set(priorities).size) { + toast({ + variant: "destructive", + title: "Duplicate Priorities", + description: "Please enter unique priorities" + }); + setLoading(false); + return; + } + if (rule.new) { const res = await api.put( `/resource/${params.resourceId}/rule`, @@ -342,6 +385,50 @@ export default function ResourceRules(props: { } const columns: ColumnDef[] = [ + { + accessorKey: "priority", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => ( + { + const parsed = z.coerce + .number() + .int() + .optional() + .safeParse(e.target.value); + + if (!parsed.data) { + toast({ + variant: "destructive", + title: "Invalid IP", + description: "Please enter a valid priority" + }); + setLoading(false); + return; + } + + updateRule(row.original.ruleId, { + priority: parsed.data + }); + }} + /> + ) + }, { accessorKey: "action", header: "Action", @@ -400,6 +487,18 @@ export default function ResourceRules(props: { /> ) }, + { + accessorKey: "enabled", + header: "Enabled", + cell: ({ row }) => ( + + updateRule(row.original.ruleId, { enabled: val }) + } + /> + ) + }, { id: "actions", cell: ({ row }) => ( @@ -434,14 +533,14 @@ export default function ResourceRules(props: { 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. -

+
+

+ 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. +

+
Actions @@ -661,6 +760,9 @@ export default function ResourceRules(props: { +

+ Rules are evaluated by priority in ascending order. +