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

@@ -406,6 +406,8 @@ export const resourceRules = pgTable("resourceRules", {
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
templateRuleId: integer("templateRuleId")
.references(() => templateRules.ruleId, { onDelete: "cascade" }),
enabled: boolean("enabled").notNull().default(true),
priority: integer("priority").notNull(),
action: varchar("action").notNull(), // ACCEPT, DROP
@@ -413,6 +415,40 @@ export const resourceRules = pgTable("resourceRules", {
value: varchar("value").notNull()
});
// Rule templates (reusable rule sets)
export const ruleTemplates = pgTable("ruleTemplates", {
templateId: varchar("templateId").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
name: varchar("name").notNull(),
description: varchar("description"),
createdAt: bigint("createdAt", { mode: "number" }).notNull()
});
// Rules within templates
export const templateRules = pgTable("templateRules", {
ruleId: serial("ruleId").primaryKey(),
templateId: varchar("templateId")
.notNull()
.references(() => ruleTemplates.templateId, { onDelete: "cascade" }),
enabled: boolean("enabled").notNull().default(true),
priority: integer("priority").notNull(),
action: varchar("action").notNull(), // ACCEPT, DROP
match: varchar("match").notNull(), // CIDR, IP, PATH
value: varchar("value").notNull()
});
// Template assignments to resources
export const resourceTemplates = pgTable("resourceTemplates", {
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
templateId: varchar("templateId")
.notNull()
.references(() => ruleTemplates.templateId, { onDelete: "cascade" })
});
export const supporterKey = pgTable("supporterKey", {
keyId: serial("keyId").primaryKey(),
key: varchar("key").notNull(),
@@ -637,3 +673,6 @@ export type OlmSession = InferSelectModel<typeof olmSessions>;
export type UserClient = InferSelectModel<typeof userClients>;
export type RoleClient = InferSelectModel<typeof roleClients>;
export type OrgDomains = InferSelectModel<typeof orgDomains>;
export type RuleTemplate = InferSelectModel<typeof ruleTemplates>;
export type TemplateRule = InferSelectModel<typeof templateRules>;
export type ResourceTemplate = InferSelectModel<typeof resourceTemplates>;

View File

@@ -534,6 +534,8 @@ export const resourceRules = sqliteTable("resourceRules", {
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
templateRuleId: integer("templateRuleId")
.references(() => templateRules.ruleId, { onDelete: "cascade" }),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull(),
action: text("action").notNull(), // ACCEPT, DROP
@@ -541,6 +543,40 @@ export const resourceRules = sqliteTable("resourceRules", {
value: text("value").notNull()
});
// Rule templates (reusable rule sets)
export const ruleTemplates = sqliteTable("ruleTemplates", {
templateId: text("templateId").primaryKey(),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
createdAt: integer("createdAt").notNull()
});
// Rules within templates
export const templateRules = sqliteTable("templateRules", {
ruleId: integer("ruleId").primaryKey({ autoIncrement: true }),
templateId: text("templateId")
.notNull()
.references(() => ruleTemplates.templateId, { onDelete: "cascade" }),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull(),
action: text("action").notNull(), // ACCEPT, DROP
match: text("match").notNull(), // CIDR, IP, PATH
value: text("value").notNull()
});
// Template assignments to resources
export const resourceTemplates = sqliteTable("resourceTemplates", {
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
templateId: text("templateId")
.notNull()
.references(() => ruleTemplates.templateId, { onDelete: "cascade" })
});
export const supporterKey = sqliteTable("supporterKey", {
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
key: text("key").notNull(),
@@ -679,3 +715,6 @@ export type ApiKey = InferSelectModel<typeof apiKeys>;
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
export type OrgDomains = InferSelectModel<typeof orgDomains>;
export type RuleTemplate = InferSelectModel<typeof ruleTemplates>;
export type TemplateRule = InferSelectModel<typeof templateRules>;
export type ResourceTemplate = InferSelectModel<typeof resourceTemplates>;

View File

@@ -11,6 +11,7 @@ export enum OpenAPITags {
Invitation = "Invitation",
Target = "Target",
Rule = "Rule",
RuleTemplate = "Rule Template",
AccessToken = "Access Token",
Idp = "Identity Provider",
Client = "Client",

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

View File

@@ -8,6 +8,7 @@ import path from "path";
import m1 from "./scriptsPg/1.6.0";
import m2 from "./scriptsPg/1.7.0";
import m3 from "./scriptsPg/1.8.0";
import m4 from "./scriptsPg/1.10.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -16,7 +17,8 @@ import m3 from "./scriptsPg/1.8.0";
const migrations = [
{ version: "1.6.0", run: m1 },
{ version: "1.7.0", run: m2 },
{ version: "1.8.0", run: m3 }
{ version: "1.8.0", run: m3 },
{ version: "1.10.0", run: m4 }
// Add new migrations here as they are created
] as {
version: string;

View File

@@ -25,6 +25,7 @@ import m20 from "./scriptsSqlite/1.5.0";
import m21 from "./scriptsSqlite/1.6.0";
import m22 from "./scriptsSqlite/1.7.0";
import m23 from "./scriptsSqlite/1.8.0";
import m24 from "./scriptsSqlite/1.10.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -49,6 +50,7 @@ const migrations = [
{ version: "1.6.0", run: m21 },
{ version: "1.7.0", run: m22 },
{ version: "1.8.0", run: m23 },
{ version: "1.10.0", run: m24 },
// Add new migrations here as they are created
] as const;

View File

@@ -0,0 +1,63 @@
import { db } from "@server/db/pg";
import { ruleTemplates, templateRules, resourceTemplates } from "@server/db/pg/schema";
const version = "1.10.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
// Create rule templates table
await db.execute(`
CREATE TABLE IF NOT EXISTS "ruleTemplates" (
"templateId" varchar PRIMARY KEY,
"orgId" varchar NOT NULL,
"name" varchar NOT NULL,
"description" varchar,
"createdAt" bigint NOT NULL,
FOREIGN KEY ("orgId") REFERENCES "orgs" ("orgId") ON DELETE CASCADE
);
`);
// Create template rules table
await db.execute(`
CREATE TABLE IF NOT EXISTS "templateRules" (
"ruleId" serial PRIMARY KEY,
"templateId" varchar NOT NULL,
"enabled" boolean NOT NULL DEFAULT true,
"priority" integer NOT NULL,
"action" varchar NOT NULL,
"match" varchar NOT NULL,
"value" varchar NOT NULL,
FOREIGN KEY ("templateId") REFERENCES "ruleTemplates" ("templateId") ON DELETE CASCADE
);
`);
// Create resource templates table
await db.execute(`
CREATE TABLE IF NOT EXISTS "resourceTemplates" (
"resourceId" integer NOT NULL,
"templateId" varchar NOT NULL,
PRIMARY KEY ("resourceId", "templateId"),
FOREIGN KEY ("resourceId") REFERENCES "resources" ("resourceId") ON DELETE CASCADE,
FOREIGN KEY ("templateId") REFERENCES "ruleTemplates" ("templateId") ON DELETE CASCADE
);
`);
console.log("Added rule template tables");
// Add templateRuleId column to resourceRules table
await db.execute(`
ALTER TABLE "resourceRules"
ADD COLUMN "templateRuleId" INTEGER
REFERENCES "templateRules"("ruleId") ON DELETE CASCADE
`);
console.log("Added templateRuleId column to resourceRules table");
} catch (e) {
console.log("Unable to add rule template tables and columns");
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -0,0 +1,70 @@
import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
import { db } from "@server/db/sqlite";
const version = "1.10.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const sqliteDb = new Database(location);
try {
sqliteDb.transaction(() => {
// Create rule templates table
sqliteDb.exec(`
CREATE TABLE IF NOT EXISTS 'ruleTemplates' (
'templateId' text PRIMARY KEY,
'orgId' text NOT NULL,
'name' text NOT NULL,
'description' text,
'createdAt' integer NOT NULL,
FOREIGN KEY ('orgId') REFERENCES 'orgs' ('orgId') ON DELETE CASCADE
);
`);
// Create template rules table
sqliteDb.exec(`
CREATE TABLE IF NOT EXISTS 'templateRules' (
'ruleId' integer PRIMARY KEY AUTOINCREMENT,
'templateId' text NOT NULL,
'enabled' integer NOT NULL DEFAULT 1,
'priority' integer NOT NULL,
'action' text NOT NULL,
'match' text NOT NULL,
'value' text NOT NULL,
FOREIGN KEY ('templateId') REFERENCES 'ruleTemplates' ('templateId') ON DELETE CASCADE
);
`);
// Create resource templates table
sqliteDb.exec(`
CREATE TABLE IF NOT EXISTS 'resourceTemplates' (
'resourceId' integer NOT NULL,
'templateId' text NOT NULL,
PRIMARY KEY ('resourceId', 'templateId'),
FOREIGN KEY ('resourceId') REFERENCES 'resources' ('resourceId') ON DELETE CASCADE,
FOREIGN KEY ('templateId') REFERENCES 'ruleTemplates' ('templateId') ON DELETE CASCADE
);
`);
})();
console.log("Added rule template tables");
// Add templateRuleId column to resourceRules table
await db.run(`
ALTER TABLE resourceRules
ADD COLUMN templateRuleId INTEGER
REFERENCES templateRules(ruleId) ON DELETE CASCADE
`);
console.log("Added templateRuleId column to resourceRules table");
} catch (e) {
console.log("Unable to add rule template tables and columns");
throw e;
}
console.log(`${version} migration complete`);
}