mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-05 20:21:19 +02:00
Pull the session cookie properly
This commit is contained in:
@@ -8,7 +8,8 @@ import {
|
|||||||
resourceAiModels,
|
resourceAiModels,
|
||||||
resources,
|
resources,
|
||||||
siteResourceAiModels,
|
siteResourceAiModels,
|
||||||
siteResources
|
siteResources,
|
||||||
|
users
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { decrypt } from "@server/lib/crypto";
|
import { decrypt } from "@server/lib/crypto";
|
||||||
@@ -18,18 +19,121 @@ import {
|
|||||||
AiProviderType,
|
AiProviderType,
|
||||||
resolveAiProviderConfig
|
resolveAiProviderConfig
|
||||||
} from "@server/lib/aiProviderDefaults";
|
} from "@server/lib/aiProviderDefaults";
|
||||||
|
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||||
|
import {
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
validateSessionToken
|
||||||
|
} from "@server/auth/sessions/app";
|
||||||
|
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
|
||||||
type ResolvedTarget = {
|
type ResolvedTarget = {
|
||||||
|
resourceId: number | null;
|
||||||
|
orgId: string | null;
|
||||||
provider: AiProvider;
|
provider: AiProvider;
|
||||||
// null = no restriction; every enabled model on the provider is allowed
|
// null = no restriction; every enabled model on the provider is allowed
|
||||||
allowedModelIds: number[] | null;
|
allowedModelIds: number[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fallback for clients that hit the endpoint directly (e.g. an AI tool's
|
||||||
|
// "API key" field) instead of going through a browser session - badger
|
||||||
|
// forwards whatever Authorization header the client sent untouched in that
|
||||||
|
// case. This prefix lets us tell "this bearer value is a Pangolin resource
|
||||||
|
// access token" apart from an arbitrary/opaque API key a user might paste
|
||||||
|
// in, without guessing based on format alone.
|
||||||
|
const USER_TOKEN_PREFIX = "pu_";
|
||||||
|
|
||||||
|
export type RequestUser = {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
name: string | null;
|
||||||
|
role: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function buildRequestUser(
|
||||||
|
userId: string,
|
||||||
|
orgId: string | null
|
||||||
|
): Promise<RequestUser | null> {
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgRoles = orgId ? await getUserOrgRoles(user.userId, orgId) : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: user.userId,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: orgRoles.map((r) => r.roleName).join(", ") || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRequestUser(
|
||||||
|
req: Request,
|
||||||
|
resourceId: number | null,
|
||||||
|
orgId: string | null
|
||||||
|
): Promise<RequestUser | null> {
|
||||||
|
// Public resources behind badger: badger passes the resource session
|
||||||
|
// cookie through to the backend (same mechanism the browser gateway,
|
||||||
|
// e.g. the SSH page, relies on), so we can validate it exactly like
|
||||||
|
// verifySessionUserMiddleware does for the dashboard.
|
||||||
|
const sessionToken = req.cookies?.[SESSION_COOKIE_NAME];
|
||||||
|
if (sessionToken) {
|
||||||
|
const { session, user } = await validateSessionToken(sessionToken);
|
||||||
|
if (session && user) {
|
||||||
|
return buildRequestUser(user.userId, orgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User devices hitting the endpoint directly (no browser session to
|
||||||
|
// forward) fall back to a Pangolin resource access token passed as the
|
||||||
|
// client's "API key".
|
||||||
|
const authHeader = req.headers["authorization"];
|
||||||
|
if (typeof authHeader !== "string" || !resourceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bearer = authHeader.match(/^Bearer\s+(.+)$/i)?.[1];
|
||||||
|
if (!bearer || !bearer.startsWith(USER_TOKEN_PREFIX)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [accessTokenId, accessToken] = bearer
|
||||||
|
.slice(USER_TOKEN_PREFIX.length)
|
||||||
|
.split(".");
|
||||||
|
if (!accessTokenId || !accessToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { valid, tokenItem } = await verifyResourceAccessToken({
|
||||||
|
accessToken,
|
||||||
|
accessTokenId,
|
||||||
|
resourceId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!valid || !tokenItem?.userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildRequestUser(tokenItem.userId, tokenItem.orgId);
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
||||||
const [resourceRow] = await db
|
const [resourceRow] = await db
|
||||||
.select({ resourceId: resources.resourceId, provider: aiProviders })
|
.select({
|
||||||
|
resourceId: resources.resourceId,
|
||||||
|
orgId: resources.orgId,
|
||||||
|
provider: aiProviders
|
||||||
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
aiProviders,
|
aiProviders,
|
||||||
@@ -52,6 +156,8 @@ async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
|||||||
.where(eq(resourceAiModels.resourceId, resourceRow.resourceId));
|
.where(eq(resourceAiModels.resourceId, resourceRow.resourceId));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
resourceId: resourceRow.resourceId,
|
||||||
|
orgId: resourceRow.orgId,
|
||||||
provider: resourceRow.provider,
|
provider: resourceRow.provider,
|
||||||
allowedModelIds: restrictions.length
|
allowedModelIds: restrictions.length
|
||||||
? restrictions.map((r) => r.modelId)
|
? restrictions.map((r) => r.modelId)
|
||||||
@@ -62,6 +168,7 @@ async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
|||||||
const [siteResourceRow] = await db
|
const [siteResourceRow] = await db
|
||||||
.select({
|
.select({
|
||||||
siteResourceId: siteResources.siteResourceId,
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
orgId: siteResources.orgId,
|
||||||
provider: aiProviders
|
provider: aiProviders
|
||||||
})
|
})
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
@@ -91,6 +198,11 @@ async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// siteResources have no per-user auth/policy stack today (see
|
||||||
|
// the routing comment in getTraefikConfig.ts), so there's no
|
||||||
|
// resource access token scope to validate a user token against.
|
||||||
|
resourceId: null,
|
||||||
|
orgId: siteResourceRow.orgId,
|
||||||
provider: siteResourceRow.provider,
|
provider: siteResourceRow.provider,
|
||||||
allowedModelIds: restrictions.length
|
allowedModelIds: restrictions.length
|
||||||
? restrictions.map((r) => r.modelId)
|
? restrictions.map((r) => r.modelId)
|
||||||
@@ -128,7 +240,18 @@ export async function chatCompletions(req: Request, res: Response): Promise<any>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { provider, allowedModelIds } = target;
|
const { provider, allowedModelIds, resourceId, orgId } = target;
|
||||||
|
|
||||||
|
// Best-effort identity resolution - not yet enforced, but lets us
|
||||||
|
// start making per-user access decisions (e.g. model/role-based
|
||||||
|
// restrictions) without another round of plumbing later.
|
||||||
|
const requestUser = await resolveRequestUser(req, resourceId, orgId);
|
||||||
|
if (requestUser) {
|
||||||
|
logger.debug(
|
||||||
|
`AI gateway request from user ${requestUser.userId} (${requestUser.username})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const requestedModel =
|
const requestedModel =
|
||||||
typeof req.body?.model === "string" ? req.body.model : undefined;
|
typeof req.body?.model === "string" ? req.body.model : undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -222,7 +222,9 @@ export async function verifyResourceSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { blockAccess, mode } = resource;
|
const { blockAccess, mode } = resource;
|
||||||
const dontStripSession = ["ssh", "rdp", "vnc"].includes(mode);
|
const dontStripSession = ["ssh", "rdp", "vnc", "inference"].includes(
|
||||||
|
mode
|
||||||
|
);
|
||||||
|
|
||||||
if (blockAccess) {
|
if (blockAccess) {
|
||||||
logger.debug("Resource blocked", host);
|
logger.debug("Resource blocked", host);
|
||||||
|
|||||||
Reference in New Issue
Block a user