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

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