Skip to content

Commit ef05554

Browse files
authored
Merge branch 'main' into blog/litellm-alternatives
2 parents eaa9b56 + 7293759 commit ef05554

27 files changed

Lines changed: 1447 additions & 250 deletions

File tree

apps/api/src/auth/config.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,44 @@ const isHosted = process.env.HOSTED === "true";
3333
async function isSSOEnforcedForEmail(
3434
email: string | null | undefined,
3535
): Promise<boolean> {
36+
// Keep enforcement tied to the same flag that surfaces the SSO sign-in entry
37+
// point: with SSO_ENABLED off there's no way to sign in via SSO, so blocking
38+
// password/social/passkey would just lock the domain out entirely.
39+
if (process.env.SSO_ENABLED !== "true") {
40+
return false;
41+
}
42+
3643
const domain = email?.trim().toLowerCase().split("@")[1];
3744
if (!domain) {
3845
return false;
3946
}
4047
const providers = await db.query.ssoProvider.findMany({
4148
where: { enforced: { eq: true } },
42-
columns: { domain: true },
49+
columns: { domain: true, organizationId: true },
50+
});
51+
const matching = providers.filter(
52+
(provider) =>
53+
provider.organizationId &&
54+
provider.domain
55+
.split(",")
56+
.map((d) => d.trim().toLowerCase())
57+
.includes(domain),
58+
);
59+
if (!matching.length) {
60+
return false;
61+
}
62+
63+
// Only an active, enterprise org can enforce SSO. A soft-deleted org can no
64+
// longer be managed to turn enforcement off, so a stale enforced row must not
65+
// keep blocking a domain's users forever.
66+
const orgs = await db.query.organization.findMany({
67+
where: {
68+
id: { in: [...new Set(matching.map((p) => p.organizationId as string))] },
69+
},
70+
columns: { status: true, plan: true },
4371
});
44-
return providers.some((provider) =>
45-
provider.domain
46-
.split(",")
47-
.map((d) => d.trim().toLowerCase())
48-
.includes(domain),
72+
return orgs.some(
73+
(org) => org.status !== "deleted" && org.plan === "enterprise",
4974
);
5075
}
5176

@@ -1060,9 +1085,12 @@ The LLM Gateway Team`.trim();
10601085
!ctx.path.startsWith("/sso/") &&
10611086
(await isSSOEnforcedForEmail(dbUser?.email))
10621087
) {
1088+
// Delete only the session this blocked attempt just created — not
1089+
// every session for the user — so a rejected social/passkey login
1090+
// doesn't also sign them out of valid SSO sessions elsewhere.
10631091
await db
10641092
.delete(tables.session)
1065-
.where(eq(tables.session.userId, userId));
1093+
.where(eq(tables.session.id, newSession.session.id));
10661094
return ssoRequiredResponse();
10671095
}
10681096

apps/api/src/auth/handler.spec.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, test } from "vitest";
2+
3+
import { app } from "@/index.js";
4+
5+
// Better Auth's sso() plugin mounts its own provider-management endpoints under
6+
// the /auth/* catch-all. They only require a session, so `handler.ts` blocks
7+
// direct HTTP access to them (see BLOCKED_SSO_PLUGIN_PATHS) — our own
8+
// enterprise/admin-gated wrapper lives at /sso and calls the plugin via
9+
// `apiAuth.api.*`, which bypasses this handler entirely.
10+
const BLOCKED_PATHS = [
11+
"/auth/sso/register",
12+
"/auth/sso/update-provider",
13+
"/auth/sso/delete-provider",
14+
"/auth/sso/get-provider",
15+
"/auth/sso/providers",
16+
"/auth/sso/request-domain-verification",
17+
"/auth/sso/verify-domain",
18+
];
19+
20+
// Paths the login flow needs must NOT be blocked by us; they pass through to
21+
// Better Auth's handler.
22+
const ALLOWED_PATHS = [
23+
"/auth/sign-in/sso",
24+
"/auth/sso/callback",
25+
"/auth/sso/saml2/sp/metadata",
26+
];
27+
28+
const BLOCK_BODY = { error: "not_found", message: "Not found" };
29+
30+
// Our block responds with exactly this 404 JSON, distinct from any 404 Better
31+
// Auth itself might return, so allowed paths can be told apart from blocked.
32+
function isOurBlock(status: number, body: unknown): boolean {
33+
return (
34+
status === 404 &&
35+
!!body &&
36+
typeof body === "object" &&
37+
(body as { error?: string }).error === "not_found" &&
38+
(body as { message?: string }).message === "Not found"
39+
);
40+
}
41+
42+
describe("SSO plugin management endpoint blocking", () => {
43+
test.each(BLOCKED_PATHS)("blocks POST %s with 404", async (path) => {
44+
const response = await app.request(path, {
45+
method: "POST",
46+
headers: { "Content-Type": "application/json" },
47+
body: "{}",
48+
});
49+
50+
expect(response.status).toBe(404);
51+
expect(await response.json()).toEqual(BLOCK_BODY);
52+
});
53+
54+
test.each(BLOCKED_PATHS)("blocks GET %s with 404", async (path) => {
55+
const response = await app.request(path, { method: "GET" });
56+
57+
expect(response.status).toBe(404);
58+
expect(await response.json()).toEqual(BLOCK_BODY);
59+
});
60+
61+
test("blocks even with a query string", async () => {
62+
const response = await app.request(
63+
"/auth/sso/get-provider?providerId=acme",
64+
{ method: "GET" },
65+
);
66+
67+
expect(response.status).toBe(404);
68+
expect(await response.json()).toEqual(BLOCK_BODY);
69+
});
70+
71+
test("does not block an unrelated /auth path", async () => {
72+
const response = await app.request("/auth/ok", { method: "GET" });
73+
const body = await response.json().catch(() => null);
74+
75+
expect(isOurBlock(response.status, body)).toBe(false);
76+
});
77+
78+
test.each(ALLOWED_PATHS)(
79+
"does not block the login-flow path %s",
80+
async (path) => {
81+
const response = await app.request(path, { method: "GET" });
82+
const body = await response.json().catch(() => null);
83+
84+
// Reached Better Auth (any status/body) rather than our 404 sentinel.
85+
expect(isOurBlock(response.status, body)).toBe(false);
86+
},
87+
);
88+
});

apps/api/src/auth/handler.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ import type { ServerTypes } from "@/vars.js";
77
// Create a Hono app for auth routes
88
export const authHandler = new OpenAPIHono<ServerTypes>();
99

10+
// Better Auth's sso() plugin mounts its own provider-management endpoints under
11+
// the /auth/* catch-all. They only require a session (any authenticated user),
12+
// so exposing them would let a user bypass the enterprise/admin-gated /sso
13+
// wrapper and register a provider — claiming any email domain — with no
14+
// organization scoping. Block direct HTTP access to them here; our server-side
15+
// registration calls `apiAuth.api.registerSSOProvider` directly and never goes
16+
// through this handler, so it is unaffected. The SAML sign-in/callback/metadata
17+
// endpoints (/auth/sso/saml2/*, /auth/sso/callback, /auth/sign-in/sso) are left
18+
// reachable because the login flow needs them.
19+
const BLOCKED_SSO_PLUGIN_PATHS = new Set([
20+
"/auth/sso/register",
21+
"/auth/sso/update-provider",
22+
"/auth/sso/delete-provider",
23+
"/auth/sso/get-provider",
24+
"/auth/sso/providers",
25+
"/auth/sso/request-domain-verification",
26+
"/auth/sso/verify-domain",
27+
]);
28+
1029
authHandler.use("*", async (c, next) => {
1130
const session = await apiAuth.api.getSession({ headers: c.req.raw.headers });
1231

@@ -22,5 +41,8 @@ authHandler.use("*", async (c, next) => {
2241
});
2342

2443
authHandler.on(["POST", "GET"], "/auth/*", (c) => {
44+
if (BLOCKED_SSO_PLUGIN_PATHS.has(new URL(c.req.url).pathname)) {
45+
return c.json({ error: "not_found", message: "Not found" }, 404);
46+
}
2547
return apiAuth.handler(c.req.raw);
2648
});

apps/api/src/lib/sso-roles.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { db, eq, tables } from "@llmgateway/db";
2+
3+
export type OrgRole = "owner" | "admin" | "developer";
4+
5+
export interface RoleChange {
6+
old: OrgRole;
7+
new: OrgRole;
8+
}
9+
10+
const ROLE_RANK: Record<OrgRole, number> = {
11+
developer: 1,
12+
admin: 2,
13+
owner: 3,
14+
};
15+
16+
// Recompute an org member's role from their SCIM group memberships and the
17+
// org's group->role mappings. The highest-precedence mapped role wins; the
18+
// default is `developer`. Owners are never auto-demoted — owner is only ever
19+
// assigned manually (or by an explicit owner mapping), so an admin who set up
20+
// SSO can't be locked out by a group that maps to a lower role.
21+
//
22+
// Returns the {old,new} role change when it updated the membership, or null
23+
// when nothing changed, so callers (e.g. SCIM) can audit the transition.
24+
export async function recomputeUserRole(
25+
userId: string,
26+
organizationId: string,
27+
): Promise<RoleChange | null> {
28+
const membership = await db.query.userOrganization.findFirst({
29+
where: {
30+
userId: { eq: userId },
31+
organizationId: { eq: organizationId },
32+
},
33+
columns: { id: true, role: true },
34+
});
35+
if (!membership) {
36+
return null;
37+
}
38+
39+
const groupMemberships = await db.query.scimGroupMember.findMany({
40+
where: { userId: { eq: userId } },
41+
columns: { scimGroupId: true },
42+
});
43+
const groupIds = groupMemberships.map((m) => m.scimGroupId);
44+
45+
let mappedRole: OrgRole = "developer";
46+
if (groupIds.length) {
47+
const groups = await db.query.scimGroup.findMany({
48+
where: {
49+
id: { in: groupIds },
50+
organizationId: { eq: organizationId },
51+
},
52+
columns: { displayName: true },
53+
});
54+
const names = groups.map((g) => g.displayName);
55+
if (names.length) {
56+
const mappings = await db.query.ssoRoleMapping.findMany({
57+
where: {
58+
organizationId: { eq: organizationId },
59+
groupName: { in: names },
60+
},
61+
columns: { role: true },
62+
});
63+
for (const mapping of mappings) {
64+
if (ROLE_RANK[mapping.role] > ROLE_RANK[mappedRole]) {
65+
mappedRole = mapping.role;
66+
}
67+
}
68+
}
69+
}
70+
71+
if (membership.role === "owner" && ROLE_RANK[mappedRole] < ROLE_RANK.owner) {
72+
return null;
73+
}
74+
if (membership.role !== mappedRole) {
75+
await db
76+
.update(tables.userOrganization)
77+
.set({ role: mappedRole })
78+
.where(eq(tables.userOrganization.id, membership.id));
79+
return { old: membership.role, new: mappedRole };
80+
}
81+
return null;
82+
}
83+
84+
// Recompute the role of every member of the SCIM group(s) with `groupName` in
85+
// this org. Used when a role mapping is added or removed after the IdP has
86+
// already pushed the group and its members, so existing members pick up (or
87+
// lose) the mapped role without waiting for a later SCIM membership event.
88+
export async function recomputeRoleForGroupName(
89+
organizationId: string,
90+
groupName: string,
91+
) {
92+
const groups = await db.query.scimGroup.findMany({
93+
where: {
94+
organizationId: { eq: organizationId },
95+
displayName: { eq: groupName },
96+
},
97+
columns: { id: true },
98+
});
99+
if (!groups.length) {
100+
return;
101+
}
102+
103+
const members = await db.query.scimGroupMember.findMany({
104+
where: { scimGroupId: { in: groups.map((g) => g.id) } },
105+
columns: { userId: true },
106+
});
107+
const userIds = [...new Set(members.map((m) => m.userId))];
108+
for (const userId of userIds) {
109+
await recomputeUserRole(userId, organizationId);
110+
}
111+
}

apps/api/src/routes/keys-api.spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,79 @@ describe("keys route", () => {
417417
expect(apiKey?.periodUsageDurationUnit).toBe("day");
418418
});
419419

420+
test("POST /keys/api rejects a limit above the member budget", async () => {
421+
await db
422+
.update(tables.userOrganization)
423+
.set({ usageLimit: "10" })
424+
.where(eq(tables.userOrganization.id, "test-user-org-id"));
425+
426+
const res = await app.request("/keys/api", {
427+
method: "POST",
428+
headers: {
429+
"Content-Type": "application/json",
430+
Cookie: token,
431+
},
432+
body: JSON.stringify({
433+
description: "Over-budget key",
434+
projectId: "test-project-id",
435+
usageLimit: "50",
436+
}),
437+
});
438+
439+
expect(res.status).toBe(400);
440+
const json = await res.json();
441+
expect(json.message).toMatch(/organization limit of \$10\.00/);
442+
443+
const apiKey = await db.query.apiKey.findFirst({
444+
where: { description: { eq: "Over-budget key" } },
445+
});
446+
expect(apiKey).toBeUndefined();
447+
});
448+
449+
test("POST /keys/api allows a limit at or below the member budget", async () => {
450+
await db
451+
.update(tables.userOrganization)
452+
.set({ usageLimit: "10" })
453+
.where(eq(tables.userOrganization.id, "test-user-org-id"));
454+
455+
const res = await app.request("/keys/api", {
456+
method: "POST",
457+
headers: {
458+
"Content-Type": "application/json",
459+
Cookie: token,
460+
},
461+
body: JSON.stringify({
462+
description: "Within-budget key",
463+
projectId: "test-project-id",
464+
usageLimit: "10",
465+
}),
466+
});
467+
468+
expect(res.status).toBe(200);
469+
const json = await res.json();
470+
expect(json.apiKey.usageLimit).toBe("10");
471+
});
472+
473+
test("PATCH /keys/api/limit/{id} rejects a limit above the member budget", async () => {
474+
await db
475+
.update(tables.userOrganization)
476+
.set({ usageLimit: "10" })
477+
.where(eq(tables.userOrganization.id, "test-user-org-id"));
478+
479+
const res = await app.request("/keys/api/limit/test-api-key-id", {
480+
method: "PATCH",
481+
headers: {
482+
"Content-Type": "application/json",
483+
Cookie: token,
484+
},
485+
body: JSON.stringify({ usageLimit: "50" }),
486+
});
487+
488+
expect(res.status).toBe(400);
489+
const json = await res.json();
490+
expect(json.message).toMatch(/organization limit of \$10\.00/);
491+
});
492+
420493
test("PATCH /keys/api/limit/{id} updates and resets period usage", async () => {
421494
await db
422495
.update(tables.apiKey)

0 commit comments

Comments
 (0)