Repository navigation
feat(guardrails): project-level overrides #3642
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
141c170
feat(guardrails): project-level overrides
steebchen ccab229
fix(guardrails): name the project in scope banners
steebchen c10627e
docs(guardrails): clarify scope precedence
steebchen 85caaa4
fix(guardrails): address review feedback
steebchen 23e80eb
fix(guardrails): drop stale add-rule hint when pending
steebchen 0c88f4c
Merge remote-tracking branch 'origin/main' into project-level-guardrails
steebchen 50deb59
fix(guardrails): make reset atomic, scope inherited save
steebchen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,258 @@ | ||
| 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); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an organization has project-level custom rules, the adjacent
guardrailRule.findManystill filters only byorganizationId, so the admin Guardrails tab mixes every project rule into the organization-level rule list even though the config is correctly restricted toprojectId IS NULL. Add the same null-project predicate to the rules query so the displayed rules match the displayed organization config.Useful? React with 👍 / 👎.