Compare commits

...

7 Commits

Author SHA1 Message Date
Owen
7fa44981cf Merge branch 'adrianeastles-feature/resource-rule-templates' into policies 2025-10-06 20:55:04 -07:00
Owen
1bacad7854 Merge branch 'feature/resource-rule-templates' of github.com:adrianeastles/pangolin into adrianeastles-feature/resource-rule-templates 2025-10-06 20:54:43 -07:00
Adrian Astles
75cec731e8 Resource Rules page:
Split into 3 clear sections: Enabled Rules (with explanation), Rule Templates, and Resource Rules Configuration
Hide Rules Configuration when rules are disabled

Rule Template pages:
Rules: adopt Settings section layout; right-aligned “Add Rule” button that opens a Create Rule dialog; remove inline add form; consistent table styling
2025-08-08 19:30:26 +08:00
Adrian Astles
16a88281bb Added better notifications for users when templates are updated.
Added confirmation dialogs for destructive actions.
Other improvements/changes
When deleting rule templates, we now clean up all resource rules that were created from the template.
2025-08-07 23:49:56 +08:00
Adrian Astles
1574cbc5df Pagination for template rules table and resource rules table. 2025-08-07 23:23:20 +08:00
Adrian Astles
2cb2a115b0 align template rules table columns with resource rules page 2025-08-07 23:14:24 +08:00
Adrian Astles
9dce7b2cde 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
2025-08-07 22:57:18 +08:00
33 changed files with 3411 additions and 3 deletions

View File

@@ -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",

View File

@@ -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<typeof orgDomains>;
export type SiteResource = InferSelectModel<typeof siteResources>;
export type SetupToken = InferSelectModel<typeof setupTokens>;
export type HostMeta = InferSelectModel<typeof hostMeta>;
export type TargetHealthCheck = InferSelectModel<typeof targetHealthCheck>;
export type TargetHealthCheck = InferSelectModel<typeof targetHealthCheck>;
export type RuleTemplate = InferSelectModel<typeof ruleTemplates>;
export type TemplateRule = InferSelectModel<typeof templateRules>;
export type ResourceTemplate = InferSelectModel<typeof resourceTemplates>;

View File

@@ -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<typeof siteResources>;
export type OrgDomains = InferSelectModel<typeof orgDomains>;
export type SetupToken = InferSelectModel<typeof setupTokens>;
export type HostMeta = InferSelectModel<typeof hostMeta>;
export type TargetHealthCheck = InferSelectModel<typeof targetHealthCheck>;
export type TargetHealthCheck = InferSelectModel<typeof targetHealthCheck>;
export type RuleTemplate = InferSelectModel<typeof ruleTemplates>;
export type TemplateRule = InferSelectModel<typeof templateRules>;
export type ResourceTemplate = InferSelectModel<typeof resourceTemplates>;

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,121 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { ruleTemplates } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { generateId } from "@server/auth/sessions/app";
const createRuleTemplateParamsSchema = z
.object({
orgId: z.string().min(1)
})
.strict();
const createRuleTemplateBodySchema = z
.object({
name: z.string().min(1).max(100).refine(name => name.trim().length > 0, {
message: "Template name cannot be empty or just whitespace"
}),
description: z.string().max(500).optional()
})
.strict();
registry.registerPath({
method: "post",
path: "/org/{orgId}/rule-templates",
description: "Create a rule template.",
tags: [OpenAPITags.Org, OpenAPITags.RuleTemplate],
request: {
params: createRuleTemplateParamsSchema,
body: {
content: {
"application/json": {
schema: createRuleTemplateBodySchema
}
}
}
},
responses: {}
});
export async function createRuleTemplate(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = createRuleTemplateParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = createRuleTemplateBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const { name, description } = parsedBody.data;
// Check if template with same name already exists
const existingTemplate = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.name, name)))
.limit(1);
if (existingTemplate.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`A template with the name "${name}" already exists in this organization`
)
);
}
const templateId = generateId(15);
const createdAt = Date.now();
const [newTemplate] = await db
.insert(ruleTemplates)
.values({
templateId,
orgId,
name,
description: description || null,
createdAt
})
.returning();
return response(res, {
data: newTemplate,
success: true,
error: false,
message: "Rule template created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,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"
});
}
}

View File

