Scoped Branch - Rule Templates:

- Add rule templates for reusable access control rules
- Support template assignment to resources with automatic rule propagation
- Add template management UI
- Implement template rule protection on resource rules page
This commit is contained in:
Adrian Astles
2025-08-07 22:57:18 +08:00
parent 4679ce968b
commit 9dce7b2cde
35 changed files with 3199 additions and 88 deletions

View File

@@ -14,6 +14,7 @@ import * as accessToken from "./accessToken";
import * as idp from "./idp";
import * as license from "./license";
import * as apiKeys from "./apiKeys";
import * as ruleTemplate from "./ruleTemplate";
import HttpCode from "@server/types/HttpCode";
import {
verifyAccessTokenAccess,
@@ -339,6 +340,80 @@ authenticated.delete(
resource.deleteResourceRule
);
// Rule template routes
authenticated.post(
"/org/:orgId/rule-templates",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createResourceRule),
ruleTemplate.createRuleTemplate
);
authenticated.get(
"/org/:orgId/rule-templates",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listResourceRules),
ruleTemplate.listRuleTemplates
);
authenticated.get(
"/org/:orgId/rule-templates/:templateId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listResourceRules),
ruleTemplate.getRuleTemplate
);
authenticated.put(
"/org/:orgId/rule-templates/:templateId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createResourceRule),
ruleTemplate.updateRuleTemplate
);
authenticated.get(
"/org/:orgId/rule-templates/:templateId/rules",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listResourceRules),
ruleTemplate.listTemplateRules
);
authenticated.post(
"/org/:orgId/rule-templates/:templateId/rules",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createResourceRule),
ruleTemplate.addTemplateRule
);
authenticated.put(
"/org/:orgId/rule-templates/:templateId/rules/:ruleId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createResourceRule),
ruleTemplate.updateTemplateRule
);
authenticated.delete(
"/org/:orgId/rule-templates/:templateId/rules/:ruleId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteResourceRule),
ruleTemplate.deleteTemplateRule
);
authenticated.delete(
"/org/:orgId/rule-templates/:templateId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteResourceRule),
ruleTemplate.deleteRuleTemplate
);
authenticated.put(
"/resource/:resourceId/templates/:templateId",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.createResourceRule),
ruleTemplate.assignTemplateToResource
);
authenticated.delete(
"/resource/:resourceId/templates/:templateId",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.deleteResourceRule),
ruleTemplate.unassignTemplateFromResource
);
authenticated.get(
"/resource/:resourceId/templates",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.listResourceRules),
ruleTemplate.listResourceTemplates
);
authenticated.get(
"/target/:targetId",
verifyTargetAccess,

View File

@@ -39,6 +39,7 @@ function queryResourceRules(resourceId: number) {
.select({
ruleId: resourceRules.ruleId,
resourceId: resourceRules.resourceId,
templateRuleId: resourceRules.templateRuleId,
action: resourceRules.action,
match: resourceRules.match,
value: resourceRules.value,

View File

@@ -0,0 +1,161 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { templateRules, ruleTemplates } from "@server/db";
import { eq, and } 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";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "@server/lib/validators";
const addTemplateRuleParamsSchema = z
.object({
orgId: z.string().min(1),
templateId: z.string().min(1)
})
.strict();
const addTemplateRuleBodySchema = z
.object({
action: z.enum(["ACCEPT", "DROP"]),
match: z.enum(["CIDR", "IP", "PATH"]),
value: z.string().min(1),
priority: z.number().int().optional(),
enabled: z.boolean().optional()
})
.strict();
export async function addTemplateRule(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = addTemplateRuleParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = addTemplateRuleBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId, templateId } = parsedParams.data;
const { action, match, value, priority, enabled = true } = parsedBody.data;
// Check if template exists and belongs to the organization
const existingTemplate = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.limit(1);
if (existingTemplate.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Rule template not found"
)
);
}
// Validate the value based on match type
if (match === "CIDR" && !isValidCIDR(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid CIDR format"
)
);
}
if (match === "IP" && !isValidIP(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid IP address format"
)
);
}
if (match === "PATH" && !isValidUrlGlobPattern(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid URL pattern format"
)
);
}
// Check for duplicate rule
const existingRule = await db
.select()
.from(templateRules)
.where(and(
eq(templateRules.templateId, templateId),
eq(templateRules.action, action),
eq(templateRules.match, match),
eq(templateRules.value, value)
))
.limit(1);
if (existingRule.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Rule already exists"
)
);
}
// Determine priority if not provided
let finalPriority = priority;
if (finalPriority === undefined) {
const maxPriority = await db
.select({ maxPriority: templateRules.priority })
.from(templateRules)
.where(eq(templateRules.templateId, templateId))
.orderBy(templateRules.priority)
.limit(1);
finalPriority = (maxPriority[0]?.maxPriority || 0) + 1;
}
// Add the rule
const [newRule] = await db
.insert(templateRules)
.values({
templateId,
action,
match,
value,
priority: finalPriority,
enabled
})
.returning();
return response(res, {
data: newRule,
success: true,
error: false,
message: "Template rule added successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,176 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourceTemplates, ruleTemplates, resources, templateRules, resourceRules } from "@server/db";
import { eq, and } 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";
import { OpenAPITags, registry } from "@server/openApi";
const assignTemplateToResourceParamsSchema = z
.object({
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive()),
templateId: z.string().min(1)
})
.strict();
registry.registerPath({
method: "put",
path: "/resource/{resourceId}/templates/{templateId}",
description: "Assign a template to a resource.",
tags: [OpenAPITags.Resource, OpenAPITags.RuleTemplate],
request: {
params: assignTemplateToResourceParamsSchema
},
responses: {}
});
export async function assignTemplateToResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = assignTemplateToResourceParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId, templateId } = 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`
)
);
}
// Verify that the template exists
const [template] = await db
.select()
.from(ruleTemplates)
.where(eq(ruleTemplates.templateId, templateId))
.limit(1);
if (!template) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Rule template with ID ${templateId} not found`
)
);
}
// Verify that the template belongs to the same organization as the resource
if (template.orgId !== resource.orgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
`Template ${templateId} does not belong to the same organization as resource ${resourceId}`
)
);
}
// Check if the template is already assigned to this resource
const [existingAssignment] = await db
.select()
.from(resourceTemplates)
.where(and(eq(resourceTemplates.resourceId, resourceId), eq(resourceTemplates.templateId, templateId)))
.limit(1);
if (existingAssignment) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Template ${templateId} is already assigned to resource ${resourceId}`
)
);
}
// Assign the template to the resource
await db
.insert(resourceTemplates)
.values({
resourceId,
templateId
});
// Automatically sync the template rules to the resource
try {
// Get all rules from the template
const templateRulesList = await db
.select()
.from(templateRules)
.where(eq(templateRules.templateId, templateId))
.orderBy(templateRules.priority);
if (templateRulesList.length > 0) {
// Get existing resource rules to calculate the next priority
const existingRules = await db
.select()
.from(resourceRules)
.where(eq(resourceRules.resourceId, resourceId))
.orderBy(resourceRules.priority);
// Calculate the starting priority for new template rules
// They should come after the highest existing priority
const maxExistingPriority = existingRules.length > 0
? Math.max(...existingRules.map(r => r.priority))
: 0;
// Create new resource rules from template rules with adjusted priorities
const newRules = templateRulesList.map((templateRule, index) => ({
resourceId,
templateRuleId: templateRule.ruleId, // Link to the template rule
action: templateRule.action,
match: templateRule.match,
value: templateRule.value,
priority: maxExistingPriority + index + 1, // Simple sequential ordering
enabled: templateRule.enabled
}));
await db
.insert(resourceRules)
.values(newRules);
}
} catch (error) {
logger.error("Error auto-syncing template rules during assignment:", error);
// Don't fail the assignment if sync fails, just log it
}
return response(res, {
data: { resourceId, templateId },
success: true,
error: false,
message: "Template assigned to resource successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,121 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { ruleTemplates } from "@server/db";
import { eq, and } 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";
import { OpenAPITags, registry } from "@server/openApi";
import { generateId } from "@server/auth/sessions/app";
const createRuleTemplateParamsSchema = z
.object({
orgId: z.string().min(1)
})
.strict();
const createRuleTemplateBodySchema = z
.object({
name: z.string().min(1).max(100).refine(name => name.trim().length > 0, {
message: "Template name cannot be empty or just whitespace"
}),
description: z.string().max(500).optional()
})
.strict();
registry.registerPath({
method: "post",
path: "/org/{orgId}/rule-templates",
description: "Create a rule template.",
tags: [OpenAPITags.Org, OpenAPITags.RuleTemplate],
request: {
params: createRuleTemplateParamsSchema,
body: {
content: {
"application/json": {
schema: createRuleTemplateBodySchema
}
}
}
},
responses: {}
});
export async function createRuleTemplate(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = createRuleTemplateParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = createRuleTemplateBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const { name, description } = parsedBody.data;
// Check if template with same name already exists
const existingTemplate = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.name, name)))
.limit(1);
if (existingTemplate.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`A template with the name "${name}" already exists in this organization`
)
);
}
const templateId = generateId(15);
const createdAt = Date.now();
const [newTemplate] = await db
.insert(ruleTemplates)
.values({
templateId,
orgId,
name,
description: description || null,
createdAt
})
.returning();
return response(res, {
data: newTemplate,
success: true,
error: false,
message: "Rule template 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,60 @@
import { z } from "zod";
import { db } from "@server/db";
import { ruleTemplates, templateRules, resourceTemplates } from "@server/db";
import { eq, and } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { generateId } from "@server/auth/sessions/app";
const deleteRuleTemplateSchema = z.object({
orgId: z.string().min(1),
templateId: z.string().min(1)
});
export async function deleteRuleTemplate(req: any, res: any) {
try {
const { orgId, templateId } = deleteRuleTemplateSchema.parse({
orgId: req.params.orgId,
templateId: req.params.templateId
});
// Check if template exists and belongs to the organization
const existingTemplate = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.limit(1);
if (existingTemplate.length === 0) {
return res.status(404).json({
success: false,
message: "Rule template not found"
});
}
// Delete template rules first (due to foreign key constraint)
await db
.delete(templateRules)
.where(eq(templateRules.templateId, templateId));
// Delete resource template assignments
await db
.delete(resourceTemplates)
.where(eq(resourceTemplates.templateId, templateId));
// Delete the template
await db
.delete(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)));
return res.status(200).json({
success: true,
message: "Rule template deleted successfully"
});
} catch (error) {
console.error("Error deleting rule template:", error);
return res.status(500).json({
success: false,
message: "Internal server error"
});
}
}

View File

@@ -0,0 +1,100 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { templateRules, ruleTemplates } from "@server/db";
import { eq, and } 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 deleteTemplateRuleParamsSchema = z
.object({
orgId: z.string().min(1),
templateId: z.string().min(1),
ruleId: z.string().min(1)
})
.strict();
export async function deleteTemplateRule(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = deleteTemplateRuleParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, templateId, ruleId } = parsedParams.data;
// Check if template exists and belongs to the organization
const existingTemplate = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.limit(1);
if (existingTemplate.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Rule template not found"
)
);
}
// Check if rule exists and belongs to the template
const existingRule = await db
.select()
.from(templateRules)
.where(and(eq(templateRules.templateId, templateId), eq(templateRules.ruleId, parseInt(ruleId))))
.limit(1);
if (existingRule.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Template rule not found"
)
);
}
// Delete the rule
await db
.delete(templateRules)
.where(and(eq(templateRules.templateId, templateId), eq(templateRules.ruleId, parseInt(ruleId))));
// Also delete all resource rules that were created from this template rule
try {
const { resourceRules } = await import("@server/db");
await db
.delete(resourceRules)
.where(eq(resourceRules.templateRuleId, parseInt(ruleId)));
} catch (error) {
logger.error("Error deleting resource rules created from template rule:", error);
// Don't fail the template rule deletion if resource rule deletion fails, just log it
}
return response(res, {
data: null,
success: true,
error: false,
message: "Template rule deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,69 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { ruleTemplates } from "@server/db";
import { eq, and } 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 getRuleTemplateParamsSchema = z
.object({
orgId: z.string().min(1),
templateId: z.string().min(1)
})
.strict();
export async function getRuleTemplate(
req: any,
res: any,
next: any
): Promise<any> {
try {
const parsedParams = getRuleTemplateParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, templateId } = parsedParams.data;
// Get the template
const template = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.limit(1);
if (template.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Rule template not found"
)
);
}
return response(res, {
data: template[0],
success: true,
error: false,
message: "Rule template retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
console.error("Error getting rule template:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}

View File

@@ -0,0 +1,12 @@
export * from "./createRuleTemplate";
export * from "./listRuleTemplates";
export * from "./getRuleTemplate";
export * from "./updateRuleTemplate";
export * from "./listTemplateRules";
export * from "./addTemplateRule";
export * from "./updateTemplateRule";
export * from "./deleteTemplateRule";
export * from "./assignTemplateToResource";
export * from "./unassignTemplateFromResource";
export * from "./listResourceTemplates";
export * from "./deleteRuleTemplate";

View File

@@ -0,0 +1,104 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourceTemplates, ruleTemplates, resources } from "@server/db";
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";
import { OpenAPITags, registry } from "@server/openApi";
const listResourceTemplatesParamsSchema = z
.object({
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
export type ListResourceTemplatesResponse = {
templates: Awaited<ReturnType<typeof queryResourceTemplates>>;
};
function queryResourceTemplates(resourceId: number) {
return db
.select({
templateId: ruleTemplates.templateId,
name: ruleTemplates.name,
description: ruleTemplates.description,
orgId: ruleTemplates.orgId,
createdAt: ruleTemplates.createdAt
})
.from(resourceTemplates)
.innerJoin(ruleTemplates, eq(resourceTemplates.templateId, ruleTemplates.templateId))
.where(eq(resourceTemplates.resourceId, resourceId))
.orderBy(ruleTemplates.createdAt);
}
registry.registerPath({
method: "get",
path: "/resource/{resourceId}/templates",
description: "List templates assigned to a resource.",
tags: [OpenAPITags.Resource, OpenAPITags.RuleTemplate],
request: {
params: listResourceTemplatesParamsSchema
},
responses: {}
});
export async function listResourceTemplates(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = listResourceTemplatesParamsSchema.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 templatesList = await queryResourceTemplates(resourceId);
return response<ListResourceTemplatesResponse>(res, {
data: {
templates: templatesList
},
success: true,
error: false,
message: "Resource templates retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,127 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { ruleTemplates } from "@server/db";
import { eq, sql } 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";
import { OpenAPITags, registry } from "@server/openApi";
const listRuleTemplatesParamsSchema = z
.object({
orgId: z.string().min(1)
})
.strict();
const listRuleTemplatesQuerySchema = 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())
});
export type ListRuleTemplatesResponse = {
templates: Awaited<ReturnType<typeof queryRuleTemplates>>;
pagination: { total: number; limit: number; offset: number };
};
function queryRuleTemplates(orgId: string) {
return db
.select({
templateId: ruleTemplates.templateId,
orgId: ruleTemplates.orgId,
name: ruleTemplates.name,
description: ruleTemplates.description,
createdAt: ruleTemplates.createdAt
})
.from(ruleTemplates)
.where(eq(ruleTemplates.orgId, orgId))
.orderBy(ruleTemplates.createdAt);
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/rule-templates",
description: "List rule templates for an organization.",
tags: [OpenAPITags.Org, OpenAPITags.RuleTemplate],
request: {
params: listRuleTemplatesParamsSchema,
query: listRuleTemplatesQuerySchema
},
responses: {}
});
export async function listRuleTemplates(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listRuleTemplatesQuerySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const { limit, offset } = parsedQuery.data;
const parsedParams = listRuleTemplatesParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
const baseQuery = queryRuleTemplates(orgId);
let templatesList = await baseQuery.limit(limit).offset(offset);
// Get total count
const countResult = await db
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(ruleTemplates)
.where(eq(ruleTemplates.orgId, orgId));
const totalCount = Number(countResult[0]?.count || 0);
return response<ListRuleTemplatesResponse>(res, {
data: {
templates: templatesList,
pagination: {
total: totalCount,
limit,
offset
}
},
success: true,
error: false,
message: "Rule templates retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,73 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { templateRules, ruleTemplates } from "@server/db";
import { eq, and } 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 listTemplateRulesParamsSchema = z
.object({
orgId: z.string().min(1),
templateId: z.string().min(1)
})
.strict();
export async function listTemplateRules(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = listTemplateRulesParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, templateId } = parsedParams.data;
// Check if template exists and belongs to the organization
const existingTemplate = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.limit(1);
if (existingTemplate.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Rule template not found"
)
);
}
// Get template rules
const rules = await db
.select()
.from(templateRules)
.where(eq(templateRules.templateId, templateId))
.orderBy(templateRules.priority);
return response(res, {
data: { rules },
success: true,
error: false,
message: "Template rules retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,130 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourceTemplates, resources, resourceRules, templateRules } from "@server/db";
import { eq, and } 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";
import { OpenAPITags, registry } from "@server/openApi";
const unassignTemplateFromResourceParamsSchema = z
.object({
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive()),
templateId: z.string().min(1)
})
.strict();
registry.registerPath({
method: "delete",
path: "/resource/{resourceId}/templates/{templateId}",
description: "Unassign a template from a resource.",
tags: [OpenAPITags.Resource, OpenAPITags.RuleTemplate],
request: {
params: unassignTemplateFromResourceParamsSchema
},
responses: {}
});
export async function unassignTemplateFromResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = unassignTemplateFromResourceParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId, templateId } = 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`
)
);
}
// Check if the template is assigned to this resource
const [existingAssignment] = await db
.select()
.from(resourceTemplates)
.where(and(eq(resourceTemplates.resourceId, resourceId), eq(resourceTemplates.templateId, templateId)))
.limit(1);
if (!existingAssignment) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Template ${templateId} is not assigned to resource ${resourceId}`
)
);
}
// Remove the template assignment
await db
.delete(resourceTemplates)
.where(and(eq(resourceTemplates.resourceId, resourceId), eq(resourceTemplates.templateId, templateId)));
// Remove all resource rules that were created from this template
// We can now use the templateRuleId to precisely identify which rules to remove
try {
// Get all template rules for this template
const templateRulesList = await db
.select()
.from(templateRules)
.where(eq(templateRules.templateId, templateId))
.orderBy(templateRules.priority);
if (templateRulesList.length > 0) {
// Remove resource rules that have templateRuleId matching any of the template rules
for (const templateRule of templateRulesList) {
await db
.delete(resourceRules)
.where(and(
eq(resourceRules.resourceId, resourceId),
eq(resourceRules.templateRuleId, templateRule.ruleId)
));
}
}
} catch (error) {
logger.error("Error removing template rules during unassignment:", error);
// Don't fail the unassignment if rule removal fails, just log it
}
return response(res, {
data: { resourceId, templateId },
success: true,
error: false,
message: "Template unassigned from resource successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,117 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { ruleTemplates } from "@server/db";
import { eq, and } 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 updateRuleTemplateParamsSchema = z
.object({
orgId: z.string().min(1),
templateId: z.string().min(1)
})
.strict();
const updateRuleTemplateBodySchema = z
.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional()
})
.strict();
export async function updateRuleTemplate(
req: any,
res: any,
next: any
): Promise<any> {
try {
const parsedParams = updateRuleTemplateParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = updateRuleTemplateBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId, templateId } = parsedParams.data;
const { name, description } = parsedBody.data;
// Check if template exists and belongs to the organization
const existingTemplate = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.limit(1);
if (existingTemplate.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Rule template not found"
)
);
}
// Check if another template with the same name already exists (excluding current template)
const duplicateTemplate = await db
.select()
.from(ruleTemplates)
.where(and(
eq(ruleTemplates.orgId, orgId),
eq(ruleTemplates.name, name),
eq(ruleTemplates.templateId, templateId)
))
.limit(1);
if (duplicateTemplate.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Template with name "${name}" already exists`
)
);
}
// Update the template
const [updatedTemplate] = await db
.update(ruleTemplates)
.set({
name,
description: description || null
})
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.returning();
return response(res, {
data: updatedTemplate,
success: true,
error: false,
message: "Rule template updated successfully",
status: HttpCode.OK
});
} catch (error) {
console.error("Error updating rule template:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}

View File

@@ -0,0 +1,177 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { templateRules, ruleTemplates } from "@server/db";
import { eq, and } 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";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "@server/lib/validators";
const updateTemplateRuleParamsSchema = z
.object({
orgId: z.string().min(1),
templateId: z.string().min(1),
ruleId: z.string().min(1)
})
.strict();
const updateTemplateRuleBodySchema = z
.object({
action: z.enum(["ACCEPT", "DROP"]).optional(),
match: z.enum(["CIDR", "IP", "PATH"]).optional(),
value: z.string().min(1).optional(),
priority: z.number().int().optional(),
enabled: z.boolean().optional()
})
.strict();
export async function updateTemplateRule(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = updateTemplateRuleParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = updateTemplateRuleBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId, templateId, ruleId } = parsedParams.data;
const updateData = parsedBody.data;
// Check if template exists and belongs to the organization
const existingTemplate = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.limit(1);
if (existingTemplate.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Rule template not found"
)
);
}
// Check if rule exists and belongs to the template
const existingRule = await db
.select()
.from(templateRules)
.where(and(eq(templateRules.templateId, templateId), eq(templateRules.ruleId, parseInt(ruleId))))
.limit(1);
if (existingRule.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Template rule not found"
)
);
}
// Validate the value if it's being updated
if (updateData.value && updateData.match) {
if (updateData.match === "CIDR" && !isValidCIDR(updateData.value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid CIDR format"
)
);
}
if (updateData.match === "IP" && !isValidIP(updateData.value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid IP address format"
)
);
}
if (updateData.match === "PATH" && !isValidUrlGlobPattern(updateData.value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid URL pattern format"
)
);
}
}
// Update the rule
const [updatedRule] = await db
.update(templateRules)
.set(updateData)
.where(and(eq(templateRules.templateId, templateId), eq(templateRules.ruleId, parseInt(ruleId))))
.returning();
// Propagate changes to all resource rules created from this template rule
try {
const { resourceRules } = await import("@server/db");
// Find all resource rules that were created from this template rule
const affectedResourceRules = await db
.select()
.from(resourceRules)
.where(eq(resourceRules.templateRuleId, parseInt(ruleId)));
if (affectedResourceRules.length > 0) {
// Update all affected resource rules with the same changes
// Note: We don't update priority as that should remain independent
const propagationData = {
...updateData,
priority: undefined // Don't propagate priority changes
};
// Remove undefined values
Object.keys(propagationData).forEach(key => {
if (propagationData[key] === undefined) {
delete propagationData[key];
}
});
if (Object.keys(propagationData).length > 0) {
await db
.update(resourceRules)
.set(propagationData)
.where(eq(resourceRules.templateRuleId, parseInt(ruleId)));
}
}
} catch (error) {
logger.error("Error propagating template rule changes to resource rules:", error);
// Don't fail the template rule update if propagation fails, just log it
}
return response(res, {
data: updatedRule,
success: true,
error: false,
message: "Template rule updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}