diff --git a/apps/api/src/routes/admin-org-details.spec.ts b/apps/api/src/routes/admin-org-details.spec.ts index 0a24b25117..1983811b9e 100644 --- a/apps/api/src/routes/admin-org-details.spec.ts +++ b/apps/api/src/routes/admin-org-details.spec.ts @@ -199,6 +199,31 @@ describe("admin organization details endpoints", () => { }, ]); + // A project override must not leak into the org-level config or rules. + await db.insert(tables.project).values({ + id: "guardrail-project", + organizationId: ORG_ID, + name: "Guardrail Project", + }); + await db.insert(tables.guardrailConfig).values({ + id: "gc-project", + organizationId: ORG_ID, + projectId: "guardrail-project", + inheritOrganization: false, + enabled: true, + maxFileSizeMb: 1, + }); + await db.insert(tables.guardrailRule).values({ + id: "gr-project", + organizationId: ORG_ID, + projectId: "guardrail-project", + name: "Project only", + type: "custom_regex", + config: { type: "custom_regex", pattern: "\\bACC-\\d{9}\\b" }, + priority: 20, + action: "redact", + }); + const res = await get("/guardrails", cookie); expect(res.status).toBe(200); const json = await res.json(); diff --git a/apps/api/src/routes/admin-org-details.ts b/apps/api/src/routes/admin-org-details.ts index c8a397ea53..c7d653d1b1 100644 --- a/apps/api/src/routes/admin-org-details.ts +++ b/apps/api/src/routes/admin-org-details.ts @@ -467,11 +467,15 @@ adminOrgDetails.openapi(getOrganizationGuardrails, async (c) => { db.query.guardrailConfig.findFirst({ where: { organizationId: { eq: orgId }, + projectId: { isNull: true }, }, }), + // Org-level rules only, to match the org-level config above — project + // overrides are configured and enforced separately. db.query.guardrailRule.findMany({ where: { organizationId: { eq: orgId }, + projectId: { isNull: true }, }, orderBy: { priority: "asc", diff --git a/apps/api/src/routes/guardrails-project.spec.ts b/apps/api/src/routes/guardrails-project.spec.ts new file mode 100644 index 0000000000..3bdb0f44ac --- /dev/null +++ b/apps/api/src/routes/guardrails-project.spec.ts @@ -0,0 +1,280 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { app } from "@/index.js"; +import { createTestUser, deleteAll } from "@/testing.js"; + +import { db, eq, tables } from "@llmgateway/db"; + +describe("project guardrails API", () => { + let token: string; + + afterEach(async () => { + await deleteAll(); + }); + + beforeEach(async () => { + token = await createTestUser(); + + await db.insert(tables.organization).values({ + id: "test-org-id", + name: "Test Organization", + billingEmail: "test@example.com", + plan: "enterprise", + }); + + await db.insert(tables.userOrganization).values({ + id: "test-user-org-id", + userId: "test-user-id", + organizationId: "test-org-id", + }); + + await db.insert(tables.project).values([ + { + id: "test-project-id", + name: "Test Project", + organizationId: "test-org-id", + }, + { + id: "other-project-id", + name: "Other Project", + organizationId: "test-org-id", + }, + ]); + }); + + function authed(path: string, init: RequestInit = {}) { + return app.request(path, { + ...init, + headers: { + "Content-Type": "application/json", + Cookie: token, + }, + }); + } + + test("requires authentication", async () => { + const res = await app.request( + "/guardrails/projects/test-project-id/config", + ); + expect(res.status).toBe(401); + }); + + test("developers cannot manage project guardrails", async () => { + await db + .update(tables.userOrganization) + .set({ role: "developer" }) + .where(eq(tables.userOrganization.id, "test-user-org-id")); + + const res = await authed("/guardrails/projects/test-project-id/config"); + expect(res.status).toBe(403); + }); + + test("non-enterprise organizations are rejected", async () => { + await db + .update(tables.organization) + .set({ plan: "pro" }) + .where(eq(tables.organization.id, "test-org-id")); + + const res = await authed("/guardrails/projects/test-project-id/config"); + expect(res.status).toBe(403); + }); + + test("rejects a project outside the user's organizations", async () => { + await db.insert(tables.organization).values({ + id: "foreign-org-id", + name: "Foreign Organization", + billingEmail: "foreign@example.com", + plan: "enterprise", + }); + await db.insert(tables.project).values({ + id: "foreign-project-id", + name: "Foreign Project", + organizationId: "foreign-org-id", + }); + + const res = await authed("/guardrails/projects/foreign-project-id/config"); + expect(res.status).toBe(403); + }); + + test("project config starts empty and defaults to inheriting", async () => { + const res = await authed("/guardrails/projects/test-project-id/config"); + expect(res.status).toBe(200); + expect(await res.json()).toBeNull(); + + const saved = await authed("/guardrails/projects/test-project-id/config", { + method: "PUT", + body: JSON.stringify({ enabled: true }), + }); + expect(saved.status).toBe(200); + + const body = await saved.json(); + expect(body.projectId).toBe("test-project-id"); + expect(body.organizationId).toBe("test-org-id"); + expect(body.inheritOrganization).toBe(true); + }); + + test("organization and project configs are stored separately", async () => { + await authed("/guardrails/config/test-org-id", { + method: "PUT", + body: JSON.stringify({ enabled: true, maxFileSizeMb: 25 }), + }); + await authed("/guardrails/projects/test-project-id/config", { + method: "PUT", + body: JSON.stringify({ + inheritOrganization: false, + enabled: true, + maxFileSizeMb: 5, + }), + }); + + const orgConfig = await ( + await authed("/guardrails/config/test-org-id") + ).json(); + expect(orgConfig.projectId).toBeNull(); + expect(orgConfig.maxFileSizeMb).toBe(25); + + const projectConfig = await ( + await authed("/guardrails/projects/test-project-id/config") + ).json(); + expect(projectConfig.maxFileSizeMb).toBe(5); + expect(projectConfig.inheritOrganization).toBe(false); + }); + + test("rules are scoped to the level they were created on", async () => { + const orgRule = await ( + await authed("/guardrails/rules/test-org-id", { + method: "POST", + body: JSON.stringify({ + name: "Org rule", + type: "blocked_terms", + config: { + type: "blocked_terms", + terms: ["org"], + matchType: "contains", + caseSensitive: false, + }, + }), + }) + ).json(); + expect(orgRule.projectId).toBeNull(); + + const projectRule = await ( + await authed("/guardrails/projects/test-project-id/rules", { + method: "POST", + body: JSON.stringify({ + name: "Project rule", + type: "blocked_terms", + config: { + type: "blocked_terms", + terms: ["project"], + matchType: "contains", + caseSensitive: false, + }, + }), + }) + ).json(); + expect(projectRule.projectId).toBe("test-project-id"); + + const orgRules = await ( + await authed("/guardrails/rules/test-org-id") + ).json(); + expect(orgRules.rules.map((r: { name: string }) => r.name)).toEqual([ + "Org rule", + ]); + + const projectRules = await ( + await authed("/guardrails/projects/test-project-id/rules") + ).json(); + expect(projectRules.rules.map((r: { name: string }) => r.name)).toEqual([ + "Project rule", + ]); + + // A project route must not reach an organization rule, nor another + // project's rule. + const crossScope = await authed( + `/guardrails/projects/test-project-id/rules/${orgRule.id}`, + { method: "DELETE" }, + ); + expect(crossScope.status).toBe(404); + + const otherProject = await authed( + `/guardrails/projects/other-project-id/rules/${projectRule.id}`, + { method: "PATCH", body: JSON.stringify({ enabled: false }) }, + ); + expect(otherProject.status).toBe(404); + + const orgRouteOnProjectRule = await authed( + `/guardrails/rules/test-org-id/${projectRule.id}`, + { method: "DELETE" }, + ); + expect(orgRouteOnProjectRule.status).toBe(404); + }); + + test("project overrides are listed for the organization", async () => { + const empty = await ( + await authed("/guardrails/config/test-org-id/project-overrides") + ).json(); + expect(empty.projects).toEqual([]); + + await authed("/guardrails/projects/test-project-id/config", { + method: "PUT", + body: JSON.stringify({ inheritOrganization: true, enabled: true }), + }); + + const stillInheriting = await ( + await authed("/guardrails/config/test-org-id/project-overrides") + ).json(); + expect(stillInheriting.projects).toEqual([]); + + await authed("/guardrails/projects/test-project-id/config", { + method: "PUT", + body: JSON.stringify({ inheritOrganization: false }), + }); + + const overrides = await ( + await authed("/guardrails/config/test-org-id/project-overrides") + ).json(); + expect(overrides.projects).toEqual([ + { id: "test-project-id", name: "Test Project", enabled: true }, + ]); + }); + + test("resetting a project config keeps it project-scoped", async () => { + await authed("/guardrails/projects/test-project-id/config", { + method: "PUT", + body: JSON.stringify({ inheritOrganization: false, maxFileSizeMb: 99 }), + }); + + const reset = await ( + await authed("/guardrails/projects/test-project-id/config/reset", { + method: "POST", + }) + ).json(); + + expect(reset.projectId).toBe("test-project-id"); + expect(reset.inheritOrganization).toBe(true); + expect(reset.maxFileSizeMb).toBe(10); + }); + + test("resetting keeps exactly one config row for the scope", async () => { + await authed("/guardrails/config/test-org-id", { + method: "PUT", + body: JSON.stringify({ maxFileSizeMb: 42 }), + }); + + const before = await db.query.guardrailConfig.findFirst({ + where: { organizationId: { eq: "test-org-id" } }, + }); + + await authed("/guardrails/config/test-org-id/reset", { method: "POST" }); + + // Reset updates in place, so the scope is never left without a config and + // the row keeps its identity. + const rows = await db.query.guardrailConfig.findMany({ + where: { organizationId: { eq: "test-org-id" } }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe(before!.id); + expect(rows[0].maxFileSizeMb).toBe(10); + }); +}); diff --git a/apps/api/src/routes/guardrails.ts b/apps/api/src/routes/guardrails.ts index f55a75b7d2..f54cfe7681 100644 --- a/apps/api/src/routes/guardrails.ts +++ b/apps/api/src/routes/guardrails.ts @@ -27,6 +27,15 @@ import type { export const guardrails = new OpenAPIHono(); +/** + * The scope a guardrail request operates on: the organization-level config + * (`projectId: null`) or a single project's override. + */ +interface GuardrailScopeContext { + organizationId: string; + projectId: string | null; +} + // Helper to check enterprise access async function checkEnterpriseAccess( userId: string, @@ -65,6 +74,60 @@ async function checkEnterpriseAccess( return { userOrg }; } +async function requireOrgScope( + userId: string, + organizationId: string, +): Promise { + await checkEnterpriseAccess(userId, organizationId); + return { organizationId, projectId: null }; +} + +/** + * Project guardrails are administered by the project's owners and admins. + * Roles are organization-level, so this reuses the organization check against + * the project's owning organization — project-scoped "developer" members are + * excluded there. + */ +async function requireProjectScope( + userId: string, + projectId: string, +): Promise { + const project = await db.query.project.findFirst({ + where: { id: { eq: projectId } }, + }); + + if (!project || project.status === "deleted") { + throw new HTTPException(404, { message: "Project not found" }); + } + + await checkEnterpriseAccess(userId, project.organizationId); + + return { organizationId: project.organizationId, projectId }; +} + +function findScopedConfig(scope: GuardrailScopeContext) { + return db.query.guardrailConfig.findFirst({ + where: scope.projectId + ? { projectId: { eq: scope.projectId } } + : { + organizationId: { eq: scope.organizationId }, + projectId: { isNull: true }, + }, + }); +} + +function findScopedRules(scope: GuardrailScopeContext) { + return db.query.guardrailRule.findMany({ + where: scope.projectId + ? { projectId: { eq: scope.projectId } } + : { + organizationId: { eq: scope.organizationId }, + projectId: { isNull: true }, + }, + orderBy: { priority: "desc" }, + }); +} + // Schemas const systemRuleConfigSchema = z.object({ enabled: z.boolean(), @@ -83,6 +146,8 @@ const systemRulesConfigSchema = z.object({ const guardrailConfigSchema = z.object({ id: z.string(), organizationId: z.string(), + projectId: z.string().nullable(), + inheritOrganization: z.boolean(), enabled: z.boolean(), systemRules: systemRulesConfigSchema.nullable(), maxFileSizeMb: z.number(), @@ -119,6 +184,7 @@ const customRuleConfigSchema = z.union([ const guardrailRuleSchema = z.object({ id: z.string(), organizationId: z.string(), + projectId: z.string().nullable(), name: z.string(), type: z.enum(customRuleTypes), config: customRuleConfigSchema, @@ -173,70 +239,70 @@ guardrails.openapi(getConfig, async (c) => { } const { organizationId } = c.req.param(); - await checkEnterpriseAccess(user.id, organizationId); - - const config = await db.query.guardrailConfig.findFirst({ - where: { organizationId: { eq: organizationId } }, - }); + const scope = await requireOrgScope(user.id, organizationId); - return c.json(config ?? null); + return c.json((await findScopedConfig(scope)) ?? null); }); -// PUT /guardrails/config/:organizationId - Update or create config -const updateConfig = createRoute({ - method: "put", - path: "/config/{organizationId}", +// GET /guardrails/projects/:projectId/config - Get project config +const getProjectConfig = createRoute({ + method: "get", + path: "/projects/{projectId}/config", request: { params: z.object({ - organizationId: z.string(), + projectId: z.string(), }), - body: { - content: { - "application/json": { - schema: z.object({ - enabled: z.boolean().optional(), - systemRules: systemRulesConfigSchema.optional(), - maxFileSizeMb: z.number().optional(), - allowedFileTypes: z.array(z.string()).optional(), - piiAction: z.enum(["block", "redact", "warn", "allow"]).optional(), - }), - }, - }, - }, }, responses: { 200: { content: { "application/json": { - schema: guardrailConfigSchema, + schema: guardrailConfigSchema.nullable(), }, }, - description: "Updated guardrail configuration", + description: "Project guardrail configuration", }, }, }); -guardrails.openapi(updateConfig, async (c) => { +guardrails.openapi(getProjectConfig, async (c) => { const user = c.get("user"); if (!user) { throw new HTTPException(401, { message: "Unauthorized" }); } - const { organizationId } = c.req.param(); - await checkEnterpriseAccess(user.id, organizationId); + const { projectId } = c.req.param(); + const scope = await requireProjectScope(user.id, projectId); - const body = c.req.valid("json"); + return c.json((await findScopedConfig(scope)) ?? null); +}); - // Check if config exists - const existing = await db.query.guardrailConfig.findFirst({ - where: { organizationId: { eq: organizationId } }, - }); +const updateConfigBodySchema = z.object({ + enabled: z.boolean().optional(), + systemRules: systemRulesConfigSchema.optional(), + maxFileSizeMb: z.number().optional(), + allowedFileTypes: z.array(z.string()).optional(), + piiAction: z.enum(["block", "redact", "warn", "allow"]).optional(), +}); + +// `inheritOrganization` only exists on project rows: while true the project +// falls back to the organization config, which is the default for new projects. +const updateProjectConfigBodySchema = updateConfigBodySchema.extend({ + inheritOrganization: z.boolean().optional(), +}); + +async function upsertScopedConfig( + scope: GuardrailScopeContext, + body: z.infer, +) { + const existing = await findScopedConfig(scope); if (existing) { - // Update existing config const [updated] = await db .update(tables.guardrailConfig) .set({ + inheritOrganization: + body.inheritOrganization ?? existing.inheritOrganization, enabled: body.enabled ?? existing.enabled, systemRules: (body.systemRules as SystemRulesConfig) ?? existing.systemRules, @@ -246,14 +312,15 @@ guardrails.openapi(updateConfig, async (c) => { }) .where(eq(tables.guardrailConfig.id, existing.id)) .returning(); - return c.json(updated); + return updated; } - // Create new config const [created] = await db .insert(tables.guardrailConfig) .values({ - organizationId, + organizationId: scope.organizationId, + projectId: scope.projectId, + inheritOrganization: body.inheritOrganization ?? true, enabled: body.enabled ?? true, systemRules: (body.systemRules as SystemRulesConfig) ?? defaultSystemRulesConfig, @@ -263,7 +330,183 @@ guardrails.openapi(updateConfig, async (c) => { }) .returning(); - return c.json(created); + return created; +} + +const guardrailConfigDefaults = { + inheritOrganization: true, + enabled: true, + systemRules: defaultSystemRulesConfig, + maxFileSizeMb: 10, + allowedFileTypes: defaultAllowedFileTypes, + piiAction: "redact" as GuardrailAction, +}; + +async function resetScopedConfig(scope: GuardrailScopeContext) { + const existing = await findScopedConfig(scope); + + // Update in place rather than delete-then-insert: the gap between the two + // statements would leave the scope with no config at all, which the gateway + // reads as "no guardrails", and concurrent resets would race the partial + // unique index. + if (existing) { + const [updated] = await db + .update(tables.guardrailConfig) + .set(guardrailConfigDefaults) + .where(eq(tables.guardrailConfig.id, existing.id)) + .returning(); + return updated; + } + + const [created] = await db + .insert(tables.guardrailConfig) + .values({ + organizationId: scope.organizationId, + projectId: scope.projectId, + ...guardrailConfigDefaults, + }) + .returning(); + + return created; +} + +// PUT /guardrails/config/:organizationId - Update or create config +const updateConfig = createRoute({ + method: "put", + path: "/config/{organizationId}", + request: { + params: z.object({ + organizationId: z.string(), + }), + body: { + content: { + "application/json": { + schema: updateConfigBodySchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: guardrailConfigSchema, + }, + }, + description: "Updated guardrail configuration", + }, + }, +}); + +guardrails.openapi(updateConfig, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { organizationId } = c.req.param(); + const scope = await requireOrgScope(user.id, organizationId); + + return c.json(await upsertScopedConfig(scope, c.req.valid("json"))); +}); + +// GET /guardrails/config/:organizationId/project-overrides - Projects that +// have opted out of the organization config +const listProjectOverrides = createRoute({ + method: "get", + path: "/config/{organizationId}/project-overrides", + request: { + params: z.object({ + organizationId: z.string(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + projects: z.array( + z.object({ + id: z.string(), + name: z.string(), + enabled: z.boolean(), + }), + ), + }), + }, + }, + description: "Projects overriding the organization guardrails", + }, + }, +}); + +guardrails.openapi(listProjectOverrides, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { organizationId } = c.req.param(); + await requireOrgScope(user.id, organizationId); + + const configs = await db.query.guardrailConfig.findMany({ + where: { + organizationId: { eq: organizationId }, + projectId: { isNotNull: true }, + inheritOrganization: { eq: false }, + }, + with: { project: true }, + }); + + return c.json({ + projects: configs + .filter((config) => config.project?.status !== "deleted") + .map((config) => ({ + id: config.projectId!, + name: config.project?.name ?? "Unknown project", + enabled: config.enabled, + })), + }); +}); + +// PUT /guardrails/projects/:projectId/config - Update or create project config +const updateProjectConfig = createRoute({ + method: "put", + path: "/projects/{projectId}/config", + request: { + params: z.object({ + projectId: z.string(), + }), + body: { + content: { + "application/json": { + schema: updateProjectConfigBodySchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: guardrailConfigSchema, + }, + }, + description: "Updated project guardrail configuration", + }, + }, +}); + +guardrails.openapi(updateProjectConfig, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { projectId } = c.req.param(); + const scope = await requireProjectScope(user.id, projectId); + + return c.json(await upsertScopedConfig(scope, c.req.valid("json"))); }); // POST /guardrails/config/:organizationId/reset - Reset to defaults @@ -294,26 +537,42 @@ guardrails.openapi(resetConfig, async (c) => { } const { organizationId } = c.req.param(); - await checkEnterpriseAccess(user.id, organizationId); + const scope = await requireOrgScope(user.id, organizationId); - // Delete existing and create new with defaults - await db - .delete(tables.guardrailConfig) - .where(eq(tables.guardrailConfig.organizationId, organizationId)); + return c.json(await resetScopedConfig(scope)); +}); - const [created] = await db - .insert(tables.guardrailConfig) - .values({ - organizationId, - enabled: true, - systemRules: defaultSystemRulesConfig, - maxFileSizeMb: 10, - allowedFileTypes: defaultAllowedFileTypes, - piiAction: "redact", - }) - .returning(); +// POST /guardrails/projects/:projectId/config/reset - Reset project to defaults +const resetProjectConfig = createRoute({ + method: "post", + path: "/projects/{projectId}/config/reset", + request: { + params: z.object({ + projectId: z.string(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: guardrailConfigSchema, + }, + }, + description: "Reset project guardrail configuration", + }, + }, +}); + +guardrails.openapi(resetProjectConfig, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } - return c.json(created); + const { projectId } = c.req.param(); + const scope = await requireProjectScope(user.id, projectId); + + return c.json(await resetScopedConfig(scope)); }); // GET /guardrails/rules/:organizationId - List custom rules @@ -346,16 +605,76 @@ guardrails.openapi(listRules, async (c) => { } const { organizationId } = c.req.param(); - await checkEnterpriseAccess(user.id, organizationId); + const scope = await requireOrgScope(user.id, organizationId); - const rules = await db.query.guardrailRule.findMany({ - where: { organizationId: { eq: organizationId } }, - orderBy: { priority: "desc" }, - }); + return c.json({ rules: await findScopedRules(scope) }); +}); - return c.json({ rules }); +// GET /guardrails/projects/:projectId/rules - List project custom rules +const listProjectRules = createRoute({ + method: "get", + path: "/projects/{projectId}/rules", + request: { + params: z.object({ + projectId: z.string(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + rules: z.array(guardrailRuleSchema), + }), + }, + }, + description: "List of project custom guardrail rules", + }, + }, +}); + +guardrails.openapi(listProjectRules, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { projectId } = c.req.param(); + const scope = await requireProjectScope(user.id, projectId); + + return c.json({ rules: await findScopedRules(scope) }); }); +const createRuleBodySchema = z.object({ + name: z.string(), + type: z.enum(customRuleTypes), + config: customRuleConfigSchema, + priority: z.number().optional(), + enabled: z.boolean().optional(), + action: z.enum(["block", "redact", "warn", "allow"]).optional(), +}); + +async function createScopedRule( + scope: GuardrailScopeContext, + body: z.infer, +) { + const [created] = await db + .insert(tables.guardrailRule) + .values({ + organizationId: scope.organizationId, + projectId: scope.projectId, + name: body.name, + type: body.type, + config: body.config as CustomRuleConfig, + priority: body.priority ?? 100, + enabled: body.enabled ?? true, + action: (body.action as GuardrailAction) ?? "block", + }) + .returning(); + + return created; +} + // POST /guardrails/rules/:organizationId - Create custom rule const createRule = createRoute({ method: "post", @@ -367,14 +686,7 @@ const createRule = createRoute({ body: { content: { "application/json": { - schema: z.object({ - name: z.string(), - type: z.enum(customRuleTypes), - config: customRuleConfigSchema, - priority: z.number().optional(), - enabled: z.boolean().optional(), - action: z.enum(["block", "redact", "warn", "allow"]).optional(), - }), + schema: createRuleBodySchema, }, }, }, @@ -398,45 +710,23 @@ guardrails.openapi(createRule, async (c) => { } const { organizationId } = c.req.param(); - await checkEnterpriseAccess(user.id, organizationId); + const scope = await requireOrgScope(user.id, organizationId); - const body = c.req.valid("json"); - - const [created] = await db - .insert(tables.guardrailRule) - .values({ - organizationId, - name: body.name, - type: body.type, - config: body.config as CustomRuleConfig, - priority: body.priority ?? 100, - enabled: body.enabled ?? true, - action: (body.action as GuardrailAction) ?? "block", - }) - .returning(); - - return c.json(created); + return c.json(await createScopedRule(scope, c.req.valid("json"))); }); -// PATCH /guardrails/rules/:organizationId/:ruleId - Update custom rule -const updateRule = createRoute({ - method: "patch", - path: "/rules/{organizationId}/{ruleId}", +// POST /guardrails/projects/:projectId/rules - Create project custom rule +const createProjectRule = createRoute({ + method: "post", + path: "/projects/{projectId}/rules", request: { params: z.object({ - organizationId: z.string(), - ruleId: z.string(), + projectId: z.string(), }), body: { content: { "application/json": { - schema: z.object({ - name: z.string().optional(), - config: customRuleConfigSchema.optional(), - priority: z.number().optional(), - enabled: z.boolean().optional(), - action: z.enum(["block", "redact", "warn", "allow"]).optional(), - }), + schema: createRuleBodySchema, }, }, }, @@ -448,33 +738,60 @@ const updateRule = createRoute({ schema: guardrailRuleSchema, }, }, - description: "Updated custom rule", + description: "Created project custom rule", }, }, }); -guardrails.openapi(updateRule, async (c) => { +guardrails.openapi(createProjectRule, async (c) => { const user = c.get("user"); if (!user) { throw new HTTPException(401, { message: "Unauthorized" }); } - const { organizationId, ruleId } = c.req.param(); - await checkEnterpriseAccess(user.id, organizationId); + const { projectId } = c.req.param(); + const scope = await requireProjectScope(user.id, projectId); - const body = c.req.valid("json"); + return c.json(await createScopedRule(scope, c.req.valid("json"))); +}); +const updateRuleBodySchema = z.object({ + name: z.string().optional(), + config: customRuleConfigSchema.optional(), + priority: z.number().optional(), + enabled: z.boolean().optional(), + action: z.enum(["block", "redact", "warn", "allow"]).optional(), +}); + +/** + * Look up a rule within its scope so an organization rule can never be edited + * or deleted through a project route, and vice versa. + */ +async function findScopedRule(scope: GuardrailScopeContext, ruleId: string) { const existing = await db.query.guardrailRule.findFirst({ - where: { - id: { eq: ruleId }, - organizationId: { eq: organizationId }, - }, + where: scope.projectId + ? { id: { eq: ruleId }, projectId: { eq: scope.projectId } } + : { + id: { eq: ruleId }, + organizationId: { eq: scope.organizationId }, + projectId: { isNull: true }, + }, }); if (!existing) { throw new HTTPException(404, { message: "Rule not found" }); } + return existing; +} + +async function updateScopedRule( + scope: GuardrailScopeContext, + ruleId: string, + body: z.infer, +) { + const existing = await findScopedRule(scope, ruleId); + const [updated] = await db .update(tables.guardrailRule) .set({ @@ -484,10 +801,100 @@ guardrails.openapi(updateRule, async (c) => { enabled: body.enabled ?? existing.enabled, action: (body.action as GuardrailAction) ?? existing.action, }) - .where(eq(tables.guardrailRule.id, ruleId)) + .where(eq(tables.guardrailRule.id, existing.id)) .returning(); - return c.json(updated); + return updated; +} + +async function deleteScopedRule(scope: GuardrailScopeContext, ruleId: string) { + const existing = await findScopedRule(scope, ruleId); + + await db + .delete(tables.guardrailRule) + .where(eq(tables.guardrailRule.id, existing.id)); +} + +// PATCH /guardrails/rules/:organizationId/:ruleId - Update custom rule +const updateRule = createRoute({ + method: "patch", + path: "/rules/{organizationId}/{ruleId}", + request: { + params: z.object({ + organizationId: z.string(), + ruleId: z.string(), + }), + body: { + content: { + "application/json": { + schema: updateRuleBodySchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: guardrailRuleSchema, + }, + }, + description: "Updated custom rule", + }, + }, +}); + +guardrails.openapi(updateRule, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { organizationId, ruleId } = c.req.param(); + const scope = await requireOrgScope(user.id, organizationId); + + return c.json(await updateScopedRule(scope, ruleId, c.req.valid("json"))); +}); + +// PATCH /guardrails/projects/:projectId/rules/:ruleId - Update project rule +const updateProjectRule = createRoute({ + method: "patch", + path: "/projects/{projectId}/rules/{ruleId}", + request: { + params: z.object({ + projectId: z.string(), + ruleId: z.string(), + }), + body: { + content: { + "application/json": { + schema: updateRuleBodySchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: guardrailRuleSchema, + }, + }, + description: "Updated project custom rule", + }, + }, +}); + +guardrails.openapi(updateProjectRule, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { projectId, ruleId } = c.req.param(); + const scope = await requireProjectScope(user.id, projectId); + + return c.json(await updateScopedRule(scope, ruleId, c.req.valid("json"))); }); // DELETE /guardrails/rules/:organizationId/:ruleId - Delete custom rule @@ -519,16 +926,43 @@ guardrails.openapi(deleteRule, async (c) => { } const { organizationId, ruleId } = c.req.param(); - await checkEnterpriseAccess(user.id, organizationId); + const scope = await requireOrgScope(user.id, organizationId); + await deleteScopedRule(scope, ruleId); - await db - .delete(tables.guardrailRule) - .where( - and( - eq(tables.guardrailRule.id, ruleId), - eq(tables.guardrailRule.organizationId, organizationId), - ), - ); + return c.json({ success: true }); +}); + +// DELETE /guardrails/projects/:projectId/rules/:ruleId - Delete project rule +const deleteProjectRule = createRoute({ + method: "delete", + path: "/projects/{projectId}/rules/{ruleId}", + request: { + params: z.object({ + projectId: z.string(), + ruleId: z.string(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ success: z.boolean() }), + }, + }, + description: "Project rule deleted", + }, + }, +}); + +guardrails.openapi(deleteProjectRule, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { projectId, ruleId } = c.req.param(); + const scope = await requireProjectScope(user.id, projectId); + await deleteScopedRule(scope, ruleId); return c.json({ success: true }); }); @@ -816,7 +1250,7 @@ guardrails.openapi(testContent, async (c) => { } const { organizationId } = c.req.param(); - await checkEnterpriseAccess(user.id, organizationId); + await requireOrgScope(user.id, organizationId); const { content } = c.req.valid("json"); @@ -833,6 +1267,75 @@ guardrails.openapi(testContent, async (c) => { }); }); +// POST /guardrails/projects/:projectId/test - Test content against project rules +const testProjectContent = createRoute({ + method: "post", + path: "/projects/{projectId}/test", + request: { + params: z.object({ + projectId: z.string(), + }), + body: { + content: { + "application/json": { + schema: z.object({ + content: z.string(), + }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + passed: z.boolean(), + blocked: z.boolean(), + violations: z.array( + z.object({ + ruleId: z.string(), + ruleName: z.string(), + category: z.string(), + action: z.enum(["block", "redact", "warn", "allow"]), + matchedPattern: z.string().optional(), + matchedContent: z.string().optional(), + }), + ), + rulesChecked: z.number(), + }), + }, + }, + description: "Test result", + }, + }, +}); + +guardrails.openapi(testProjectContent, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { projectId } = c.req.param(); + const scope = await requireProjectScope(user.id, projectId); + + const { content } = c.req.valid("json"); + + const result = await checkGuardrails({ + organizationId: scope.organizationId, + projectId, + messages: [{ role: "user", content }], + }); + + return c.json({ + passed: result.passed, + blocked: result.blocked, + violations: result.violations, + rulesChecked: result.rulesChecked, + }); +}); + // GET /guardrails/system-rules - List all system rules with defaults const listSystemRules = createRoute({ method: "get", diff --git a/apps/docs/content/features/guardrails.mdx b/apps/docs/content/features/guardrails.mdx index 40232e5536..3850d4b8dc 100644 --- a/apps/docs/content/features/guardrails.mdx +++ b/apps/docs/content/features/guardrails.mdx @@ -25,6 +25,25 @@ Guardrails run on every API request, scanning message content for: When a violation is detected, you control what happens: block the request, redact the content, or log a warning. +## Organization and Project Scopes + +Guardrails are configured for the whole organization by default, and every project uses that configuration. + +A project can opt out and define its own guardrails instead — useful when one application needs stricter or looser rules than the rest of the organization. Turn off **Use organization guardrails** in the project's Guardrails settings, and that project's configuration and custom rules fully replace the organization's for its traffic. + +Exactly one scope is ever in force for a request: + +| Project setting | What runs on that project's requests | +| ----------------------------------- | --------------------------------------------------------- | +| **Use organization guardrails** on | The organization configuration and its custom rules | +| **Use organization guardrails** off | The project's own configuration and its custom rules only | + +Opting out never inherits partially: the organization's custom rules stop applying to that project too, and the project starts with a copy of the organization's current settings so nothing is silently dropped at the moment you opt out. Other projects are unaffected either way. + +Both pages state which scope wins. A project page names itself and, while it inherits, shows the organization settings read-only; the organization page lists any projects that override it, so it is clear those settings apply everywhere else. + +Only organization owners and admins can change guardrails, at either scope. + ## System Rules Built-in rules protect against common threats: @@ -90,7 +109,7 @@ For each rule, choose how to respond: ## Custom Rules -Create organization-specific rules for your use case: +Create custom rules for your use case, on the organization or on a single project: ### Blocked Terms @@ -133,10 +152,11 @@ Request → Guardrails Check → Action Based on Rules → Forward to Model (if ``` 1. **Request received** — API request comes in with messages -2. **Content scanned** — All text content is checked against enabled rules -3. **Violations detected** — Matches are identified and logged -4. **Action taken** — Based on rule configuration (block/redact/warn) -5. **Request proceeds** — If not blocked, the (potentially redacted) request continues +2. **Scope resolved** — The project's own guardrails if it overrides, otherwise the organization's +3. **Content scanned** — All text content is checked against enabled rules +4. **Violations detected** — Matches are identified and logged +5. **Action taken** — Based on rule configuration (block/redact/warn) +6. **Request proceeds** — If not blocked, the (potentially redacted) request continues ## Best Practices diff --git a/apps/docs/content/learn/guardrails.mdx b/apps/docs/content/learn/guardrails.mdx index 2f70fc98ec..37a4b3cb81 100644 --- a/apps/docs/content/learn/guardrails.mdx +++ b/apps/docs/content/learn/guardrails.mdx @@ -20,6 +20,14 @@ The Guardrails page lets you configure content safety rules that automatically s A global toggle at the top enables or disables all guardrails for your organization. Click **Save Changes** to apply. +## Organization vs. Project + +The same page exists in each project's settings. By default a project uses the organization guardrails, and the project page shows them read-only alongside a banner naming the project — for example "Enterprise Project uses the organization guardrails". + +Turn off **Use organization guardrails** on the project page to give that project its own configuration and custom rules, which then replace the organization's for that project's traffic. The project starts from a copy of the organization's current settings, so opting out does not drop the rules that were already in force — edit down from there and click **Save Changes**. + +The organization page names any projects that override it ("The settings below apply to every project except …"), so it is always clear where these settings do and do not apply. + ## System Rules Six built-in rules with individual enable/disable toggles: diff --git a/apps/docs/content/learn/security-events.mdx b/apps/docs/content/learn/security-events.mdx index 47e8f3a685..22944c65ea 100644 --- a/apps/docs/content/learn/security-events.mdx +++ b/apps/docs/content/learn/security-events.mdx @@ -7,7 +7,7 @@ icon: ShieldAlert import { Callout } from "fumadocs-ui/components/callout"; import { ThemedImage } from "@/components/themed-image"; -The Security Events page shows all guardrail violations detected across your organization, helping you monitor content safety and policy enforcement. +The Security Events page shows all guardrail violations detected across your organization, helping you monitor content safety and policy enforcement. Violations from projects that [override the organization guardrails](/learn/guardrails) appear here too, so this page stays the single place to review enforcement. diff --git a/apps/gateway/src/chat/chat.ts b/apps/gateway/src/chat/chat.ts index dc507c12be..a7b9fa394b 100644 --- a/apps/gateway/src/chat/chat.ts +++ b/apps/gateway/src/chat/chat.ts @@ -2575,6 +2575,7 @@ chat.openapi(completions, async (c) => { if (organization.plan === "enterprise") { guardrailResult = await checkGuardrails({ organizationId: project.organizationId, + projectId: project.id, messages: messages as Parameters[0]["messages"], }); diff --git a/apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/guardrails/page.tsx b/apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/guardrails/page.tsx new file mode 100644 index 0000000000..0dc62b54cd --- /dev/null +++ b/apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/guardrails/page.tsx @@ -0,0 +1,24 @@ +import { GuardrailsSettings } from "@/components/guardrails/guardrails-settings"; + +export default async function ProjectGuardrailsPage({ + params, +}: { + params: Promise<{ orgId: string; projectId: string }>; +}) { + const { orgId, projectId } = await params; + + return ( +
+
+
+

