Browse Source

rules server validation, enabled toggle, fix wildcard

Milo Schwartz 5 months ago
parent
commit
fdf1dfdeba

+ 3 - 1
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<typeof resourceOtp>;
 export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
 export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
 export type VersionMigration = InferSelectModel<typeof versionMigrations>;
-export type ResourceRule = InferSelectModel<typeof resourceRules>;
+export type ResourceRule = InferSelectModel<typeof resourceRules>;

+ 1 - 0
server/schemas/subdomainSchema.ts → 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());
+

+ 68 - 0
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;
+}

+ 152 - 86
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 { generateSessionToken } from "@server/auth/sessions/app";
+import {
+    createResourceSession,
+    serializeResourceSessionCookie,
+    validateResourceSessionToken
+} from "@server/auth/sessions/resource";
+import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
 import db from "@server/db";
 import {
-    resourceRules,
+    Resource,
     ResourceAccessToken,
     ResourcePassword,
     resourcePassword,
     ResourcePincode,
     resourcePincode,
+    ResourceRule,
+    resourceRules,
     resources,
+    roleResources,
     sessions,
     userOrgs,
-    users,
-    ResourceRule
+    userResources,
+    users
 } from "@server/db/schema";
-import { and, eq } from "drizzle-orm";
 import config from "@server/lib/config";
-import {
-    createResourceSession,
-    serializeResourceSessionCookie,
-    validateResourceSessionToken
-} from "@server/auth/sessions/resource";
-import { Resource, roleResources, userResources } from "@server/db/schema";
+import { isIpInCidr } from "@server/lib/ip";
+import { response } from "@server/lib/response";
 import logger from "@server/logger";
-import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
+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 { generateSessionToken } from "@server/auth/sessions/app";
-import { isIpInCidr } from "@server/lib/ip";
+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 (
-            (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";
+        if (!rule.enabled) {
+            continue;
         }
-        // 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";
-            }
+        if (
+            clientIp &&
+            rule.match == "CIDR" &&
+            isIpInCidr(clientIp, rule.value)
+        ) {
+            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}"`);
+
+    // Normalize and split paths into segments
+    const normalize = (p: string) => p.split("/").filter(Boolean);
+    const patternParts = normalize(pattern);
+    const pathParts = normalize(path);
+
+    logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`);
+    logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`);
+
+    // 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;
+            }
 
-    // Escape special regex characters except *
-    const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
+            logger.debug(`${indent}Failed to match wildcard`);
+            return false;
+        }
 
-    // Replace * with regex pattern for any valid URL segment characters
-    const regexPattern = escapedPattern.replace(/\*/g, "[a-zA-Z0-9_-]+");
+        // For regular segments, they must match exactly
+        if (currentPatternPart !== currentPathPart) {
+            logger.debug(
+                `${indent}Segment mismatch: "${currentPatternPart}" != "${currentPathPart}"`
+            );
+            return false;
+        }
 
-    // Create the final pattern that:
-    // 1. Optionally matches leading slash
-    // 2. Matches the pattern
-    // 3. Optionally matches trailing slash
-    return new RegExp(`^/?${regexPattern}/?$`);
-}
+        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;
+}

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

+ 47 - 3
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();
 

+ 13 - 6
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<number>`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<ListResourceRulesResponse>(res, {
             data: {
                 rules: rulesList,
@@ -129,4 +136,4 @@ export async function listResourceRules(
             createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
         );
     }
-}
+}

+ 1 - 1
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({

+ 56 - 7
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<any> {
     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")
         );
     }
-}
+}

+ 2 - 0
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,

+ 1 - 1
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";

+ 1 - 2
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";
 

+ 114 - 81
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<ListResourceRulesResponse["rules"]> & {
@@ -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<LocalRule>[] = [
+        {
+            accessorKey: "priority",
+            header: ({ column }) => {
+                return (
+                    <Button
+                        variant="ghost"
+                        onClick={() =>
+                            column.toggleSorting(column.getIsSorted() === "asc")
+                        }
+                    >
+                        Priority
+                        <ArrowUpDown className="ml-2 h-4 w-4" />
+                    </Button>
+                );
+            },
+            cell: ({ row }) => (
+                <Input
+                    defaultValue={row.original.priority}
+                    className="w-[75px]"
+                    type="number"
+                    onBlur={(e) => {
+                        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 }) => (
+                <Switch
+                    defaultChecked={row.original.enabled}
+                    onCheckedChange={(val) =>
+                        updateRule(row.original.ruleId, { enabled: val })
+                    }
+                />
+            )
+        },
         {
             id: "actions",
             cell: ({ row }) => (
@@ -434,14 +533,14 @@ export default function ResourceRules(props: {
                 <InfoIcon className="h-4 w-4" />
                 <AlertTitle className="font-semibold">About Rules</AlertTitle>
                 <AlertDescription className="mt-4">
-                    <p className="mb-4">
-                        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.
-                    </p>
+                    <div className="space-y-1 mb-4">
+                        <p>
+                            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.
+                        </p>
+                    </div>
                     <InfoSections>
                         <InfoSection>
                             <InfoSectionTitle>Actions</InfoSectionTitle>
@@ -661,6 +760,9 @@ export default function ResourceRules(props: {
                             </TableBody>
                         </Table>
                     </TableContainer>
+                    <p className="text-sm text-muted-foreground">
+                        Rules are evaluated by priority in ascending order.
+                    </p>
                 </SettingsSectionBody>
                 <SettingsSectionFooter>
                     <Button
@@ -675,72 +777,3 @@ export default function ResourceRules(props: {
         </SettingsContainer>
     );
 }
-
-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;
-}