From 9dce7b2cdee8289d8c20f9b07f845ae8e9b258ad Mon Sep 17 00:00:00 2001 From: Adrian Astles <49412215+adrianeastles@users.noreply.github.com> Date: Thu, 7 Aug 2025 22:57:18 +0800 Subject: [PATCH 1/5] 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 --- messages/en-US.json | 21 + server/db/pg/schema.ts | 39 ++ server/db/sqlite/schema.ts | 39 ++ server/openApi.ts | 1 + server/routers/external.ts | 75 +++ server/routers/resource/listResourceRules.ts | 1 + .../routers/ruleTemplate/addTemplateRule.ts | 161 +++++++ .../ruleTemplate/assignTemplateToResource.ts | 176 +++++++ .../ruleTemplate/createRuleTemplate.ts | 121 +++++ .../ruleTemplate/deleteRuleTemplate.ts | 60 +++ .../ruleTemplate/deleteTemplateRule.ts | 100 ++++ .../routers/ruleTemplate/getRuleTemplate.ts | 69 +++ server/routers/ruleTemplate/index.ts | 12 + .../ruleTemplate/listResourceTemplates.ts | 104 ++++ .../routers/ruleTemplate/listRuleTemplates.ts | 127 +++++ .../routers/ruleTemplate/listTemplateRules.ts | 73 +++ .../unassignTemplateFromResource.ts | 130 +++++ .../ruleTemplate/updateRuleTemplate.ts | 117 +++++ .../ruleTemplate/updateTemplateRule.ts | 177 +++++++ server/setup/migrationsPg.ts | 4 +- server/setup/migrationsSqlite.ts | 2 + server/setup/scriptsPg/1.10.0.ts | 63 +++ server/setup/scriptsSqlite/1.10.0.ts | 70 +++ .../resources/[resourceId]/rules/page.tsx | 227 +++++---- .../rule-templates/RuleTemplatesDataTable.tsx | 36 ++ .../rule-templates/RuleTemplatesTable.tsx | 272 +++++++++++ .../[templateId]/general/page.tsx | 154 ++++++ .../rule-templates/[templateId]/layout.tsx | 84 ++++ .../rule-templates/[templateId]/page.tsx | 10 + .../[templateId]/rules/page.tsx | 28 ++ .../[orgId]/settings/rule-templates/page.tsx | 72 +++ src/app/navigation.tsx | 8 +- src/components/HorizontalTabs.tsx | 1 + .../ruleTemplate/ResourceRulesManager.tsx | 204 ++++++++ .../ruleTemplate/TemplateRulesManager.tsx | 449 ++++++++++++++++++ 35 files changed, 3199 insertions(+), 88 deletions(-) create mode 100644 server/routers/ruleTemplate/addTemplateRule.ts create mode 100644 server/routers/ruleTemplate/assignTemplateToResource.ts create mode 100644 server/routers/ruleTemplate/createRuleTemplate.ts create mode 100644 server/routers/ruleTemplate/deleteRuleTemplate.ts create mode 100644 server/routers/ruleTemplate/deleteTemplateRule.ts create mode 100644 server/routers/ruleTemplate/getRuleTemplate.ts create mode 100644 server/routers/ruleTemplate/index.ts create mode 100644 server/routers/ruleTemplate/listResourceTemplates.ts create mode 100644 server/routers/ruleTemplate/listRuleTemplates.ts create mode 100644 server/routers/ruleTemplate/listTemplateRules.ts create mode 100644 server/routers/ruleTemplate/unassignTemplateFromResource.ts create mode 100644 server/routers/ruleTemplate/updateRuleTemplate.ts create mode 100644 server/routers/ruleTemplate/updateTemplateRule.ts create mode 100644 server/setup/scriptsPg/1.10.0.ts create mode 100644 server/setup/scriptsSqlite/1.10.0.ts create mode 100644 src/app/[orgId]/settings/rule-templates/RuleTemplatesDataTable.tsx create mode 100644 src/app/[orgId]/settings/rule-templates/RuleTemplatesTable.tsx create mode 100644 src/app/[orgId]/settings/rule-templates/[templateId]/general/page.tsx create mode 100644 src/app/[orgId]/settings/rule-templates/[templateId]/layout.tsx create mode 100644 src/app/[orgId]/settings/rule-templates/[templateId]/page.tsx create mode 100644 src/app/[orgId]/settings/rule-templates/[templateId]/rules/page.tsx create mode 100644 src/app/[orgId]/settings/rule-templates/page.tsx create mode 100644 src/components/ruleTemplate/ResourceRulesManager.tsx create mode 100644 src/components/ruleTemplate/TemplateRulesManager.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 9986c5fd0..fcbb31cc8 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1004,6 +1004,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", @@ -1093,6 +1113,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 be4e58e21..cd8592df9 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -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; export type UserClient = InferSelectModel; export type RoleClient = InferSelectModel; export type OrgDomains = 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 5773a5f3b..aff390d3e 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -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; export type ApiKeyAction = InferSelectModel; export type ApiKeyOrg = InferSelectModel; export type OrgDomains = InferSelectModel; +export type RuleTemplate = InferSelectModel; +export type TemplateRule = InferSelectModel; +export type ResourceTemplate = InferSelectModel; diff --git a/server/openApi.ts b/server/openApi.ts index 32cdb67bd..3a6340e1d 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 5bae553e8..24998e2ba 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -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, diff --git a/server/routers/resource/listResourceRules.ts b/server/routers/resource/listResourceRules.ts index ff96afea8..2c9ffa0df 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 000000000..5845937a2 --- /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 000000000..4cb99cc72 --- /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 000000000..a0d72ea8a --- /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 000000000..6d37095b3 --- /dev/null +++ b/server/routers/ruleTemplate/deleteRuleTemplate.ts @@ -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" + }); + } +} diff --git a/server/routers/ruleTemplate/deleteTemplateRule.ts b/server/routers/ruleTemplate/deleteTemplateRule.ts new file mode 100644 index 000000000..3eda8eacd --- /dev/null +++ b/server/routers/ruleTemplate/deleteTemplateRule.ts @@ -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 { + 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") + ); + } +} diff --git a/server/routers/ruleTemplate/getRuleTemplate.ts b/server/routers/ruleTemplate/getRuleTemplate.ts new file mode 100644 index 000000000..ec1a83ac2 --- /dev/null +++ b/server/routers/ruleTemplate/getRuleTemplate.ts @@ -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 { + 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 000000000..eda5475aa --- /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 000000000..4362d9a55 --- /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 000000000..476303db9 --- /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 000000000..f4e473954 --- /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 000000000..6f8a28b5d --- /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 000000000..1532ba6b3 --- /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 000000000..6a964760b --- /dev/null +++ b/server/routers/ruleTemplate/updateTemplateRule.ts @@ -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 { + 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") + ); + } +} diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 07ece65be..a82af54db 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -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; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 15dd28d28..21a6c5f61 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -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; diff --git a/server/setup/scriptsPg/1.10.0.ts b/server/setup/scriptsPg/1.10.0.ts new file mode 100644 index 000000000..8f4103f03 --- /dev/null +++ b/server/setup/scriptsPg/1.10.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.10.0.ts b/server/setup/scriptsSqlite/1.10.0.ts new file mode 100644 index 000000000..4ce21eec6 --- /dev/null +++ b/server/setup/scriptsSqlite/1.10.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/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index 2f7d03ee2..e6fa31ac8 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -73,6 +73,7 @@ import { import { Switch } from "@app/components/ui/switch"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; +import { ResourceRulesManager } from "@app/components/ruleTemplate/ResourceRulesManager"; // Schema for rule validation const addRuleSchema = z.object({ @@ -122,29 +123,30 @@ export default function ResourceRules(props: { } }); - useEffect(() => { - const fetchRules = async () => { - try { - const res = await api.get< - AxiosResponse - >(`/resource/${params.resourceId}/rules`); - if (res.status === 200) { - setRules(res.data.data.rules); - } - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t('rulesErrorFetch'), - description: formatAxiosError( - err, - t('rulesErrorFetchDescription') - ) - }); - } finally { - setPageLoading(false); + const fetchRules = async () => { + try { + const res = await api.get< + AxiosResponse + >(`/resource/${params.resourceId}/rules`); + if (res.status === 200) { + setRules(res.data.data.rules); } - }; + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: t('rulesErrorFetch'), + description: formatAxiosError( + err, + t('rulesErrorFetchDescription') + ) + }); + } finally { + setPageLoading(false); + } + }; + + useEffect(() => { fetchRules(); }, []); @@ -208,6 +210,7 @@ export default function ResourceRules(props: { ruleId: new Date().getTime(), new: true, resourceId: resource.resourceId, + templateRuleId: null, priority, enabled: true }; @@ -434,85 +437,116 @@ export default function ResourceRules(props: { { accessorKey: "action", header: t('rulesAction'), - cell: ({ row }) => ( - - ) + cell: ({ row }) => { + const isTemplateRule = row.original.templateRuleId !== null; + return ( + + ); + } }, { accessorKey: "match", header: t('rulesMatchType'), - cell: ({ row }) => ( - - ) + cell: ({ row }) => { + const isTemplateRule = row.original.templateRuleId !== null; + return ( + + ); + } }, { accessorKey: "value", header: t('value'), - cell: ({ row }) => ( - - updateRule(row.original.ruleId, { - value: e.target.value - }) - } - /> - ) + cell: ({ row }) => { + const isTemplateRule = row.original.templateRuleId !== null; + return ( + + updateRule(row.original.ruleId, { + value: e.target.value + }) + } + disabled={isTemplateRule} + /> + ); + } }, { accessorKey: "enabled", header: t('enabled'), - cell: ({ row }) => ( - - updateRule(row.original.ruleId, { enabled: val }) - } - /> - ) + cell: ({ row }) => { + const isTemplateRule = row.original.templateRuleId !== null; + return ( + + updateRule(row.original.ruleId, { enabled: val }) + } + disabled={isTemplateRule} + className={isTemplateRule ? 'opacity-50' : ''} + /> + ); + } }, { id: "actions", - cell: ({ row }) => ( -
- -
- ) + cell: ({ row }) => { + const isTemplateRule = row.original.templateRuleId !== null; + return ( +
+ {isTemplateRule ? ( +
+ Template +
+ ) : ( +
+ Manual +
+ )} + +
+ ); + } } ]; @@ -754,6 +788,27 @@ export default function ResourceRules(props: { + {/* Template Assignment Section */} + {rulesEnabled && ( + + + + {t('ruleTemplates')} + + + {t('ruleTemplatesDescription')} + + + + + + + )} +
+ ); + } + }, + { + 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 + +