+ Guardrails +

+
+ +
+
+ ); +} diff --git a/apps/ui/src/app/dashboard/[orgId]/org/guardrails/contact-sales-card.tsx b/apps/ui/src/app/dashboard/[orgId]/org/guardrails/contact-sales-card.tsx deleted file mode 100644 index d3f8f891fd..0000000000 --- a/apps/ui/src/app/dashboard/[orgId]/org/guardrails/contact-sales-card.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { CheckCircle, Mail } from "lucide-react"; - -import { Button } from "@/lib/components/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/lib/components/card"; - -export function ContactSalesCard() { - return ( -
-
-
-

- Guardrails -

-
- - - - Enterprise Feature - - Guardrails are available on the Enterprise plan - - - -

- Protect your LLM applications with enterprise-grade content safety - controls. Automatically detect and block prompt injections, - jailbreak attempts, and sensitive data exposure. -

- -
-

What's included:

-
    -
  • - - Prompt injection and jailbreak detection -
  • -
  • - - PII detection with automatic redaction -
  • -
  • - - Secrets and credentials scanning -
  • -
  • - - Custom blocked terms and regex patterns -
  • -
  • - - Topic restriction policies -
  • -
  • - - Real-time security event monitoring -
  • -
-
- - -
-
-
-
- ); -} diff --git a/apps/ui/src/app/dashboard/[orgId]/org/guardrails/guardrails-client.tsx b/apps/ui/src/app/dashboard/[orgId]/org/guardrails/guardrails-client.tsx deleted file mode 100644 index 7296e86bea..0000000000 --- a/apps/ui/src/app/dashboard/[orgId]/org/guardrails/guardrails-client.tsx +++ /dev/null @@ -1,790 +0,0 @@ -"use client"; - -import { Plus, Trash2, Save, X } from "lucide-react"; -import { useParams } from "next/navigation"; -import { useState, useEffect, useCallback } from "react"; - -import { useDashboardNavigation } from "@/hooks/useDashboardNavigation"; -import { useTeamMembers } from "@/hooks/useTeam"; -import { useUser } from "@/hooks/useUser"; -import { Badge } from "@/lib/components/badge"; -import { Button } from "@/lib/components/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/lib/components/card"; -import { Input } from "@/lib/components/input"; -import { Label } from "@/lib/components/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/lib/components/select"; -import { Switch } from "@/lib/components/switch"; -import { useFetchClient } from "@/lib/fetch-client"; - -import { ContactSalesCard } from "./contact-sales-card"; - -interface SystemRuleConfig { - enabled: boolean; - action: "block" | "redact" | "warn" | "allow"; -} - -interface SystemRulesConfig { - prompt_injection: SystemRuleConfig; - jailbreak: SystemRuleConfig; - pii_detection: SystemRuleConfig; - secrets: SystemRuleConfig; - file_types: SystemRuleConfig; - document_leakage: SystemRuleConfig; -} - -interface GuardrailConfig { - id: string; - enabled: boolean; - systemRules: SystemRulesConfig; - maxFileSizeMb: number; - allowedFileTypes: string[]; - piiAction: "block" | "redact" | "warn" | "allow"; -} - -interface CustomRule { - id: string; - name: string; - type: "blocked_terms" | "custom_regex" | "topic_restriction"; - enabled: boolean; - priority: number; - action: "block" | "redact" | "warn" | "allow"; - config: Record; -} - -const SYSTEM_RULES = [ - { - id: "prompt_injection", - name: "Prompt Injection Detection", - description: - "Detect attempts to override system instructions or inject malicious prompts", - }, - { - id: "jailbreak", - name: "Jailbreak Prevention", - description: "Block attempts to bypass AI safety measures", - }, - { - id: "pii_detection", - name: "PII Detection", - description: - "Detect and optionally redact personal identifiable information", - }, - { - id: "secrets", - name: "Secrets Detection", - description: "Detect API keys, passwords, and other credentials", - }, - { - id: "file_types", - name: "File Type Restrictions", - description: "Restrict uploads to allowed file types", - }, - { - id: "document_leakage", - name: "Document Leakage Prevention", - description: "Prevent exposure of confidential document content", - }, -] as const; - -const DEFAULT_CONFIG: GuardrailConfig = { - id: "", - enabled: false, - systemRules: { - prompt_injection: { enabled: true, action: "block" }, - jailbreak: { enabled: true, action: "block" }, - pii_detection: { enabled: true, action: "redact" }, - secrets: { enabled: true, action: "block" }, - file_types: { enabled: true, action: "block" }, - document_leakage: { enabled: false, action: "warn" }, - }, - maxFileSizeMb: 10, - allowedFileTypes: ["pdf", "txt", "md", "csv", "json", "xml"], - piiAction: "redact", -}; - -export function GuardrailsClient() { - const params = useParams(); - const organizationId = params.orgId as string; - const fetchClient = useFetchClient(); - const { selectedOrganization } = useDashboardNavigation(); - const { user } = useUser(); - const { data: teamData, isLoading: isLoadingTeam } = - useTeamMembers(organizationId); - - const currentUserRole = teamData?.members.find( - (member) => member.userId === user?.id, - )?.role; - - const [config, setConfig] = useState(null); - const [customRules, setCustomRules] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - - // Edit state for custom rules - const [showAddRule, setShowAddRule] = useState(false); - const [newRule, setNewRule] = useState({ - name: "", - type: "blocked_terms" as - "blocked_terms" | "custom_regex" | "topic_restriction", - action: "block" as "block" | "redact" | "warn" | "allow", - terms: "", - pattern: "", - topics: "", - }); - - const canManageGuardrails = - selectedOrganization?.plan === "enterprise" && - (currentUserRole === "owner" || currentUserRole === "admin"); - - const fetchConfig = useCallback(async () => { - setIsLoading(true); - setError(null); - // allSettled so a fulfilled response is still applied when the other - // request fails. - const [configResult, rulesResult] = await Promise.allSettled([ - fetchClient.GET("/guardrails/config/{organizationId}", { - params: { path: { organizationId } }, - }), - fetchClient.GET("/guardrails/rules/{organizationId}", { - params: { path: { organizationId } }, - }), - ]); - - // openapi-fetch resolves non-2xx responses as { error }, so checking for a - // rejection alone would only catch network-level failures. - const configResponse = - configResult.status === "fulfilled" ? configResult.value : null; - const rulesResponse = - rulesResult.status === "fulfilled" ? rulesResult.value : null; - - if (configResponse && !configResponse.error) { - // An org with no config yet gets a 200 with a null body — only then do - // the defaults apply. Falling back to them after an HTTP error would - // show guardrails as disabled and let the next save overwrite the - // org's real configuration. - setConfig( - (configResponse.data as unknown as GuardrailConfig | null) ?? - DEFAULT_CONFIG, - ); - } - - if (rulesResponse && !rulesResponse.error) { - setCustomRules( - (rulesResponse.data as { rules: CustomRule[] } | undefined)?.rules ?? - [], - ); - } - - if ( - !configResponse || - configResponse.error || - !rulesResponse || - rulesResponse.error - ) { - setError("Failed to load guardrails configuration"); - } - setIsLoading(false); - }, [fetchClient, organizationId]); - - useEffect(() => { - if (canManageGuardrails) { - void fetchConfig(); - } else { - setIsLoading(false); - } - }, [canManageGuardrails, fetchConfig]); - - const saveConfig = async () => { - if (!config) { - return; - } - - try { - setIsSaving(true); - setError(null); - setSuccess(null); - - await fetchClient.PUT("/guardrails/config/{organizationId}", { - params: { path: { organizationId } }, - body: { - enabled: config.enabled, - systemRules: config.systemRules, - maxFileSizeMb: config.maxFileSizeMb, - allowedFileTypes: config.allowedFileTypes, - piiAction: config.piiAction, - }, - }); - - setSuccess("Configuration saved successfully"); - setTimeout(() => setSuccess(null), 3000); - } catch { - setError("Failed to save configuration"); - } finally { - setIsSaving(false); - } - }; - - const updateSystemRule = ( - ruleId: keyof SystemRulesConfig, - field: keyof SystemRuleConfig, - value: boolean | string, - ) => { - if (!config) { - return; - } - - setConfig({ - ...config, - systemRules: { - ...config.systemRules, - [ruleId]: { - ...config.systemRules[ruleId], - [field]: value, - }, - }, - }); - }; - - const addCustomRule = async () => { - try { - setError(null); - - let ruleConfig: - | { - type: "blocked_terms"; - terms: string[]; - matchType: "exact" | "contains" | "regex"; - caseSensitive: boolean; - } - | { type: "custom_regex"; pattern: string } - | { type: "topic_restriction"; blockedTopics: string[] }; - - if (newRule.type === "blocked_terms") { - ruleConfig = { - type: "blocked_terms", - terms: newRule.terms.split("\n").filter((t) => t.trim()), - matchType: "contains", - caseSensitive: false, - }; - } else if (newRule.type === "custom_regex") { - ruleConfig = { - type: "custom_regex", - pattern: newRule.pattern, - }; - } else { - ruleConfig = { - type: "topic_restriction", - blockedTopics: newRule.topics.split("\n").filter((t) => t.trim()), - }; - } - - const response = await fetchClient.POST( - "/guardrails/rules/{organizationId}", - { - params: { path: { organizationId } }, - body: { - name: newRule.name, - type: newRule.type, - action: newRule.action, - config: ruleConfig, - enabled: true, - priority: customRules.length + 1, - }, - }, - ); - - if (response.data) { - setCustomRules([...customRules, response.data as CustomRule]); - setShowAddRule(false); - setNewRule({ - name: "", - type: "blocked_terms", - action: "block", - terms: "", - pattern: "", - topics: "", - }); - } - } catch { - setError("Failed to add rule"); - } - }; - - const deleteCustomRule = async (ruleId: string) => { - try { - await fetchClient.DELETE("/guardrails/rules/{organizationId}/{ruleId}", { - params: { path: { organizationId, ruleId } }, - }); - - setCustomRules(customRules.filter((r) => r.id !== ruleId)); - } catch { - setError("Failed to delete rule"); - } - }; - - const toggleRuleEnabled = async (rule: CustomRule) => { - try { - await fetchClient.PATCH("/guardrails/rules/{organizationId}/{ruleId}", { - params: { path: { organizationId, ruleId: rule.id } }, - body: { enabled: !rule.enabled }, - }); - - setCustomRules( - customRules.map((r) => - r.id === rule.id ? { ...r, enabled: !r.enabled } : r, - ), - ); - } catch { - setError("Failed to update rule"); - } - }; - - if (selectedOrganization?.plan !== "enterprise") { - return ; - } - - if (isLoadingTeam || !currentUserRole) { - return ( -
-
-
- ); - } - - if (currentUserRole !== "owner" && currentUserRole !== "admin") { - return ( - - - Access Denied - - Only organization owners and admins can manage guardrails. - - - - ); - } - - if (isLoading) { - return ( -
-
-
- ); - } - - return ( -
- {error && ( -
- {error} -
- )} - - {success && ( -
- {success} -
- )} - - {/* Main Toggle */} - - -
-
- Guardrails - - Configure content safety rules for your LLM applications - -
- {config && ( -
-
- - setConfig({ ...config, enabled }) - } - /> - -
- -
- )} -
-
-
- - {config && ( -
- {/* System Rules */} - - - System Rules - - Built-in security rules powered by pattern matching - - - - {SYSTEM_RULES.map((rule) => { - const ruleConfig = - config.systemRules[rule.id as keyof SystemRulesConfig]; - return ( -
-
- - updateSystemRule( - rule.id as keyof SystemRulesConfig, - "enabled", - enabled, - ) - } - /> -
-
{rule.name}
-
- {rule.description} -
-
-
- {ruleConfig?.enabled && ( - - )} -
- ); - })} -
-
- - {/* File Restrictions */} - - - File Restrictions - - Configure allowed file types and size limits - - - -
- - - setConfig({ - ...config, - maxFileSizeMb: parseInt(e.target.value, 10) || 10, - }) - } - className="w-32" - /> -
-
- -
- {config.allowedFileTypes.map((type) => ( - - {type} - {config.enabled && ( - - )} - - ))} -
-
- { - if (e.key === "Enter") { - const input = e.currentTarget; - const value = input.value.trim().toLowerCase(); - if (value && !config.allowedFileTypes.includes(value)) { - setConfig({ - ...config, - allowedFileTypes: [ - ...config.allowedFileTypes, - value, - ], - }); - input.value = ""; - } - } - }} - /> -
-
-
-
- - {/* Custom Rules */} - - -
-
- Custom Rules - - Create custom content filtering rules - -
- -
-
- - {showAddRule && config.enabled && ( -
-
-

New Rule

- -
-
-
- - - setNewRule({ ...newRule, name: e.target.value }) - } - placeholder="e.g., Block competitors" - /> -
-
- - -
-
-
- - -
- {newRule.type === "blocked_terms" && ( -
- -