Scoped Branch - Rule Templates:

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
import { z } from "zod";
import { db } from "@server/db";
import { ruleTemplates, templateRules, resourceTemplates } from "@server/db";
import { eq, and } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { generateId } from "@server/auth/sessions/app";
const deleteRuleTemplateSchema = z.object({
orgId: z.string().min(1),
templateId: z.string().min(1)
});
export async function deleteRuleTemplate(req: any, res: any) {
try {
const { orgId, templateId } = deleteRuleTemplateSchema.parse({
orgId: req.params.orgId,
templateId: req.params.templateId
});
// Check if template exists and belongs to the organization
const existingTemplate = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.limit(1);
if (existingTemplate.length === 0) {
return res.status(404).json({
success: false,
message: "Rule template not found"
});
}
// Delete template rules first (due to foreign key constraint)
await db
.delete(templateRules)
.where(eq(templateRules.templateId, templateId));
// Delete resource template assignments
await db
.delete(resourceTemplates)
.where(eq(resourceTemplates.templateId, templateId));
// Delete the template
await db
.delete(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)));
return res.status(200).json({
success: true,
message: "Rule template deleted successfully"
});
} catch (error) {
console.error("Error deleting rule template:", error);
return res.status(500).json({
success: false,
message: "Internal server error"
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ListResourceRulesResponse>
>(`/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<ListResourceRulesResponse>
>(`/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 }) => (
<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>
)
cell: ({ row }) => {
const isTemplateRule = row.original.templateRuleId !== null;
return (
<Select
defaultValue={row.original.action}
onValueChange={(value: "ACCEPT" | "DROP") =>
updateRule(row.original.ruleId, { action: value })
}
disabled={isTemplateRule}
>
<SelectTrigger className={`min-w-[150px] ${isTemplateRule ? 'opacity-50 cursor-not-allowed' : ''}`}>
<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>
)
cell: ({ row }) => {
const isTemplateRule = row.original.templateRuleId !== null;
return (
<Select
defaultValue={row.original.match}
onValueChange={(value: "CIDR" | "IP" | "PATH") =>
updateRule(row.original.ruleId, { match: value })
}
disabled={isTemplateRule}
>
<SelectTrigger className={`min-w-[125px] ${isTemplateRule ? 'opacity-50 cursor-not-allowed' : ''}`}>
<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
})
}
/>
)
cell: ({ row }) => {
const isTemplateRule = row.original.templateRuleId !== null;
return (
<Input
defaultValue={row.original.value}
className={`min-w-[200px] ${isTemplateRule ? 'opacity-50 cursor-not-allowed' : ''}`}
onBlur={(e) =>
updateRule(row.original.ruleId, {
value: e.target.value
})
}
disabled={isTemplateRule}
/>
);
}
},
{
accessorKey: "enabled",
header: t('enabled'),
cell: ({ row }) => (
<Switch
defaultChecked={row.original.enabled}
onCheckedChange={(val) =>
updateRule(row.original.ruleId, { enabled: val })
}
/>
)
cell: ({ row }) => {
const isTemplateRule = row.original.templateRuleId !== null;
return (
<Switch
defaultChecked={row.original.enabled}
onCheckedChange={(val) =>
updateRule(row.original.ruleId, { enabled: val })
}
disabled={isTemplateRule}
className={isTemplateRule ? 'opacity-50' : ''}
/>
);
}
},
{
id: "actions",
cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2">
<Button
variant="outline"
onClick={() => removeRule(row.original.ruleId)}
>
{t('delete')}
</Button>
</div>
)
cell: ({ row }) => {
const isTemplateRule = row.original.templateRuleId !== null;
return (
<div className="flex items-center justify-end space-x-2">
{isTemplateRule ? (
<div className="text-xs text-muted-foreground bg-muted px-1 py-0.5 rounded">
Template
</div>
) : (
<div className="text-xs text-blue-600 bg-blue-100 px-1 py-0.5 rounded">
Manual
</div>
)}
<Button
variant="outline"
onClick={() => removeRule(row.original.ruleId)}
disabled={isTemplateRule}
className={isTemplateRule ? 'opacity-50 cursor-not-allowed' : ''}
>
{t('delete')}
</Button>
</div>
);
}
}
];
@@ -754,6 +788,27 @@ export default function ResourceRules(props: {
</SettingsSectionBody>
</SettingsSection>
{/* Template Assignment Section */}
{rulesEnabled && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('ruleTemplates')}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('ruleTemplatesDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<ResourceRulesManager
resourceId={params.resourceId.toString()}
orgId={resource.orgId}
onUpdate={fetchRules}
/>
</SettingsSectionBody>
</SettingsSection>
)}
<div className="flex justify-end">
<Button
onClick={saveAllSettings}

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,154 @@
"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
} 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: t("ruleTemplateUpdated"),
description: t("ruleTemplateUpdatedDescription")
});
} 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 title={t("templateDetails")} />
</SettingsSectionHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<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>
<Button type="submit" disabled={saving}>
<Save className="w-4 h-4 mr-2" />
{saving ? t("saving") : t("save")}
</Button>
</form>
</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,28 @@
"use client";
import { useParams } from "next/navigation";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { TemplateRulesManager } from "@app/components/ruleTemplate/TemplateRulesManager";
export default function RulesPage() {
const params = useParams();
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle title="Template Rules" />
</SettingsSectionHeader>
<TemplateRulesManager
orgId={params.orgId as string}
templateId={params.templateId as string}
/>
</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

@@ -13,7 +13,8 @@ import {
TicketCheck,
User,
Globe, // Added from 'dev' branch
MonitorUp // Added from 'dev' branch
MonitorUp, // Added from 'dev' branch
Shield
} from "lucide-react";
export type SidebarNavSection = { // Added from 'dev' branch
@@ -84,6 +85,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

@@ -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,204 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
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";
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 { 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: "Success",
description: "Template assigned successfully"
});
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) => {
if (!confirm("Are you sure you want to unassign this template?")) {
return;
}
try {
const response = await api.delete(`/resource/${resourceId}/templates/${templateId}`);
if (response.status === 200 || response.status === 201) {
toast({
title: "Success",
description: "Template unassigned successfully"
});
await fetchData();
if (onUpdate) {
await onUpdate();
}
} else {
toast({
title: "Error",
description: response.data.message || "Failed to unassign template",
variant: "destructive"
});
}
} catch (error) {
toast({
title: "Error",
description: formatAxiosError(error, "Failed to unassign template"),
variant: "destructive"
});
}
};
if (loading) {
return <div>Loading...</div>;
}
return (
<div className="space-y-6">
{/* Template Assignment */}
<Card>
<CardHeader>
<CardTitle>Template Assignment</CardTitle>
<CardDescription>
Assign rule templates to this resource for consistent access control
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-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>
{resourceTemplates.map((template) => (
<div key={template.templateId} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center space-x-2">
<span className="font-medium">{template.name}</span>
<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>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,449 @@
"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 } from "lucide-react";
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 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;
}
await api.post(`/org/${orgId}/rule-templates/${templateId}/rules`, data);
toast({
title: "Success",
description: "Rule added successfully"
});
form.reset();
fetchRules();
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description: formatAxiosError(error, "Failed to add rule")
});
} finally {
setAddingRule(false);
}
};
const removeRule = async (ruleId: number) => {
try {
await api.delete(`/org/${orgId}/rule-templates/${templateId}/rules/${ruleId}`);
toast({
title: "Success",
description: "Rule removed successfully"
});
fetchRules();
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description: formatAxiosError(error, "Failed to remove rule")
});
}
};
const updateRule = async (ruleId: number, data: Partial<TemplateRule>) => {
try {
await api.put(`/org/${orgId}/rule-templates/${templateId}/rules/${ruleId}`, data);
fetchRules();
} catch (error) {
console.error("Failed to update rule:", error);
}
};
const columns: ColumnDef<TemplateRule>[] = [
{
accessorKey: "priority",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Priority
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<Input
type="number"
defaultValue={row.original.priority}
className="w-20"
onBlur={(e) =>
updateRule(row.original.ruleId, {
priority: parseInt(e.target.value, 10)
})
}
/>
)
},
{
accessorKey: "action",
header: "Action",
cell: ({ row }) => (
<Select
defaultValue={row.original.action}
onValueChange={(value) =>
updateRule(row.original.ruleId, { action: value })
}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCEPT">Accept</SelectItem>
<SelectItem value="DROP">Drop</SelectItem>
</SelectContent>
</Select>
)
},
{
accessorKey: "match",
header: "Match",
cell: ({ row }) => (
<Select
defaultValue={row.original.match}
onValueChange={(value) =>
updateRule(row.original.ruleId, { match: value })
}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IP">IP</SelectItem>
<SelectItem value="CIDR">CIDR</SelectItem>
<SelectItem value="PATH">Path</SelectItem>
</SelectContent>
</Select>
)
},
{
accessorKey: "value",
header: "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: "Enabled",
cell: ({ row }) => (
<Switch
defaultChecked={row.original.enabled}
onCheckedChange={(val) =>
updateRule(row.original.ruleId, { enabled: val })
}
/>
)
},
{
id: "actions",
cell: ({ row }) => (
<Button
variant="outline"
size="sm"
onClick={() => removeRule(row.original.ruleId)}
>
<Trash2 className="h-4 w-4" />
</Button>
)
}
];
const table = useReactTable({
data: rules,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
state: {
pagination: {
pageIndex: 0,
pageSize: 1000
}
}
});
if (loading) {
return <div>Loading rules...</div>;
}
return (
<div className="space-y-6">
<Form {...form}>
<form onSubmit={form.handleSubmit(addRule)} className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-end">
<FormField
control={form.control}
name="action"
render={({ field }) => (
<FormItem>
<FormLabel>Action</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCEPT">Accept</SelectItem>
<SelectItem value="DROP">Drop</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="match"
render={({ field }) => (
<FormItem>
<FormLabel>Match</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IP">IP</SelectItem>
<SelectItem value="CIDR">CIDR</SelectItem>
<SelectItem value="PATH">Path</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="value"
render={({ field }) => (
<FormItem>
<FormLabel>Value</FormLabel>
<FormControl>
<Input placeholder="Enter value" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority (optional)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Auto"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button type="submit" variant="secondary" disabled={addingRule}>
{addingRule ? "Adding Rule..." : "Add Rule"}
</Button>
</form>
</Form>
<div className="rounded-md border">
<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>
</div>
);
}