@@ -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<any> {
try {
const parsedParams = deleteTemplateRuleParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, templateId, ruleId } = parsedParams.data;
// Check if template exists and belongs to the organization
const existingTemplate = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.limit(1);
if (existingTemplate.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Rule template not found"
)
);
}
// Check if rule exists and belongs to the template
const existingRule = await db
.select()
.from(templateRules)
.where(and(eq(templateRules.templateId, templateId), eq(templateRules.ruleId, parseInt(ruleId))))
.limit(1);
if (existingRule.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Template rule not found"
)
);
}
// 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")
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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<any> {
try {
const parsedParams = updateTemplateRuleParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = updateTemplateRuleBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId, templateId, ruleId } = parsedParams.data;
const updateData = parsedBody.data;
// Check if template exists and belongs to the organization
const existingTemplate = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.limit(1);
if (existingTemplate.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Rule template not found"
)
);
}
// Check if rule exists and belongs to the template
const existingRule = await db
.select()
.from(templateRules)
.where(and(eq(templateRules.templateId, templateId), eq(templateRules.ruleId, parseInt(ruleId))))
.limit(1);
if (existingRule.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Template rule not found"
)
);
}
// Validate the value if it's being updated
if (updateData.value && updateData.match) {
if (updateData.match === "CIDR" && !isValidCIDR(updateData.value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid CIDR format"
)
);
}
if (updateData.match === "IP" && !isValidIP(updateData.value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid IP address format"
)
);
}
if (updateData.match === "PATH" && !isValidUrlGlobPattern(updateData.value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid URL pattern format"
)
);
}
}
// Update the rule
const [updatedRule] = await db
.update(templateRules)
.set(updateData)
.where(and(eq(templateRules.templateId, templateId), eq(templateRules.ruleId, parseInt(ruleId))))
.returning();
// Propagate changes to all resource rules created from this template rule
try {
const { resourceRules } = await import("@server/db");
// Find all resource rules that were created from this template rule
const affectedResourceRules = await db
.select()
.from(resourceRules)
.where(eq(resourceRules.templateRuleId, parseInt(ruleId)));
if (affectedResourceRules.length > 0) {
// Update all affected resource rules with the same changes
// Note: We don't update priority as that should remain independent
const propagationData = {
...updateData,
priority: undefined // Don't propagate priority changes
};
// Remove undefined values
Object.keys(propagationData).forEach(key => {
if ((propagationData 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")
);
}
}

View File

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

View File

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

View File

@@ -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<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
createTemplate?: () => void;
}
export function RuleTemplatesDataTable<TData, TValue>({
columns,
data,
createTemplate
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
title={t('ruleTemplates')}
searchPlaceholder={t('ruleTemplatesSearch')}
searchColumn="name"
onAdd={createTemplate}
addButtonText={t('ruleTemplateAdd')}
defaultSort={{
id: "name",
desc: false
}}
/>
);
}

View File

@@ -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<TemplateRow | null>(null);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const form = useForm<z.infer<typeof createTemplateSchema>>({
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<typeof createTemplateSchema>) => {
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<TemplateRow>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => {
const template = row.original;
return (
<span className="text-muted-foreground">
{template.description || "No description provided"}
</span>
);
}
},
{
id: "actions",
cell: ({ row }) => {
const template = row.original;
return (
<div className="flex items-center justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedTemplate(template);
setIsDeleteModalOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
<span className="text-red-500">Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${template.orgId}/settings/rule-templates/${template.id}`}
>
<Button
variant="secondary"
className="ml-2"
size="sm"
>
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
return (
<>
{selectedTemplate && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedTemplate(null);
}}
dialog={
<div>
<p className="mb-2">
Are you sure you want to delete the template "{selectedTemplate?.name}"?
</p>
<p className="mb-2">This action cannot be undone and will remove all rules associated with this template.</p>
<p className="mb-2">This will also unassign the template from any resources that are using it.</p>
<p className="text-sm text-muted-foreground">
To confirm, please type <span className="font-mono font-medium">{selectedTemplate?.name}</span> below.
</p>
</div>
}
buttonText="Delete Template"
onConfirm={async () => deleteTemplate(selectedTemplate!.id)}
string={selectedTemplate.name}
title="Delete Rule Template"
/>
)}
{/* Create Template Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Rule Template</DialogTitle>
<DialogDescription>
Create a new rule template to define access control rules
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleCreateTemplate)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Enter template name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Enter template description (optional)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
Cancel
</Button>
<Button type="submit">Create Template</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
<RuleTemplatesDataTable
columns={columns}
data={templates}
createTemplate={() => setIsCreateDialogOpen(true)}
/>
</>
);
}

View File

@@ -0,0 +1,166 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionFooter,
SettingsSectionForm
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import { Textarea } from "@app/components/ui/textarea";
import { Save } from "lucide-react";
const updateTemplateSchema = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional()
});
type UpdateTemplateForm = z.infer<typeof updateTemplateSchema>;
export default function GeneralPage() {
const params = useParams();
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [template, setTemplate] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const {
register,
handleSubmit,
setValue,
formState: { errors }
} = useForm<UpdateTemplateForm>({
resolver: zodResolver(updateTemplateSchema)
});
useEffect(() => {
const fetchTemplate = async () => {
if (!params.orgId || !params.templateId) return;
try {
const response = await api.get(
`/org/${params.orgId}/rule-templates/${params.templateId}`
);
setTemplate(response.data.data);
setValue("name", response.data.data.name);
setValue("description", response.data.data.description || "");
} catch (error) {
toast({
title: t("ruleTemplateErrorLoad"),
description: formatAxiosError(error, t("ruleTemplateErrorLoadDescription")),
variant: "destructive"
});
} finally {
setLoading(false);
}
};
fetchTemplate();
}, [params.orgId, params.templateId, setValue, t]);
const onSubmit = async (data: UpdateTemplateForm) => {
if (!params.orgId || !params.templateId) return;
setSaving(true);
try {
await api.put(
`/org/${params.orgId}/rule-templates/${params.templateId}`,
data
);
toast({
title: "Template Updated",
description: "Template details have been updated successfully. Changes to template rules will automatically propagate to all assigned resources.",
variant: "default"
});
} catch (error) {
toast({
title: t("ruleTemplateErrorUpdate"),
description: formatAxiosError(error, t("ruleTemplateErrorUpdateDescription")),
variant: "destructive"
});
} finally {
setSaving(false);
}
};
if (loading) {
return (
<SettingsContainer>
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Loading...</div>
</div>
</SettingsContainer>
);
}
if (!template) {
return (
<SettingsContainer>
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Template not found</div>
</div>
</SettingsContainer>
);
}
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("templateDetails")}
</SettingsSectionTitle>
<SettingsSectionDescription>
Update the name and description for this rule template.
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" id="template-general-form">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-2">
{t("name")}
</label>
<Input
id="name"
{...register("name")}
className={errors.name ? "border-red-500" : ""}
/>
{errors.name && (
<p className="text-red-500 text-sm mt-1">{errors.name.message}</p>
)}
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium mb-2">
{t("description")}
</label>
<Textarea id="description" {...register("description")} rows={3} />
</div>
</form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button type="submit" form="template-general-form" disabled={saving}>
<Save className="w-4 h-4 mr-2" />
{saving ? t("saving") : t("save")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}

View File

@@ -0,0 +1,84 @@
import { internal } from "@app/lib/api";
import { GetRuleTemplateResponse } from "@server/routers/ruleTemplate";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import { cache } from "react";
import { getTranslations } from 'next-intl/server';
interface RuleTemplateLayoutProps {
children: React.ReactNode;
params: Promise<{ templateId: string; orgId: string }>;
}
export default async function RuleTemplateLayout(props: RuleTemplateLayoutProps) {
const params = await props.params;
const t = await getTranslations();
const { children } = props;
let template = null;
try {
const res = await internal.get<AxiosResponse<GetRuleTemplateResponse>>(
`/org/${params.orgId}/rule-templates/${params.templateId}`,
await authCookieHeader()
);
template = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/rule-templates`);
}
if (!template) {
redirect(`/${params.orgId}/settings/rule-templates`);
}
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
await authCookieHeader()
)
);
const res = await getOrg();
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/rule-templates`);
}
if (!org) {
redirect(`/${params.orgId}/settings/rule-templates`);
}
const navItems = [
{
title: t('general'),
href: `/{orgId}/settings/rule-templates/{templateId}/general`
},
{
title: t('rules'),
href: `/{orgId}/settings/rule-templates/{templateId}/rules`
}
];
return (
<>
<SettingsSectionTitle
title={t('ruleTemplateSetting', {templateName: template?.name})}
description={t('ruleTemplateSettingDescription')}
/>
<OrgProvider org={org}>
<div className="space-y-6">
<HorizontalTabs items={navItems}>
{children}
</HorizontalTabs>
</div>
</OrgProvider>
</>
);
}

View File

@@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
export default async function RuleTemplatePage(props: {
params: Promise<{ templateId: string; orgId: string }>;
}) {
const params = await props.params;
redirect(
`/${params.orgId}/settings/rule-templates/${params.templateId}/general`
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import { useParams } from "next/navigation";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody
} from "@app/components/Settings";
import { TemplateRulesManager } from "@app/components/ruleTemplate/TemplateRulesManager";
import { useTranslations } from "next-intl";
export default function RulesPage() {
const params = useParams();
const t = useTranslations();
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('ruleTemplates')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Manage the rules for this template. Changes propagate to all assigned resources.
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<TemplateRulesManager
orgId={params.orgId as string}
templateId={params.templateId as string}
/>
</SettingsSectionBody>
</SettingsSection>
</SettingsContainer>
);
}

View File

@@ -0,0 +1,72 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";
import { redirect } from "next/navigation";
import OrgProvider from "@app/providers/OrgProvider";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import { RuleTemplatesTable } from "./RuleTemplatesTable";
type RuleTemplatesPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function RuleTemplatesPage(props: RuleTemplatesPageProps) {
const params = await props.params;
const t = await getTranslations();
let templates: any[] = [];
try {
const res = await internal.get<AxiosResponse<any>>(
`/org/${params.orgId}/rule-templates`,
await authCookieHeader()
);
templates = res.data.data.templates || [];
} catch (e) {
console.error("Failed to fetch rule templates:", e);
}
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
await authCookieHeader()
)
);
const res = await getOrg();
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/rule-templates`);
}
if (!org) {
redirect(`/${params.orgId}/settings/rule-templates`);
}
const templateRows = templates.map((template) => {
return {
id: template.templateId,
name: template.name,
description: template.description || "",
orgId: params.orgId
};
});
return (
<>
<SettingsSectionTitle
title="Rule Templates"
description="Create and manage rule templates for consistent access control across your resources"
/>
<OrgProvider org={org}>
<RuleTemplatesTable templates={templateRows} orgId={params.orgId} />
</OrgProvider>
</>
);
}

