Browse Source

Merge pull request #185 from fosrl/rules

Rules
Milo Schwartz 5 months ago
parent
commit
6fba13c8d1

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

+ 11 - 7
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(

+ 13 - 1
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<typeof orgs>;
 export type User = InferSelectModel<typeof users>;
 export type Site = InferSelectModel<typeof sites>;
@@ -403,3 +414,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>;

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

+ 114 - 1
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}/?$`);
+}

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

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

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

+ 4 - 0
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";

+ 132 - 0
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<ReturnType<typeof queryResourceRules>>;
+    pagination: { total: number; limit: number; offset: number };
+};
+
+export async function listResourceRules(
+    req: Request,
+    res: Response,
+    next: NextFunction
+): Promise<any> {
+    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<number>`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<ListResourceRulesResponse>(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")
+        );
+    }
+}

+ 2 - 1
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, {

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

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

+ 29 - 0
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.");
+}

+ 28 - 13
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<typeof addTargetSchema>
     });
@@ -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<LocalTarget>[] = [
@@ -652,7 +666,8 @@ export default function ReverseProxyTargets(props: {
                         </Table>
                     </TableContainer>
                     <p className="text-sm text-muted-foreground">
-                        Adding more than one target above will enable load balancing.
+                        Adding more than one target above will enable load
+                        balancing.
                     </p>
                 </SettingsSectionBody>
                 <SettingsSectionFooter>

+ 5 - 0
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: <Shield className="w-4 h-4" />,
         });
+        sidebarNavItems.push({
+            title: "Rules",
+            href: `/{orgId}/settings/resources/{resourceId}/rules`
+            // icon: <Shield className="w-4 h-4" />,
+        });
     }
 
     return (

+ 730 - 0
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<ListResourceRulesResponse["rules"]> & {
+    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<LocalRule[]>([]);
+    const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
+    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<ListResourceRulesResponse>
+                >(`/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<typeof addRuleSchema>) {
+        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<LocalRule>) {
+        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<LocalRule>[] = [
+        {
+            accessorKey: "action",
+            header: "Action",
+            cell: ({ row }) => (
+                <Select
+                    defaultValue={row.original.action}
+                    onValueChange={(value: "ACCEPT" | "DROP") =>
+                        updateRule(row.original.ruleId, { action: value })
+                    }
+                >
+                    <SelectTrigger className="min-w-[100px]">
+                        {row.original.action}
+                    </SelectTrigger>
+                    <SelectContent>
+                        <SelectItem value="ACCEPT">
+                            {RuleAction.ACCEPT}
+                        </SelectItem>
+                        <SelectItem value="DROP">{RuleAction.DROP}</SelectItem>
+                    </SelectContent>
+                </Select>
+            )
+        },
+        {
+            accessorKey: "match",
+            header: "Match Type",
+            cell: ({ row }) => (
+                <Select
+                    defaultValue={row.original.match}
+                    onValueChange={(value: "CIDR" | "IP" | "PATH") =>
+                        updateRule(row.original.ruleId, { match: value })
+                    }
+                >
+                    <SelectTrigger className="min-w-[100px]">
+                        {row.original.match}
+                    </SelectTrigger>
+                    <SelectContent>
+                        <SelectItem value="IP">IP</SelectItem>
+                        <SelectItem value="CIDR">IP Range</SelectItem>
+                        <SelectItem value="PATH">PATH</SelectItem>
+                    </SelectContent>
+                </Select>
+            )
+        },
+        {
+            accessorKey: "value",
+            header: "Value",
+            cell: ({ row }) => (
+                <Input
+                    defaultValue={row.original.value}
+                    className="min-w-[200px]"
+                    onBlur={(e) =>
+                        updateRule(row.original.ruleId, {
+                            value: e.target.value
+                        })
+                    }
+                />
+            )
+        },
+        {
+            id: "actions",
+            cell: ({ row }) => (
+                <div className="flex items-center justify-end space-x-2">
+                    <Button
+                        variant="outline"
+                        onClick={() => removeRule(row.original.ruleId)}
+                    >
+                        Delete
+                    </Button>
+                </div>
+            )
+        }
+    ];
+
+    const table = useReactTable({
+        data: rules,
+        columns,
+        getCoreRowModel: getCoreRowModel(),
+        getPaginationRowModel: getPaginationRowModel(),
+        getSortedRowModel: getSortedRowModel(),
+        getFilteredRowModel: getFilteredRowModel()
+    });
+
+    if (pageLoading) {
+        return <></>;
+    }
+
+    return (
+        <SettingsContainer>
+            <Alert>
+                <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>
+                    <InfoSections>
+                        <InfoSection>
+                            <InfoSectionTitle>Actions</InfoSectionTitle>
+                            <ul className="text-sm text-muted-foreground space-y-1">
+                                <li className="flex items-center gap-2">
+                                    <Check className="text-green-500 w-4 h-4" />
+                                    Always Allow: Bypass all authentication
+                                    methods
+                                </li>
+                                <li className="flex items-center gap-2">
+                                    <X className="text-red-500 w-4 h-4" />
+                                    Always Deny: Block all requests; no
+                                    authentication can be attempted
+                                </li>
+                            </ul>
+                        </InfoSection>
+                        <Separator orientation="vertical" />
+                        <InfoSection>
+                            <InfoSectionTitle>
+                                Matching Criteria
+                            </InfoSectionTitle>
+                            <ul className="text-sm text-muted-foreground space-y-1">
+                                <li className="flex items-center gap-2">
+                                    Match a specific IP address
+                                </li>
+                                <li className="flex items-center gap-2">
+                                    Match a range of IP addresses in CIDR
+                                    notation
+                                </li>
+                                <li className="flex items-center gap-2">
+                                    Match a URL path or pattern
+                                </li>
+                            </ul>
+                        </InfoSection>
+                    </InfoSections>
+                </AlertDescription>
+            </Alert>
+
+            <SettingsSection>
+                <SettingsSectionHeader>
+                    <SettingsSectionTitle>Enable Rules</SettingsSectionTitle>
+                    <SettingsSectionDescription>
+                        Enable or disable rule evaluation for this resource
+                    </SettingsSectionDescription>
+                </SettingsSectionHeader>
+                <SettingsSectionBody>
+                    <SwitchInput
+                        id="rules-toggle"
+                        label="Enable Rules"
+                        defaultChecked={resource.applyRules}
+                        onCheckedChange={async (val) => {
+                            await saveApplyRules(val);
+                        }}
+                    />
+                </SettingsSectionBody>
+            </SettingsSection>
+
+            <SettingsSection>
+                <SettingsSectionHeader>
+                    <SettingsSectionTitle>
+                        Resource Rules Configuration
+                    </SettingsSectionTitle>
+                    <SettingsSectionDescription>
+                        Configure rules to control access to your resource
+                    </SettingsSectionDescription>
+                </SettingsSectionHeader>
+                <SettingsSectionBody>
+                    <Form {...addRuleForm}>
+                        <form
+                            onSubmit={addRuleForm.handleSubmit(addRule)}
+                            className="space-y-4"
+                        >
+                            <div className="grid grid-cols-3 gap-4">
+                                <FormField
+                                    control={addRuleForm.control}
+                                    name="action"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>Action</FormLabel>
+                                            <FormControl>
+                                                <Select
+                                                    value={field.value}
+                                                    onValueChange={
+                                                        field.onChange
+                                                    }
+                                                >
+                                                    <SelectTrigger>
+                                                        <SelectValue />
+                                                    </SelectTrigger>
+                                                    <SelectContent>
+                                                        <SelectItem value="ACCEPT">
+                                                            {RuleAction.ACCEPT}
+                                                        </SelectItem>
+                                                        <SelectItem value="DROP">
+                                                            {RuleAction.DROP}
+                                                        </SelectItem>
+                                                    </SelectContent>
+                                                </Select>
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                                <FormField
+                                    control={addRuleForm.control}
+                                    name="match"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>Match Type</FormLabel>
+                                            <FormControl>
+                                                <Select
+                                                    value={field.value}
+                                                    onValueChange={
+                                                        field.onChange
+                                                    }
+                                                >
+                                                    <SelectTrigger>
+                                                        <SelectValue />
+                                                    </SelectTrigger>
+                                                    <SelectContent>
+                                                        <SelectItem value="IP">
+                                                           IP 
+                                                        </SelectItem>
+                                                        <SelectItem value="CIDR">
+                                                           IP Range 
+                                                        </SelectItem>
+                                                        {resource.http && (
+                                                            <SelectItem value="PATH">
+                                                                PATH
+                                                            </SelectItem>
+                                                        )}
+                                                    </SelectContent>
+                                                </Select>
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                                <FormField
+                                    control={addRuleForm.control}
+                                    name="value"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <InfoPopup
+                                                text="Value"
+                                                info={
+                                                    addRuleForm.watch(
+                                                        "match"
+                                                    ) === "CIDR"
+                                                        ? "Enter an address in CIDR format (e.g., 103.21.244.0/22)"
+                                                        : "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)"
+                                                }
+                                            />
+                                            <FormControl>
+                                                <Input {...field} />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                            </div>
+                            <Button
+                                type="submit"
+                                variant="outline"
+                                disabled={loading || !rulesEnabled}
+                            >
+                                Add Rule
+                            </Button>
+                        </form>
+                    </Form>
+                    <TableContainer>
+                        <Table>
+                            <TableHeader>
+                                {table.getHeaderGroups().map((headerGroup) => (
+                                    <TableRow key={headerGroup.id}>
+                                        {headerGroup.headers.map((header) => (
+                                            <TableHead key={header.id}>
+                                                {header.isPlaceholder
+                                                    ? null
+                                                    : flexRender(
+                                                          header.column
+                                                              .columnDef.header,
+                                                          header.getContext()
+                                                      )}
+                                            </TableHead>
+                                        ))}
+                                    </TableRow>
+                                ))}
+                            </TableHeader>
+                            <TableBody>
+                                {table.getRowModel().rows?.length ? (
+                                    table.getRowModel().rows.map((row) => (
+                                        <TableRow key={row.id}>
+                                            {row
+                                                .getVisibleCells()
+                                                .map((cell) => (
+                                                    <TableCell key={cell.id}>
+                                                        {flexRender(
+                                                            cell.column
+                                                                .columnDef.cell,
+                                                            cell.getContext()
+                                                        )}
+                                                    </TableCell>
+                                                ))}
+                                        </TableRow>
+                                    ))
+                                ) : (
+                                    <TableRow>
+                                        <TableCell
+                                            colSpan={columns.length}
+                                            className="h-24 text-center"
+                                        >
+                                            No rules. Add a rule using the form.
+                                        </TableCell>
+                                    </TableRow>
+                                )}
+                            </TableBody>
+                        </Table>
+                    </TableContainer>
+                </SettingsSectionBody>
+                <SettingsSectionFooter>
+                    <Button
+                        onClick={saveRules}
+                        loading={loading}
+                        disabled={loading}
+                    >
+                        Save Rules
+                    </Button>
+                </SettingsSectionFooter>
+            </SettingsSection>
+        </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;
+}