diff --git a/messages/en-US.json b/messages/en-US.json index ca5557bc..370fd098 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1041,6 +1041,26 @@ "actionDeleteResourceRule": "Delete Resource Rule", "actionListResourceRules": "List Resource Rules", "actionUpdateResourceRule": "Update Resource Rule", + "ruleTemplates": "Rule Templates", + "ruleTemplatesDescription": "Assign rule templates to automatically apply consistent rules across multiple resources", + "ruleTemplatesSearch": "Search templates...", + "ruleTemplateAdd": "Create Template", + "ruleTemplateErrorDelete": "Failed to delete template", + "ruleTemplateCreated": "Template created", + "ruleTemplateCreatedDescription": "Rule template created successfully", + "ruleTemplateErrorCreate": "Failed to create template", + "ruleTemplateErrorCreateDescription": "An error occurred while creating the template", + "ruleTemplateSetting": "Rule Template Settings", + "ruleTemplateSettingDescription": "Manage template details and rules", + "ruleTemplateErrorLoad": "Failed to load template", + "ruleTemplateErrorLoadDescription": "An error occurred while loading the template", + "ruleTemplateUpdated": "Template updated", + "ruleTemplateUpdatedDescription": "Template updated successfully", + "ruleTemplateErrorUpdate": "Failed to update template", + "ruleTemplateErrorUpdateDescription": "An error occurred while updating the template", + "save": "Save", + "saving": "Saving...", + "templateDetails": "Template Details", "actionListOrgs": "List Organizations", "actionCheckOrgId": "Check ID", "actionCreateOrg": "Create Organization", @@ -1136,6 +1156,7 @@ "sidebarInvitations": "Invitations", "sidebarRoles": "Roles", "sidebarShareableLinks": "Shareable Links", + "sidebarRuleTemplates": "Rule Templates", "sidebarApiKeys": "API Keys", "sidebarSettings": "Settings", "sidebarAllUsers": "All Users", diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 764e343d..0af06b85 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -466,6 +466,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, PASS @@ -473,6 +475,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(), @@ -711,4 +747,7 @@ export type OrgDomains = InferSelectModel; export type SiteResource = InferSelectModel; export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; -export type TargetHealthCheck = InferSelectModel; \ No newline at end of file +export type TargetHealthCheck = InferSelectModel; +export type RuleTemplate = InferSelectModel; +export type TemplateRule = InferSelectModel; +export type ResourceTemplate = InferSelectModel; diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 21e44a92..6c5c2461 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -600,6 +600,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, PASS @@ -607,6 +609,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(), @@ -748,4 +784,7 @@ export type SiteResource = InferSelectModel; export type OrgDomains = InferSelectModel; export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; -export type TargetHealthCheck = InferSelectModel; \ No newline at end of file +export type TargetHealthCheck = InferSelectModel; +export type RuleTemplate = InferSelectModel; +export type TemplateRule = InferSelectModel; +export type ResourceTemplate = InferSelectModel; diff --git a/server/openApi.ts b/server/openApi.ts index 32cdb67b..3a6340e1 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -11,6 +11,7 @@ export enum OpenAPITags { Invitation = "Invitation", Target = "Target", Rule = "Rule", + RuleTemplate = "Rule Template", AccessToken = "Access Token", Idp = "Identity Provider", Client = "Client", diff --git a/server/routers/external.ts b/server/routers/external.ts index d6fa4a16..1fb377b0 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -15,6 +15,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, @@ -453,6 +454,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, diff --git a/server/routers/resource/listResourceRules.ts b/server/routers/resource/listResourceRules.ts index 727d50ba..e62e40c4 100644 --- a/server/routers/resource/listResourceRules.ts +++ b/server/routers/resource/listResourceRules.ts @@ -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, diff --git a/server/routers/ruleTemplate/addTemplateRule.ts b/server/routers/ruleTemplate/addTemplateRule.ts new file mode 100644 index 00000000..5845937a --- /dev/null +++ b/server/routers/ruleTemplate/addTemplateRule.ts @@ -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 { + 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") + ); + } +} diff --git a/server/routers/ruleTemplate/assignTemplateToResource.ts b/server/routers/ruleTemplate/assignTemplateToResource.ts new file mode 100644 index 00000000..4cb99cc7 --- /dev/null +++ b/server/routers/ruleTemplate/assignTemplateToResource.ts @@ -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 { + 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") + ); + } +} diff --git a/server/routers/ruleTemplate/createRuleTemplate.ts b/server/routers/ruleTemplate/createRuleTemplate.ts new file mode 100644 index 00000000..a0d72ea8 --- /dev/null +++ b/server/routers/ruleTemplate/createRuleTemplate.ts @@ -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 { + 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") + ); + } +} diff --git a/server/routers/ruleTemplate/deleteRuleTemplate.ts b/server/routers/ruleTemplate/deleteRuleTemplate.ts new file mode 100644 index 00000000..f064da43 --- /dev/null +++ b/server/routers/ruleTemplate/deleteRuleTemplate.ts @@ -0,0 +1,79 @@ +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" + }); + } + + // Get all template rules for this template + const templateRulesToDelete = await db + .select({ ruleId: templateRules.ruleId }) + .from(templateRules) + .where(eq(templateRules.templateId, templateId)); + + // Delete resource rules that reference these template rules first + if (templateRulesToDelete.length > 0) { + const { resourceRules } = await import("@server/db"); + const templateRuleIds = templateRulesToDelete.map(rule => rule.ruleId); + + // Delete all resource rules that reference any of the template rules + for (const ruleId of templateRuleIds) { + await db + .delete(resourceRules) + .where(eq(resourceRules.templateRuleId, ruleId)); + } + } + + // Delete template rules + 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" + }); + } +} diff --git a/server/routers/ruleTemplate/deleteTemplateRule.ts b/server/routers/ruleTemplate/deleteTemplateRule.ts new file mode 100644 index 00000000..9de0dfc4 --- /dev/null +++ b/server/routers/ruleTemplate/deleteTemplateRule.ts @@ -0,0 +1,114 @@ +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 { + 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" + ) + ); + } + + // Count affected resources for the response message + let affectedResourcesCount = 0; + try { + const { resourceRules } = await import("@server/db"); + + // Get affected resource rules before deletion for counting + const affectedResourceRules = await db + .select() + .from(resourceRules) + .where(eq(resourceRules.templateRuleId, parseInt(ruleId))); + + affectedResourcesCount = affectedResourceRules.length; + + // Delete the resource rules first (due to foreign key constraint) + 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 + } + + // Delete the template rule after resource rules are deleted + await db + .delete(templateRules) + .where(and(eq(templateRules.templateId, templateId), eq(templateRules.ruleId, parseInt(ruleId)))); + + const message = affectedResourcesCount > 0 + ? `Template rule deleted successfully. Removed from ${affectedResourcesCount} assigned resource${affectedResourcesCount > 1 ? 's' : ''}.` + : "Template rule deleted successfully."; + + return response(res, { + data: null, + success: true, + error: false, + message, + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/ruleTemplate/getRuleTemplate.ts b/server/routers/ruleTemplate/getRuleTemplate.ts new file mode 100644 index 00000000..c16550b7 --- /dev/null +++ b/server/routers/ruleTemplate/getRuleTemplate.ts @@ -0,0 +1,77 @@ +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"; + +export type GetRuleTemplateResponse = { + templateId: string; + orgId: string; + name: string; + description: string | null; + createdAt: number; +}; + +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 { + 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" + ) + ); + } +} diff --git a/server/routers/ruleTemplate/index.ts b/server/routers/ruleTemplate/index.ts new file mode 100644 index 00000000..eda5475a --- /dev/null +++ b/server/routers/ruleTemplate/index.ts @@ -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"; diff --git a/server/routers/ruleTemplate/listResourceTemplates.ts b/server/routers/ruleTemplate/listResourceTemplates.ts new file mode 100644 index 00000000..4362d9a5 --- /dev/null +++ b/server/routers/ruleTemplate/listResourceTemplates.ts @@ -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>; +}; + +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 { + 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(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") + ); + } +} diff --git a/server/routers/ruleTemplate/listRuleTemplates.ts b/server/routers/ruleTemplate/listRuleTemplates.ts new file mode 100644 index 00000000..476303db --- /dev/null +++ b/server/routers/ruleTemplate/listRuleTemplates.ts @@ -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>; + 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 { + 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`cast(count(*) as integer)` }) + .from(ruleTemplates) + .where(eq(ruleTemplates.orgId, orgId)); + + const totalCount = Number(countResult[0]?.count || 0); + + return response(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") + ); + } +} diff --git a/server/routers/ruleTemplate/listTemplateRules.ts b/server/routers/ruleTemplate/listTemplateRules.ts new file mode 100644 index 00000000..f4e47395 --- /dev/null +++ b/server/routers/ruleTemplate/listTemplateRules.ts @@ -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 { + 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") + ); + } +} diff --git a/server/routers/ruleTemplate/unassignTemplateFromResource.ts b/server/routers/ruleTemplate/unassignTemplateFromResource.ts new file mode 100644 index 00000000..6f8a28b5 --- /dev/null +++ b/server/routers/ruleTemplate/unassignTemplateFromResource.ts @@ -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 { + 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") + ); + } +} diff --git a/server/routers/ruleTemplate/updateRuleTemplate.ts b/server/routers/ruleTemplate/updateRuleTemplate.ts new file mode 100644 index 00000000..1532ba6b --- /dev/null +++ b/server/routers/ruleTemplate/updateRuleTemplate.ts @@ -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 { + 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" + ) + ); + } +} diff --git a/server/routers/ruleTemplate/updateTemplateRule.ts b/server/routers/ruleTemplate/updateTemplateRule.ts new file mode 100644 index 00000000..2183a743 --- /dev/null +++ b/server/routers/ruleTemplate/updateTemplateRule.ts @@ -0,0 +1,194 @@ +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 { + 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 as any)[key] === undefined) { + delete (propagationData as any)[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 + } + + // Count affected resources for the response message + let affectedResourcesCount = 0; + try { + const { resourceTemplates } = await import("@server/db"); + const affectedResources = await db + .select() + .from(resourceTemplates) + .where(eq(resourceTemplates.templateId, templateId)); + affectedResourcesCount = affectedResources.length; + } catch (error) { + logger.error("Error counting affected resources:", error); + } + + const message = affectedResourcesCount > 0 + ? `Template rule updated successfully. Changes propagated to ${affectedResourcesCount} assigned resource${affectedResourcesCount > 1 ? 's' : ''}.` + : "Template rule updated successfully."; + + return response(res, { + data: updatedRule, + success: true, + error: false, + message, + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/setup/scriptsPg/1.11.0.ts b/server/setup/scriptsPg/1.11.0.ts new file mode 100644 index 00000000..8f4103f0 --- /dev/null +++ b/server/setup/scriptsPg/1.11.0.ts @@ -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`); +} diff --git a/server/setup/scriptsSqlite/1.11.0.ts b/server/setup/scriptsSqlite/1.11.0.ts new file mode 100644 index 00000000..4ce21eec --- /dev/null +++ b/server/setup/scriptsSqlite/1.11.0.ts @@ -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`); +} diff --git a/src/app/[orgId]/settings/rule-templates/RuleTemplatesDataTable.tsx b/src/app/[orgId]/settings/rule-templates/RuleTemplatesDataTable.tsx new file mode 100644 index 00000000..50a1c4e5 --- /dev/null +++ b/src/app/[orgId]/settings/rule-templates/RuleTemplatesDataTable.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { useTranslations } from 'next-intl'; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + createTemplate?: () => void; +} + +export function RuleTemplatesDataTable({ + columns, + data, + createTemplate +}: DataTableProps) { + + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/app/[orgId]/settings/rule-templates/RuleTemplatesTable.tsx b/src/app/[orgId]/settings/rule-templates/RuleTemplatesTable.tsx new file mode 100644 index 00000000..f67a3d4f --- /dev/null +++ b/src/app/[orgId]/settings/rule-templates/RuleTemplatesTable.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { RuleTemplatesDataTable } from "./RuleTemplatesDataTable"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Button } from "@app/components/ui/button"; +import { + ArrowRight, + ArrowUpDown, + MoreHorizontal, + Trash2, + Plus +} from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { formatAxiosError } from "@app/lib/api"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +export type TemplateRow = { + id: string; + name: string; + description: string; + orgId: string; +}; + +type RuleTemplatesTableProps = { + templates: TemplateRow[]; + orgId: string; +}; + +const createTemplateSchema = z.object({ + name: z.string().min(1, "Name is required").max(100, "Name must be less than 100 characters"), + description: z.string().max(500, "Description must be less than 500 characters").optional() +}); + +export function RuleTemplatesTable({ templates, orgId }: RuleTemplatesTableProps) { + const router = useRouter(); + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + + const form = useForm>({ + resolver: zodResolver(createTemplateSchema), + defaultValues: { + name: "", + description: "" + } + }); + + const deleteTemplate = (templateId: string) => { + api.delete(`/org/${orgId}/rule-templates/${templateId}`) + .catch((e) => { + console.error("Failed to delete template:", e); + toast({ + variant: "destructive", + title: t("ruleTemplateErrorDelete"), + description: formatAxiosError(e, t("ruleTemplateErrorDelete")) + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + }); + }; + + const handleCreateTemplate = async (values: z.infer) => { + try { + const response = await api.post(`/org/${orgId}/rule-templates`, values); + + if (response.status === 201) { + setIsCreateDialogOpen(false); + form.reset(); + toast({ + title: "Success", + description: "Rule template created successfully" + }); + router.refresh(); + } else { + toast({ + title: "Error", + description: response.data.message || "Failed to create rule template", + variant: "destructive" + }); + } + } catch (error) { + toast({ + title: "Error", + description: formatAxiosError(error, "Failed to create rule template"), + variant: "destructive" + }); + } + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "description", + header: "Description", + cell: ({ row }) => { + const template = row.original; + return ( + + {template.description || "No description provided"} + + ); + } + }, + { + id: "actions", + cell: ({ row }) => { + const template = row.original; + return ( +
+ + + + + + { + setSelectedTemplate(template); + setIsDeleteModalOpen(true); + }} + > + + Delete + + + + + + +
+ ); + } + } + ]; + + return ( + <> + {selectedTemplate && ( + { + setIsDeleteModalOpen(val); + setSelectedTemplate(null); + }} + dialog={ +
+

+ Are you sure you want to delete the template "{selectedTemplate?.name}"? +

+

This action cannot be undone and will remove all rules associated with this template.

+

This will also unassign the template from any resources that are using it.

+

+ To confirm, please type {selectedTemplate?.name} below. +

+
+ } + buttonText="Delete Template" + onConfirm={async () => deleteTemplate(selectedTemplate!.id)} + string={selectedTemplate.name} + title="Delete Rule Template" + /> + )} + + {/* Create Template Dialog */} + + + + Create Rule Template + + Create a new rule template to define access control rules + + +
+ + ( + + Name + + + + + + )} + /> + ( + + Description + +