View File

@@ -15,7 +15,8 @@ import {
Globe, // Added from 'dev' branch
MonitorUp, // Added from 'dev' branch
Server,
Zap
Zap,
Shield
} from "lucide-react";
export type SidebarNavSection = {
@@ -105,6 +106,11 @@ export const orgNavSections = (
title: "sidebarShareableLinks",
href: "/{orgId}/settings/share-links",
icon: <LinkIcon className="h-4 w-4" />
},
{
title: "sidebarRuleTemplates",
href: "/{orgId}/settings/rule-templates",
icon: <Shield className="h-4 w-4" />
}
]
},

View File

@@ -0,0 +1,78 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@app/components/ui/dialog";
import { Button } from "@app/components/ui/button";
import { AlertTriangle } from "lucide-react";
interface ConfirmationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmText?: string;
cancelText?: string;
variant?: "destructive" | "default";
onConfirm: () => Promise<void> | void;
loading?: boolean;
}
export function ConfirmationDialog({
open,
onOpenChange,
title,
description,
confirmText = "Confirm",
cancelText = "Cancel",
variant = "destructive",
onConfirm,
loading = false
}: ConfirmationDialogProps) {
const handleConfirm = async () => {
try {
await onConfirm();
onOpenChange(false);
} catch (error) {
// Error handling is done by the calling component
console.error("Confirmation action failed:", error);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
{title}
</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
{cancelText}
</Button>
<Button
variant={variant}
onClick={handleConfirm}
disabled={loading}
>
{loading ? "Processing..." : confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -36,6 +36,7 @@ export function HorizontalTabs({
return href
.replace("{orgId}", params.orgId as string)
.replace("{resourceId}", params.resourceId as string)
.replace("{templateId}", params.templateId as string)
.replace("{niceId}", params.niceId as string)
.replace("{userId}", params.userId as string)
.replace("{clientId}", params.clientId as string)

View File

@@ -0,0 +1,224 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@app/hooks/useToast";
import { Trash2 } from "lucide-react";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { ConfirmationDialog } from "@app/components/ConfirmationDialog";
interface RuleTemplate {
templateId: string;
name: string;
description: string;
orgId: string;
createdAt: string;
}
interface ResourceTemplate {
templateId: string;
name: string;
description: string;
orgId: string;
createdAt: string;
}
export function ResourceRulesManager({
resourceId,
orgId,
onUpdate
}: {
resourceId: string;
orgId: string;
onUpdate?: () => Promise<void>;
}) {
const [templates, setTemplates] = useState<RuleTemplate[]>([]);
const [resourceTemplates, setResourceTemplates] = useState<ResourceTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [selectedTemplate, setSelectedTemplate] = useState<string>("");
const [unassignDialogOpen, setUnassignDialogOpen] = useState(false);
const [templateToUnassign, setTemplateToUnassign] = useState<string | null>(null);
const [unassigning, setUnassigning] = useState(false);
const { toast } = useToast();
const { env } = useEnvContext();
const api = createApiClient({ env });
useEffect(() => {
fetchData();
}, [resourceId, orgId]);
const fetchData = async () => {
try {
const [templatesRes, resourceTemplatesRes] = await Promise.all([
api.get(`/org/${orgId}/rule-templates`),
api.get(`/resource/${resourceId}/templates`)
]);
if (templatesRes.status === 200) {
setTemplates(templatesRes.data.data.templates || []);
}
if (resourceTemplatesRes.status === 200) {
setResourceTemplates(resourceTemplatesRes.data.data.templates || []);
}
} catch (error) {
console.error("Error fetching data:", error);
toast({
title: "Error",
description: formatAxiosError(error, "Failed to fetch data"),
variant: "destructive"
});
} finally {
setLoading(false);
}
};
const handleAssignTemplate = async (templateId: string) => {
if (!templateId) return;
try {
const response = await api.put(`/resource/${resourceId}/templates/${templateId}`);
if (response.status === 200 || response.status === 201) {
toast({
title: "Template Assigned",
description: "Template has been assigned to this resource. All template rules have been applied and will be automatically updated when the template changes.",
variant: "default"
});
setSelectedTemplate("");
await fetchData();
if (onUpdate) {
await onUpdate();
}
} else {
toast({
title: "Error",
description: response.data.message || "Failed to assign template",
variant: "destructive"
});
}
} catch (error) {
toast({
title: "Error",
description: formatAxiosError(error, "Failed to assign template"),
variant: "destructive"
});
}
};
const handleUnassignTemplate = async (templateId: string) => {
setTemplateToUnassign(templateId);
setUnassignDialogOpen(true);
};
const confirmUnassignTemplate = async () => {
if (!templateToUnassign) return;
setUnassigning(true);
try {
const response = await api.delete(`/resource/${resourceId}/templates/${templateToUnassign}`);
if (response.status === 200 || response.status === 201) {
toast({
title: "Template Unassigned",
description: "Template has been unassigned from this resource. All template-managed rules have been removed from this resource.",
variant: "default"
});
await fetchData();
if (onUpdate) {
await onUpdate();
}
} else {
toast({
title: "Unassign Failed",
description: response.data.message || "Failed to unassign template. Please try again.",
variant: "destructive"
});
}
} catch (error) {
toast({
title: "Unassign Failed",
description: formatAxiosError(error, "Failed to unassign template. Please try again."),
variant: "destructive"
});
} finally {
setUnassigning(false);
setTemplateToUnassign(null);
}
};
if (loading) {
return <div>Loading...</div>;
}
return (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Select
value={selectedTemplate}
onValueChange={(value) => {
setSelectedTemplate(value);
handleAssignTemplate(value);
}}
>
<SelectTrigger className="w-64">
<SelectValue placeholder="Select a template to assign" />
</SelectTrigger>
<SelectContent>
{templates.map((template) => (
<SelectItem key={template.templateId} value={template.templateId}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{resourceTemplates.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium">Assigned Templates</h4>
<div className="space-y-2">
{resourceTemplates.map((template) => (
<div
key={template.templateId}
className="flex items-center justify-between p-3 border rounded-md bg-muted/30"
>
<div className="flex items-center gap-2">
<span className="font-medium">{template.name}</span>
{template.description && (
<span className="text-sm text-muted-foreground">{template.description}</span>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleUnassignTemplate(template.templateId)}
>
<Trash2 className="mr-2 h-4 w-4" />
Unassign
</Button>
</div>
))}
</div>
</div>
)}
</div>
<ConfirmationDialog
open={unassignDialogOpen}
onOpenChange={setUnassignDialogOpen}
title="Unassign Template"
description="Are you sure you want to unassign this template? This will remove all template-managed rules from this resource. This action cannot be undone."
confirmText="Unassign Template"
cancelText="Cancel"
variant="destructive"
onConfirm={confirmUnassignTemplate}
loading={unassigning}
/>
</div>
);
}

View File

@@ -0,0 +1,626 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { formatAxiosError } from "@app/lib/api";
import { toast } from "@app/hooks/useToast";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { Switch } from "@app/components/ui/switch";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
ColumnDef,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
getCoreRowModel,
useReactTable,
flexRender
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "@server/lib/validators";
import { ArrowUpDown, Trash2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { ConfirmationDialog } from "@app/components/ConfirmationDialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@app/components/ui/dialog";
const addRuleSchema = z.object({
action: z.enum(["ACCEPT", "DROP"]),
match: z.enum(["CIDR", "IP", "PATH"]),
value: z.string().min(1),
priority: z.coerce.number().int().optional()
});
type TemplateRule = {
ruleId: number;
templateId: string;
enabled: boolean;
priority: number;
action: string;
match: string;
value: string;
};
type TemplateRulesManagerProps = {
templateId: string;
orgId: string;
};
export function TemplateRulesManager({ templateId, orgId }: TemplateRulesManagerProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [rules, setRules] = useState<TemplateRule[]>([]);
const [loading, setLoading] = useState(true);
const [addingRule, setAddingRule] = useState(false);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 25
});
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [ruleToDelete, setRuleToDelete] = useState<number | null>(null);
const [deletingRule, setDeletingRule] = useState(false);
const RuleAction = {
ACCEPT: t('alwaysAllow'),
DROP: t('alwaysDeny')
} as const;
const RuleMatch = {
PATH: t('path'),
IP: "IP",
CIDR: t('ipAddressRange')
} as const;
const form = useForm<z.infer<typeof addRuleSchema>>({
resolver: zodResolver(addRuleSchema),
defaultValues: {
action: "ACCEPT",
match: "IP",
value: "",
priority: undefined
}
});
const fetchRules = async () => {
try {
const response = await api.get(`/org/${orgId}/rule-templates/${templateId}/rules`);
setRules(response.data.data.rules);
} catch (error) {
console.error("Failed to fetch template rules:", error);
toast({
variant: "destructive",
title: "Error",
description: formatAxiosError(error, "Failed to fetch template rules")
});
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchRules();
}, [templateId, orgId]);
const addRule = async (data: z.infer<typeof addRuleSchema>) => {
try {
setAddingRule(true);
// Validate the value based on match type
if (data.match === "CIDR" && !isValidCIDR(data.value)) {
toast({
variant: "destructive",
title: "Invalid CIDR format",
description: "Please enter a valid CIDR notation (e.g., 192.168.1.0/24)"
});
return;
}
if (data.match === "IP" && !isValidIP(data.value)) {
toast({
variant: "destructive",
title: "Invalid IP address",
description: "Please enter a valid IP address"
});
return;
}
if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) {
toast({
variant: "destructive",
title: "Invalid URL pattern",
description: "Please enter a valid URL pattern"
});
return;
}
const response = await api.post(`/org/${orgId}/rule-templates/${templateId}/rules`, data);
toast({
title: "Template Rule Added",
description: "A new rule has been added to the template. It will be available for assignment to resources.",
variant: "default"
});
form.reset();
fetchRules();
} catch (error) {
toast({
variant: "destructive",
title: "Add Rule Failed",
description: formatAxiosError(error, "Failed to add rule. Please check your input and try again.")
});
} finally {
setAddingRule(false);
}
};
const removeRule = async (ruleId: number) => {
setRuleToDelete(ruleId);
setDeleteDialogOpen(true);
};
const confirmDeleteRule = async () => {
if (!ruleToDelete) return;
setDeletingRule(true);
try {
await api.delete(`/org/${orgId}/rule-templates/${templateId}/rules/${ruleToDelete}`);
toast({
title: "Template Rule Removed",
description: "The rule has been removed from the template and from all assigned resources.",
variant: "default"
});
fetchRules();
} catch (error) {
toast({
variant: "destructive",
title: "Removal Failed",
description: formatAxiosError(error, "Failed to remove template rule")
});
} finally {
setDeletingRule(false);
setRuleToDelete(null);
}
};
const updateRule = async (ruleId: number, data: Partial<TemplateRule>) => {
try {
const response = await api.put(`/org/${orgId}/rule-templates/${templateId}/rules/${ruleId}`, data);
// Show success notification with propagation info if available
const message = response.data?.message || "The template rule has been updated and changes have been propagated to all assigned resources.";
toast({
title: "Template Rule Updated",
description: message,
variant: "default"
});
fetchRules();
} catch (error) {
console.error("Failed to update rule:", error);
toast({
title: "Update Failed",
description: formatAxiosError(error, "Failed to update template rule. Please try again."),
variant: "destructive"
});
}
};
const columns: ColumnDef<TemplateRule>[] = [
{
accessorKey: "priority",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('rulesPriority')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => (
<Input
defaultValue={row.original.priority}
className="w-[75px]"
type="number"
onBlur={(e) => {
const parsed = z.coerce
.number()
.int()
.optional()
.safeParse(e.target.value);
if (!parsed.data) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidIpAddress'),
description: t('rulesErrorInvalidPriorityDescription')
});
return;
}
updateRule(row.original.ruleId, {
priority: parsed.data
});
}}
/>
)
},
{
accessorKey: "action",
header: t('rulesAction'),
cell: ({ row }) => (
<Select
defaultValue={row.original.action}
onValueChange={(value: "ACCEPT" | "DROP") =>
updateRule(row.original.ruleId, { action: value })
}
>
<SelectTrigger className="min-w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCEPT">
{RuleAction.ACCEPT}
</SelectItem>
<SelectItem value="DROP">{RuleAction.DROP}</SelectItem>
</SelectContent>
</Select>
)
},
{
accessorKey: "match",
header: t('rulesMatchType'),
cell: ({ row }) => (
<Select
defaultValue={row.original.match}
onValueChange={(value: "CIDR" | "IP" | "PATH") =>
updateRule(row.original.ruleId, { match: value })
}
>
<SelectTrigger className="min-w-[125px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
</SelectContent>
</Select>
)
},
{
accessorKey: "value",
header: t('value'),
cell: ({ row }) => (
<Input
defaultValue={row.original.value}
className="min-w-[200px]"
onBlur={(e) =>
updateRule(row.original.ruleId, { value: e.target.value })
}
/>
)
},
{
accessorKey: "enabled",
header: t('enabled'),
cell: ({ row }) => (
<Switch
defaultChecked={row.original.enabled}
onCheckedChange={(val) =>
updateRule(row.original.ruleId, { enabled: val })
}
/>
)
},
{
id: "actions",
cell: ({ row }) => (
<Button
variant="outline"
onClick={() => removeRule(row.original.ruleId)}
>
{t('delete')}
</Button>
)
}
];
const table = useReactTable({
data: rules,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
state: {
pagination
},
onPaginationChange: setPagination,
manualPagination: false
});
if (loading) {
return <div className="text-muted-foreground">Loading...</div>;
}
return (
<div className="space-y-6">
<div className="flex justify-end">
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogTrigger asChild>
<Button variant="secondary" disabled={addingRule}>
{addingRule ? "Adding Rule..." : t('ruleSubmit')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('ruleSubmit')}</DialogTitle>
<DialogDescription>
{t('rulesResourceDescription')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(async (data) => {
await addRule(data);
setCreateDialogOpen(false);
})}
className="space-y-4"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="action"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesAction')}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCEPT">{RuleAction.ACCEPT}</SelectItem>
<SelectItem value="DROP">{RuleAction.DROP}</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="match"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesMatchType')}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="value"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>{t('value')}</FormLabel>
<FormControl>
<Input placeholder="Enter value" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesPriority')} (optional)</FormLabel>
<FormControl>
<Input type="number" placeholder="Auto" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button type="submit" variant="secondary" disabled={addingRule}>
{addingRule ? "Adding Rule..." : t('ruleSubmit')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
<div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No rules found. Add your first rule above.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination Controls */}
{rules.length > 0 && (
<div className="flex items-center justify-between space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
Showing {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1} to{" "}
{Math.min(
(table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length
)}{" "}
of {table.getFilteredRowModel().rows.length} rules
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
{/* Confirmation Dialog */}
<ConfirmationDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title="Delete Template Rule"
description="Are you sure you want to delete this rule? This action will remove the rule from the template and from all assigned resources. This action cannot be undone."
confirmText="Delete Rule"
cancelText="Cancel"
variant="destructive"
onConfirm={confirmDeleteRule}
loading={deletingRule}
/>
</div>
);
}