Merge pull request #185 from fosrl/rules

Rules
This commit is contained in:
Milo Schwartz 2025-02-10 21:11:57 -05:00 committed by GitHub
commit 6fba13c8d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1401 additions and 28 deletions

View file

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

View file

@ -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(

View file

@ -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>;

View file

@ -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);

View file

@ -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}/?$`);
}

View file

@ -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,

View file

@ -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")
);
}
}

View file

@ -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")
);
}
}

View file

@ -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";

View file

@ -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")
);
}
}

View file

@ -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, {

View file

@ -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")
);
}
}

View file

@ -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;

View file

@ -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.");
}

View file

@ -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,10 +316,23 @@ export default function ReverseProxyTargets(props: {
}
async function saveSsl(val: boolean) {
const res = await api.post(`/resource/${params.resourceId}`, {
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"
)
});
});
if (res && res.status === 200) {
setSslEnabled(val);
updateResource({ ssl: val });
@ -328,6 +341,7 @@ export default function ReverseProxyTargets(props: {
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>

View file

@ -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 (

View file

@ -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;
}