Merge upstream/dev into feature-response-headers

Resolve conflicts against upstream's refactors:

- server/db/sqlite/schema/schema.ts: adopt upstream's reindented
  sqliteTable(name, cols, indexes) form for sites/resources, re-applying
  the headers -> requestHeaders/responseHeaders split. Kept in sync with
  the Postgres schema.
- server/lib/traefik/headersMiddleware.ts: extend upstream's extracted
  buildCustomHeadersMiddleware helper to take requestHeaders and
  responseHeaders and emit both customRequestHeaders and
  customResponseHeaders.
- server/lib/traefik/getTraefikConfig.ts and
  server/private/lib/traefik/getTraefikConfig.ts: keep upstream's helper
  extraction and appendPathMatch refactor, dropping the superseded inline
  blocks.

Also carry the feature forward onto code that moved upstream:

- The resource settings UI moved from resources/proxy/[niceId]/proxy to
  resources/public/[niceId]/http, which dropped this branch's changes in
  the previous merge. Re-add the request/response header inputs there and
  rename the vestigial headers field on the tcp page.
- messages/da-DK.json is new upstream and still had the old customHeaders
  key; rename it in line with the other locales.

Per the contributing docs, versioned migrations are intentionally omitted
so maintainers can write them at release time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Julian van der Horst
2026-09-01 13:40:49 +02:00
co-authored by Claude Opus 5
694 changed files with 77422 additions and 12896 deletions
@@ -0,0 +1,154 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources, resourceAiModels } 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 {
assertPublicModelListApiEligible,
assertPublicResourceModelEntriesValid,
modelListTypeSchema
} from "@server/lib/aiInferenceResource";
const addAiModelToResourceBodySchema = z.strictObject({
modelId: z.number().int().positive(),
listType: modelListTypeSchema.optional().default("allow")
});
const addAiModelToResourceParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/ai-models/add",
description:
"Add a single model to an inference resource allow/block selection. Requires at least one attached AI provider in select mode. The model must belong to a select-mode provider and its listType must match the provider catalog entry. listType defaults to allow.",
tags: [OpenAPITags.PublicResource],
request: {
params: addAiModelToResourceParamsSchema,
body: {
content: {
"application/json": {
schema: addAiModelToResourceBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function addAiModelToResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = addAiModelToResourceBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { modelId, listType } = parsedBody.data;
const parsedParams = addAiModelToResourceParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const eligibleError = await assertPublicModelListApiEligible(resource);
if (eligibleError) {
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
}
const modelError = await assertPublicResourceModelEntriesValid({
orgId: resource.orgId,
resourceId,
models: [{ modelId, listType }]
});
if (modelError) {
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
}
const existingEntry = await db
.select()
.from(resourceAiModels)
.where(
and(
eq(resourceAiModels.resourceId, resourceId),
eq(resourceAiModels.modelId, modelId)
)
);
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Model already assigned to resource"
)
);
}
await db
.insert(resourceAiModels)
.values({ resourceId, modelId, listType });
return response(res, {
data: {},
success: true,
error: false,
message: "Model added to resource successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,157 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, 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";
import {
isInferenceFieldsError,
listPublicResourceAiProviders,
resolveProviderAttachments,
setPublicResourceAiProviders
} from "@server/lib/aiInferenceResource";
const addAiProviderToResourceBodySchema = z.strictObject({
providerId: z.number().int().positive()
});
const addAiProviderToResourceParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/ai-providers/add",
description:
"Add or replace a single AI provider attachment on an inference resource. The provider is attached in inherit mode, using its own allow/block lists.",
tags: [OpenAPITags.PublicResource],
request: {
params: addAiProviderToResourceParamsSchema,
body: {
content: {
"application/json": {
schema: addAiProviderToResourceBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function addAiProviderToResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = addAiProviderToResourceBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { providerId } = parsedBody.data;
const parsedParams = addAiProviderToResourceParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
if (resource.mode !== "inference") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"AI providers can only be attached to inference-mode resources"
)
);
}
const existing = await listPublicResourceAiProviders(resourceId);
const nextAttachments = [
...existing
.filter((a) => a.providerId !== providerId)
.map((a) => ({
providerId: a.providerId,
accessMode: a.accessMode,
enabled: a.enabled
})),
{
providerId,
accessMode: "inherit" as const,
enabled: true as const
}
];
const attachments = await resolveProviderAttachments({
orgId: resource.orgId,
attachments: nextAttachments,
requireAtLeastOne: true
});
if (isInferenceFieldsError(attachments)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, attachments.error)
);
}
await setPublicResourceAiProviders(resourceId, attachments);
return response(res, {
data: {},
success: true,
error: false,
message: "AI provider added to resource successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -1,7 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources, resourceWhitelist } from "@server/db";
import {
resources,
resourceWhitelist,
resourcePolicies,
resourcePolicyWhiteList
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -29,6 +34,39 @@ registry.registerPath({
method: "post",
path: "/resource/{resourceId}/whitelist/add",
description: "Add a single email to the resource whitelist.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: addEmailToResourceWhitelistParamsSchema,
body: {
content: {
"application/json": {
schema: addEmailToResourceWhitelistBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "post",
path: "/public-resource/{resourceId}/whitelist/add",
description: "Add a single email to the resource whitelist.",
tags: [OpenAPITags.PublicResource],
request: {
params: addEmailToResourceWhitelistParamsSchema,
@@ -103,40 +141,96 @@ export async function addEmailToResourceWhitelist(
);
}
if (!resource.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
// A shared policy takes precedence over the resource's inline
// (default) policy, which takes precedence over the resource's own
// direct whitelist fields. This mirrors the precedence used at
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
const policyId =
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
if (policyId !== null) {
const [policy] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, policyId));
if (!policy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Resource policy not found"
)
);
}
if (!policy.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
const existingEntry = await db
.select()
.from(resourcePolicyWhiteList)
.where(
and(
eq(resourcePolicyWhiteList.resourcePolicyId, policyId),
eq(resourcePolicyWhiteList.email, email)
)
);
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Email already exists in whitelist"
)
);
}
await db.insert(resourcePolicyWhiteList).values({
email,
resourcePolicyId: policyId
});
} else {
if (!resource.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
// Check if email already exists in whitelist
const existingEntry = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Email already exists in whitelist"
)
);
}
await db.insert(resourceWhitelist).values({
email,
resourceId
});
}
// Check if email already exists in whitelist
const existingEntry = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Email already exists in whitelist"
)
);
}
await db.insert(resourceWhitelist).values({
email,
resourceId
});
return response(res, {
data: {},
success: true,
@@ -28,6 +28,40 @@ const addRoleToResourceParamsSchema = z
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/roles/add",
description:
"Add a single role to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the role is added to the inline policy instead of directly to the resource.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: addRoleToResourceParamsSchema,
body: {
content: {
"application/json": {
schema: addRoleToResourceBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "post",
path: "/public-resource/{resourceId}/roles/add",
description:
"Add a single role to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the role is added to the inline policy instead of directly to the resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.Role],
@@ -28,6 +28,40 @@ const addUserToResourceParamsSchema = z
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/users/add",
description:
"Add a single user to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the user is added to the inline policy instead of directly to the resource.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: addUserToResourceParamsSchema,
body: {
content: {
"application/json": {
schema: addUserToResourceBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "post",
path: "/public-resource/{resourceId}/users/add",
description:
"Add a single user to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the user is added to the inline policy instead of directly to the resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.User],
+52 -69
View File
@@ -1,6 +1,5 @@
import { generateSessionToken } from "@server/auth/sessions/app";
import { db } from "@server/db";
import { Resource, resources } from "@server/db";
import { db, users } from "@server/db";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq } from "drizzle-orm";
@@ -65,82 +64,35 @@ export async function authWithAccessToken(
const { accessToken, accessTokenId } = parsedBody.data;
try {
let valid;
let tokenItem;
let error;
let resource: Resource | undefined;
if (accessTokenId) {
if (!resourceId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Resource ID is required"
)
);
}
const [foundResource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!foundResource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const res = await verifyResourceAccessToken({
const { valid, tokenItem, error, resource } =
await verifyResourceAccessToken({
accessToken,
accessTokenId,
accessToken
resourceId
});
valid = res.valid;
tokenItem = res.tokenItem;
error = res.error;
resource = foundResource;
} else {
const res = await verifyResourceAccessToken({
accessToken
});
if (!valid || !tokenItem || !resource) {
if (resource) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource access token invalid. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
);
}
valid = res.valid;
tokenItem = res.tokenItem;
error = res.error;
resource = res.resource;
}
if (!tokenItem || !resource) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"Access token does not exist for resource"
)
);
}
if (!valid) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource access token invalid. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
);
logAccessAudit({
orgId: resource.orgId,
resourceId: resource.resourceId,
action: false,
type: "accessToken",
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
}
logAccessAudit({
orgId: resource.orgId,
resourceId: resource.resourceId,
action: false,
type: "accessToken",
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
error || "Invalid access token"
error || "Access token does not exist for resource"
)
);
}
@@ -156,11 +108,42 @@ export async function authWithAccessToken(
doNotExtend: true
});
let accessAuditUser: { username: string; userId: string } | undefined;
if (tokenItem.userId) {
const [associatedUser] = await db
.select({
userId: users.userId,
username: users.username
})
.from(users)
.where(eq(users.userId, tokenItem.userId))
.limit(1);
if (associatedUser) {
accessAuditUser = {
userId: associatedUser.userId,
username: associatedUser.username
};
}
}
logAccessAudit({
orgId: resource.orgId,
resourceId: resource.resourceId,
action: true,
type: "accessToken",
apiKey: accessAuditUser
? undefined
: {
name: tokenItem.title,
apiKeyId: tokenItem.accessTokenId
},
user: accessAuditUser,
metadata: accessAuditUser
? {
accessTokenId: tokenItem.accessTokenId,
accessTokenTitle: tokenItem.title
}
: undefined,
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
+119 -29
View File
@@ -18,37 +18,44 @@ import {
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { eq, and } from "drizzle-orm";
import { eq, and, ne } from "drizzle-orm";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
import { OpenAPITags, registry } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { createCertificate } from "@server/routers/certificates";
import {
validateAndConstructDomain,
checkWildcardDomainConflict
} from "@server/lib/domainUtils";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import {
getUniqueResourceName,
getUniqueResourcePolicyName
} from "@server/db/names";
import { usageService } from "@server/lib/billing/usageService";
import { LimitId } from "@server/lib/billing";
import {
isInferenceFieldsError,
resolveProviderAttachments,
resourceAiProviderAttachmentSchema,
setPublicResourceAiProviders,
type ResourceAiProviderAttachment
} from "@server/lib/aiInferenceResource";
const createResourceParamsSchema = z.strictObject({
orgId: z.string()
});
function resolveModeFromLegacyFields(data: {
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference";
http?: boolean;
protocol?: "tcp" | "udp";
}): {
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference";
error?: string;
} {
if (data.mode) {
@@ -90,11 +97,20 @@ const createHttpResourceSchema = z
domainId: z.string(),
stickySession: z.boolean().optional(),
postAuthPath: z.string().nullable().optional(),
mode: z.enum(["http", "ssh", "rdp", "vnc", "tcp", "udp"]).optional(),
mode: z
.enum(["http", "ssh", "rdp", "vnc", "tcp", "udp", "inference"])
.optional(),
// SSH Settings
pamMode: z.enum(["passthrough", "push"]).optional(),
authDaemonPort: z.int().positive().optional(),
authDaemonMode: z.enum(["site", "remote", "native"]).optional()
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
// Inference settings
aiProviders: z
.array(resourceAiProviderAttachmentSchema)
.optional()
.describe(
"For inference-mode resources: AI providers to attach. Providers are attached in inherit mode, using each provider's own allow/block lists. Effective allow model keys must be unique across attached providers."
)
})
.refine(
(data) => {
@@ -153,6 +169,39 @@ registry.registerPath({
method: "put",
path: "/org/{orgId}/resource",
description: "Create a resource.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: createResourceParamsSchema,
body: {
content: {
"application/json": {
schema: createHttpResourceSchema.or(createRawResourceSchema)
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "put",
path: "/org/{orgId}/public-resource",
description: "Create a resource.",
tags: [OpenAPITags.PublicResource],
request: {
params: createResourceParamsSchema,
@@ -332,11 +381,50 @@ async function createHttpResource(
mode,
authDaemonPort,
authDaemonMode,
pamMode
pamMode,
aiProviders: aiProviderInputs
} = parsedBody.data;
const subdomain = parsedBody.data.subdomain;
const stickySession = parsedBody.data.stickySession;
const effectiveMode = mode ?? "http";
let providerAttachments: ResourceAiProviderAttachment[] = [];
if (effectiveMode === "inference") {
// A new resource has no model selections yet, so providers always start
// in inherit mode; select can be enabled afterwards.
const resolved = await resolveProviderAttachments({
orgId,
attachments: (aiProviderInputs ?? []).map((p) => ({
providerId: p.providerId,
accessMode: "inherit" as const,
enabled: true as const
})),
requireAtLeastOne: false
});
if (isInferenceFieldsError(resolved)) {
return next(createHttpError(HttpCode.BAD_REQUEST, resolved.error));
}
providerAttachments = resolved;
} else if (aiProviderInputs && aiProviderInputs.length > 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"AI providers can only be attached to inference-mode resources"
)
);
}
// Wildcard subdomains are not allowed for inference-mode resources
if (effectiveMode === "inference" && subdomain && subdomain.includes("*")) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Wildcard subdomains are not supported for inference-mode resources."
)
);
}
// Wildcard subdomains are a paid feature
if (subdomain && subdomain.includes("*")) {
const isLicensed = await isLicensedOrSubscribed(
@@ -376,21 +464,6 @@ async function createHttpResource(
}
}
if (
["ssh", "rdp", "vnc"].includes(mode!) &&
!isLicensedOrSubscribed(
orgId!,
tierMatrix[TierFeature.AdvancedPublicResources]
)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Your current subscription does not support browser gateway resources. Please upgrade to access this feature."
)
);
}
// Validate domain and construct full domain
const domainResult = await validateAndConstructDomain(
domainId,
@@ -406,11 +479,22 @@ async function createHttpResource(
logger.debug(`Full domain: ${fullDomain}`);
// make sure the full domain is unique
// make sure the full domain is unique. Inference resources are routed
// through the central AI gateway rather than normal target-based
// proxying, so they're allowed to share a full-domain with a
// non-inference resource (and vice versa) - only conflicts within the
// same routing category are rejected.
const existingResource = await db
.select()
.from(resources)
.where(eq(resources.fullDomain, fullDomain));
.where(
and(
eq(resources.fullDomain, fullDomain),
effectiveMode === "inference"
? ne(resources.mode, "inference")
: eq(resources.mode, "inference")
)
);
if (existingResource.length > 0) {
return next(
@@ -510,7 +594,7 @@ async function createHttpResource(
orgId,
name,
subdomain: finalSubdomain,
mode: mode,
mode: effectiveMode,
pamMode: pamMode,
authDaemonMode: authDaemonMode,
authDaemonPort: authDaemonPort,
@@ -523,6 +607,14 @@ async function createHttpResource(
})
.returning();
if (providerAttachments.length > 0) {
await setPublicResourceAiProviders(
newResource[0].resourceId,
providerAttachments,
trx
);
}
await trx.insert(roleResources).values({
roleId: adminRole[0].roleId,
resourceId: newResource[0].resourceId
@@ -550,9 +642,7 @@ async function createHttpResource(
);
}
if (build !== "oss") {
await createCertificate(domainId, fullDomain, db);
}
await createCertificate(domainId, fullDomain, db);
return response<CreateResourceResponse>(res, {
data: resource,
+44 -38
View File
@@ -9,16 +9,14 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import {
isValidCIDR,
isValidIP,
isValidUrlGlobPattern
RESOURCE_RULE_MATCH_TYPES,
getResourceRuleValueValidationError
} from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi";
import { isValidRegionId } from "@server/db/regions";
const createResourceRuleSchema = z.strictObject({
action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"]),
match: z.enum(RESOURCE_RULE_MATCH_TYPES),
value: z.string().min(1),
priority: z.int(),
enabled: z.boolean().optional()
@@ -32,6 +30,39 @@ registry.registerPath({
method: "put",
path: "/resource/{resourceId}/rule",
description: "Create a resource rule.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: createResourceRuleParamsSchema,
body: {
content: {
"application/json": {
schema: createResourceRuleSchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "put",
path: "/public-resource/{resourceId}/rule",
description: "Create a resource rule.",
tags: [OpenAPITags.PublicResource, OpenAPITags.Rule],
request: {
params: createResourceRuleParamsSchema,
@@ -118,39 +149,14 @@ export async function createResourceRule(
);
}
if (match === "CIDR") {
if (!isValidCIDR(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid CIDR provided"
)
);
}
} else if (match === "IP") {
if (!isValidIP(value)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
);
}
} else if (match === "PATH") {
if (!isValidUrlGlobPattern(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid URL glob pattern provided"
)
);
}
} else if (match === "REGION") {
if (!isValidRegionId(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid region ID provided"
)
);
}
const valueValidationError = getResourceRuleValueValidationError(
match,
value
);
if (valueValidationError) {
return next(
createHttpError(HttpCode.BAD_REQUEST, valueValidationError)
);
}
// Create the new resource rule
+26
View File
@@ -22,6 +22,32 @@ registry.registerPath({
method: "delete",
path: "/resource/{resourceId}",
description: "Delete a resource.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: deleteResourceSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "delete",
path: "/public-resource/{resourceId}",
description: "Delete a resource.",
tags: [OpenAPITags.PublicResource],
request: {
params: deleteResourceSchema
@@ -19,6 +19,32 @@ registry.registerPath({
method: "delete",
path: "/resource/{resourceId}/rule/{ruleId}",
description: "Delete a resource rule.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: deleteResourceRuleSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "delete",
path: "/public-resource/{resourceId}/rule/{ruleId}",
description: "Delete a resource rule.",
tags: [OpenAPITags.PublicResource, OpenAPITags.Rule],
request: {
params: deleteResourceRuleSchema
@@ -0,0 +1,83 @@
import response from "@server/lib/response";
import {
getBatchedStatusHistory,
type BatchedStatusHistoryResponse
} from "@server/lib/statusHistory";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const resourceIdParamsSchema = z.object({
days: z
.string()
.optional()
.transform((v) => (v ? parseInt(v, 10) : 90)),
// Minutes to add to UTC to get the requesting client's local time
// (e.g. Australia/Sydney standard time is 600). Optional and
// defaults to 0 (UTC) so older clients keep the prior behavior.
tzOffsetMinutes: z
.string()
.optional()
.transform((v) => (v ? parseInt(v, 10) : 0)),
resourceIds: z
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
return undefined;
}
const raw = Array.isArray(val) ? val : [val];
const nums = raw
.map((v) =>
typeof v === "string" ? parseInt(v, 10) : Number(v)
)
.filter((n) => Number.isInteger(n) && n > 0);
const unique = [...new Set(nums)];
return unique.length ? unique : undefined;
}, z.array(z.number().int().positive()))
.openapi({
description: "Filter by resourceIds (repeat query param)"
})
});
export async function getBatchedResourceStatusHistory(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = resourceIdParamsSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const entityType = "resource";
const { days, resourceIds, tzOffsetMinutes } = parsedQuery.data;
const data = await getBatchedStatusHistory(
entityType,
resourceIds,
days,
tzOffsetMinutes
);
return response<BatchedStatusHistoryResponse>(res, {
data,
success: true,
error: false,
message: "Status history retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+29 -1
View File
@@ -64,7 +64,7 @@ registry.registerPath({
path: "/org/{orgId}/resource/{niceId}",
description:
"Get a resource by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.",
tags: [OpenAPITags.PublicResource],
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: z.object({
orgId: z.string(),
@@ -93,6 +93,34 @@ registry.registerPath({
method: "get",
path: "/resource/{resourceId}",
description: "Get a resource by resourceId.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: z.object({
resourceId: z.number()
})
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "get",
path: "/public-resource/{resourceId}",
description: "Get a resource by resourceId.",
tags: [OpenAPITags.PublicResource],
request: {
params: z.object({
@@ -42,6 +42,7 @@ export type GetResourceAuthInfoResponse = {
skipToIdpId: number | null;
orgId: string;
postAuthPath: string | null;
mode: string;
};
export async function getResourceAuthInfo(
@@ -227,7 +228,8 @@ export async function getResourceAuthInfo(
whitelist: effectivePolicy?.emailWhitelistEnabled ?? false,
skipToIdpId: effectivePolicy?.idpId ?? resource.skipToIdpId,
orgId: resource.orgId,
postAuthPath: resource.postAuthPath ?? null
postAuthPath: resource.postAuthPath ?? null,
mode: resource.mode
},
success: true,
error: false,
+15 -2
View File
@@ -25,8 +25,21 @@ export type GetResourcePoliciesResponse = {
registry.registerPath({
method: "get",
path: "/resource/{resourceId}/policies",
description: "Get the inline and shared policies associated with a resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.Policy],
description:
"Get the inline and shared policies associated with a resource.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: getResourcePoliciesParamsSchema
},
responses: {}
});
registry.registerPath({
method: "get",
path: "/public-resource/{resourceId}/policies",
description:
"Get the inline and shared policies associated with a resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.PublicResourcePolicy],
request: {
params: getResourcePoliciesParamsSchema
},
@@ -44,6 +44,32 @@ registry.registerPath({
method: "get",
path: "/resource/{resourceId}/whitelist",
description: "Get the whitelist of emails for a specific resource.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: getResourceWhitelistSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "get",
path: "/public-resource/{resourceId}/whitelist",
description: "Get the whitelist of emails for a specific resource.",
tags: [OpenAPITags.PublicResource],
request: {
params: getResourceWhitelistSchema
@@ -96,13 +122,17 @@ export async function getResourceWhitelist(
);
}
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
// A shared policy takes precedence over the resource's inline
// (default) policy, which takes precedence over the resource's own
// direct whitelist fields. This mirrors the precedence used at
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
const policyId =
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
const whitelist = isInlinePolicy
? await queryPolicyWhitelist(resource.defaultResourcePolicyId!)
: await queryWhitelist(resourceId);
const whitelist =
policyId !== null
? await queryPolicyWhitelist(policyId)
: await queryWhitelist(resourceId);
return response<GetResourceWhitelistResponse>(res, {
data: {
+7 -2
View File
@@ -42,9 +42,14 @@ export async function getResourceStatusHistory(
const entityType = "resource";
const entityId = parsedParams.data.resourceId;
const { days } = parsedQuery.data;
const { days, tzOffsetMinutes } = parsedQuery.data;
const data = await getCachedStatusHistory(entityType, entityId, days);
const data = await getCachedStatusHistory(
entityType,
entityId,
days,
tzOffsetMinutes
);
return response<StatusHistoryResponse>(res, {
data,
+38 -47
View File
@@ -363,11 +363,6 @@ export async function getUserResources(
(r) => r.siteResourceId
);
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
let labelsForResources: Array<{
labelId: number;
name: string;
@@ -381,49 +376,45 @@ export async function getUserResources(
siteResourceId: number;
}> = [];
if (isLabelFeatureEnabled) {
[labelsForResources, labelsForSiteResources] = await Promise.all([
resourceIdList.length === 0
? Promise.resolve([])
: db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
resourceId: resourceLabels.resourceId
})
.from(labels)
.innerJoin(
resourceLabels,
eq(resourceLabels.labelId, labels.labelId)
[labelsForResources, labelsForSiteResources] = await Promise.all([
resourceIdList.length === 0
? Promise.resolve([])
: db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
resourceId: resourceLabels.resourceId
})
.from(labels)
.innerJoin(
resourceLabels,
eq(resourceLabels.labelId, labels.labelId)
)
.where(inArray(resourceLabels.resourceId, resourceIdList))
.orderBy(asc(resourceLabels.resourceLabelId)),
siteResourceIdList.length === 0
? Promise.resolve([])
: db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
siteResourceId: siteResourceLabels.siteResourceId
})
.from(labels)
.innerJoin(
siteResourceLabels,
eq(siteResourceLabels.labelId, labels.labelId)
)
.where(
inArray(
siteResourceLabels.siteResourceId,
siteResourceIdList
)
.where(
inArray(resourceLabels.resourceId, resourceIdList)
)
.orderBy(asc(resourceLabels.resourceLabelId)),
siteResourceIdList.length === 0
? Promise.resolve([])
: db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
siteResourceId: siteResourceLabels.siteResourceId
})
.from(labels)
.innerJoin(
siteResourceLabels,
eq(siteResourceLabels.labelId, labels.labelId)
)
.where(
inArray(
siteResourceLabels.siteResourceId,
siteResourceIdList
)
)
.orderBy(asc(siteResourceLabels.siteResourceLabelId))
]);
}
)
.orderBy(asc(siteResourceLabels.siteResourceLabelId))
]);
// Check for password, pincode, and whitelist protection for each resource
const resourcesWithAuth = await Promise.all(
+9
View File
@@ -33,4 +33,13 @@ export * from "./removeUserFromResource";
export * from "./listAllResourceNames";
export * from "./removeEmailFromResourceWhitelist";
export * from "./getStatusHistory";
export * from "./getBatchedStatusHistory";
export * from "./getResourcePolicies";
export * from "./listResourceAiModels";
export * from "./setResourceAiModels";
export * from "./addAiModelToResource";
export * from "./removeAiModelFromResource";
export * from "./listResourceAiProviders";
export * from "./setResourceAiProviders";
export * from "./addAiProviderToResource";
export * from "./removeAiProviderFromResource";
@@ -33,6 +33,34 @@ registry.registerPath({
method: "get",
path: "/org/{orgId}/resources-names",
description: "List all resource names for an organization.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: z.object({
orgId: z.string()
})
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/public-resource-names",
description: "List all resource names for an organization.",
tags: [OpenAPITags.PublicResource],
request: {
params: z.object({
@@ -0,0 +1,109 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources, resourceAiModels, aiModels } 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 listResourceAiModelsParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
async function query(resourceId: number) {
return await db
.select({
modelId: aiModels.modelId,
modelKey: aiModels.modelKey,
name: aiModels.name,
providerId: aiModels.providerId,
enabled: aiModels.enabled,
listType: resourceAiModels.listType
})
.from(resourceAiModels)
.innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId))
.where(eq(resourceAiModels.resourceId, resourceId));
}
export type ListResourceAiModelsResponse = {
models: NonNullable<Awaited<ReturnType<typeof query>>>;
};
registry.registerPath({
method: "get",
path: "/resource/{resourceId}/ai-models",
description:
"List the models this resource has selected from its select-mode providers' allow/block lists. Providers in inherit mode are not represented here; they use their own lists.",
tags: [OpenAPITags.PublicResource],
request: {
params: listResourceAiModelsParamsSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function listResourceAiModels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = listResourceAiModelsParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const models = await query(resourceId);
return response<ListResourceAiModelsResponse>(res, {
data: { models },
success: true,
error: false,
message: "Resource AI models retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,95 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, 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";
import { listPublicResourceAiProviders } from "@server/lib/aiInferenceResource";
const listResourceAiProvidersParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
export type ListResourceAiProvidersResponse = {
providers: Awaited<ReturnType<typeof listPublicResourceAiProviders>>;
};
registry.registerPath({
method: "get",
path: "/resource/{resourceId}/ai-providers",
description:
"List AI providers attached to an inference resource, including each attachment's accessMode.",
tags: [OpenAPITags.PublicResource],
request: {
params: listResourceAiProvidersParamsSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function listResourceAiProviders(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = listResourceAiProvidersParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const providers = await listPublicResourceAiProviders(resourceId);
return response<ListResourceAiProvidersResponse>(res, {
data: { providers },
success: true,
error: false,
message: "Resource AI providers retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -48,6 +48,32 @@ registry.registerPath({
method: "get",
path: "/resource/{resourceId}/roles",
description: "List all roles for a resource.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: listResourceRolesSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "get",
path: "/public-resource/{resourceId}/roles",
description: "List all roles for a resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.Role],
request: {
params: listResourceRolesSchema
@@ -71,6 +71,33 @@ registry.registerPath({
method: "get",
path: "/resource/{resourceId}/rules",
description: "List rules for a resource.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: listResourceRulesParamsSchema,
query: listResourceRulesSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "get",
path: "/public-resource/{resourceId}/rules",
description: "List rules for a resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.Rule],
request: {
params: listResourceRulesParamsSchema,
+64 -3
View File
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { idp, userResources, users } from "@server/db"; // Assuming these are the correct tables
import { idp, resources, userPolicies, userResources, users } from "@server/db"; // Assuming these are the correct tables
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -14,7 +14,23 @@ const listResourceUsersSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
async function queryUsers(resourceId: number) {
async function queryUsers(resourceId: number, policyId: number | null) {
if (policyId !== null) {
return await db
.select({
userId: userPolicies.userId,
username: users.username,
type: users.type,
idpName: idp.name,
idpId: users.idpId,
email: users.email
})
.from(userPolicies)
.innerJoin(users, eq(userPolicies.userId, users.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.where(eq(userPolicies.resourcePolicyId, policyId));
}
return await db
.select({
userId: userResources.userId,
@@ -38,6 +54,32 @@ registry.registerPath({
method: "get",
path: "/resource/{resourceId}/users",
description: "List all users for a resource.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: listResourceUsersSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "get",
path: "/public-resource/{resourceId}/users",
description: "List all users for a resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.User],
request: {
params: listResourceUsersSchema
@@ -78,7 +120,26 @@ export async function listResourceUsers(
const { resourceId } = parsedParams.data;
const resourceUsersList = await queryUsers(resourceId);
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
const resourceUsersList = await queryUsers(
resourceId,
isInlinePolicy ? resource.defaultResourcePolicyId! : null
);
return response<ListResourceUsersResponse>(res, {
data: {
+86 -47
View File
@@ -124,12 +124,21 @@ const listResourcesSchema = z.strictObject({
"Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status."
}),
protocol: z
.enum(["http", "https", "tcp", "udp", "ssh", "rdp", "vnc"])
.enum(["http", "https", "tcp", "udp", "ssh", "rdp", "vnc", "inference"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["http", "https", "tcp", "udp", "ssh", "rdp", "vnc"],
enum: [
"http",
"https",
"tcp",
"udp",
"ssh",
"rdp",
"vnc",
"inference"
],
description:
"Filter resources by protocol. `http` and `https` match HTTP resources without and with SSL respectively."
}),
@@ -138,6 +147,15 @@ const listResourcesSchema = z.strictObject({
description:
"When set, only resources that have at least one target on this site are returned"
}),
status: z
.enum(["pending", "approved"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["pending", "approved"],
description: "Filter by resource status"
}),
labels: z
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
@@ -400,6 +418,35 @@ registry.registerPath({
method: "get",
path: "/org/{orgId}/resources",
description: "List resources for an organization.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: z.object({
orgId: z.string()
}),
query: listResourcesSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/public-resources",
description: "List resources for an organization.",
tags: [OpenAPITags.PublicResource],
request: {
params: z.object({
@@ -451,6 +498,7 @@ export async function listResources(
sort_by,
order,
siteId,
status,
labels: labelFilter
} = parsedQuery.data;
@@ -484,11 +532,6 @@ export async function listResources(
);
}
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
let accessibleResources: Array<{ resourceId: number }>;
if (req.user) {
accessibleResources = await db
@@ -603,11 +646,12 @@ export async function listResources(
${resourcePassword.passwordId}
)
`;
const browserGatewayModes = ["http", "ssh", "rdp", "vnc"];
const browserGatewayModes = ["http", "ssh", "rdp", "vnc"] as const;
switch (authState) {
case "none":
conditions.push(
// TODO: Does inference belong here?
or(eq(resources.mode, "tcp"), eq(resources.mode, "udp"))
);
break;
@@ -665,6 +709,10 @@ export async function listResources(
}
}
if (typeof status !== "undefined") {
conditions.push(eq(resources.status, status));
}
if (siteId != null) {
const resourcesWithSite = db
.select({ resourceId: targets.resourceId })
@@ -676,7 +724,7 @@ export async function listResources(
);
}
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
if (labelFilter && labelFilter.length > 0) {
conditions.push(
inArray(
resources.resourceId,
@@ -697,25 +745,20 @@ export async function listResources(
const queryList = [
like(sql`LOWER(${resources.name})`, q),
like(sql`LOWER(${resources.niceId})`, q),
like(sql`LOWER(${resources.fullDomain})`, q)
like(sql`LOWER(${resources.fullDomain})`, q),
inArray(
resources.resourceId,
db
.select({ id: resourceLabels.resourceId })
.from(resourceLabels)
.innerJoin(
labels,
eq(labels.labelId, resourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
resources.resourceId,
db
.select({ id: resourceLabels.resourceId })
.from(resourceLabels)
.innerJoin(
labels,
eq(labels.labelId, resourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList));
}
@@ -747,27 +790,23 @@ export async function listResources(
resourceId: number;
}> = [];
if (isLabelFeatureEnabled) {
labelsForResources =
resourceIdList.length === 0
? []
: await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
resourceId: resourceLabels.resourceId
})
.from(labels)
.innerJoin(
resourceLabels,
eq(resourceLabels.labelId, labels.labelId)
)
.where(
inArray(resourceLabels.resourceId, resourceIdList)
)
.orderBy(asc(resourceLabels.resourceLabelId));
}
labelsForResources =
resourceIdList.length === 0
? []
: await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
resourceId: resourceLabels.resourceId
})
.from(labels)
.innerJoin(
resourceLabels,
eq(resourceLabels.labelId, labels.labelId)
)
.where(inArray(resourceLabels.resourceId, resourceIdList))
.orderBy(asc(resourceLabels.resourceLabelId));
const allResourceTargets =
resourceIdList.length === 0
@@ -45,18 +45,19 @@ function userResourceAliasesCacheKey(
page: number,
pageSize: number,
includeLabels: boolean,
labelFilter: string[]
labelFilter: string[],
status?: "pending" | "approved"
) {
const labelsKey =
labelFilter.length > 0 ? labelFilter.slice().sort().join(",") : "all";
return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}`;
return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}:${status ?? "all"}`;
}
const listUserResourceAliasesParamsSchema = z.strictObject({
orgId: z.string()
});
const listUserResourceAliasesQuerySchema = z.strictObject({
const listUserResourceAliasesQuerySchema = z.object({
pageSize: z.coerce
.number<string>()
.int()
@@ -96,7 +97,16 @@ const listUserResourceAliasesQuerySchema = z.strictObject({
type: "array",
description:
"Filter by resource labels. A resource matches when it has any of the given labels (OR)."
})
}),
status: z
.enum(["pending", "approved"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["pending", "approved"],
description: "Filter by site resource status"
})
});
export type UserResourceAliasItem = {
@@ -130,7 +140,8 @@ export async function listUserResourceAliases(
page,
pageSize,
includeLabels,
labels: labelFilter
labels: labelFilter,
status
} = parsedQuery.data;
const parsedParams = listUserResourceAliasesParamsSchema.safeParse(
@@ -172,7 +183,8 @@ export async function listUserResourceAliases(
page,
pageSize,
includeLabels,
labelFilter ?? []
labelFilter ?? [],
status
);
const cachedData: ListUserResourceAliasesResponse | undefined =
await cache.get(cacheKey);
@@ -248,11 +260,6 @@ export async function listUserResourceAliases(
});
}
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
const whereConditions = [
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
@@ -262,7 +269,11 @@ export async function listUserResourceAliases(
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
];
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
if (typeof status !== "undefined") {
whereConditions.push(eq(siteResources.status, status));
}
if (labelFilter && labelFilter.length > 0) {
whereConditions.push(
inArray(
siteResources.siteResourceId,
@@ -310,7 +321,7 @@ export async function listUserResourceAliases(
siteResourceId: number;
}> = [];
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) {
if (siteResourceIdList.length > 0) {
labelsForSiteResources = await db
.select({
name: labels.name,
@@ -0,0 +1,147 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources, resourceAiModels } 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 { assertPublicModelListApiEligible } from "@server/lib/aiInferenceResource";
const removeAiModelFromResourceBodySchema = z.strictObject({
modelId: z.int().positive()
});
const removeAiModelFromResourceParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/ai-models/remove",
description:
"Remove a single model from an inference resource allow/block list. Requires at least one attached AI provider.",
tags: [OpenAPITags.PublicResource],
request: {
params: removeAiModelFromResourceParamsSchema,
body: {
content: {
"application/json": {
schema: removeAiModelFromResourceBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function removeAiModelFromResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = removeAiModelFromResourceBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { modelId } = parsedBody.data;
const parsedParams = removeAiModelFromResourceParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const eligibleError = await assertPublicModelListApiEligible(resource);
if (eligibleError) {
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
}
const existingEntry = await db
.select()
.from(resourceAiModels)
.where(
and(
eq(resourceAiModels.resourceId, resourceId),
eq(resourceAiModels.modelId, modelId)
)
);
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Model not found in resource's restriction list"
)
);
}
await db
.delete(resourceAiModels)
.where(
and(
eq(resourceAiModels.resourceId, resourceId),
eq(resourceAiModels.modelId, modelId)
)
);
return response(res, {
data: {},
success: true,
error: false,
message: "Model removed from resource successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,160 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, 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";
import {
isInferenceFieldsError,
listPublicResourceAiProviders,
resolveProviderAttachments,
setPublicResourceAiProviders
} from "@server/lib/aiInferenceResource";
const removeAiProviderFromResourceBodySchema = z.strictObject({
providerId: z.number().int().positive()
});
const removeAiProviderFromResourceParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/ai-providers/remove",
description:
"Remove an AI provider attachment from an inference resource. At least one provider must remain.",
tags: [OpenAPITags.PublicResource],
request: {
params: removeAiProviderFromResourceParamsSchema,
body: {
content: {
"application/json": {
schema: removeAiProviderFromResourceBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function removeAiProviderFromResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = removeAiProviderFromResourceBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { providerId } = parsedBody.data;
const parsedParams = removeAiProviderFromResourceParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
if (resource.mode !== "inference") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"AI providers can only be attached to inference-mode resources"
)
);
}
const existing = await listPublicResourceAiProviders(resourceId);
const found = existing.find((a) => a.providerId === providerId);
if (!found) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"AI provider is not attached to this resource"
)
);
}
const remaining = existing
.filter((a) => a.providerId !== providerId)
.map((a) => ({
providerId: a.providerId,
accessMode: a.accessMode,
enabled: a.enabled
}));
const attachments = await resolveProviderAttachments({
orgId: resource.orgId,
attachments: remaining,
requireAtLeastOne: false
});
if (isInferenceFieldsError(attachments)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, attachments.error)
);
}
await setPublicResourceAiProviders(resourceId, attachments);
return response(res, {
data: {},
success: true,
error: false,
message: "AI provider removed from resource successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -1,7 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources, resourceWhitelist } from "@server/db";
import {
resources,
resourceWhitelist,
resourcePolicies,
resourcePolicyWhiteList
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -29,6 +34,39 @@ registry.registerPath({
method: "post",
path: "/resource/{resourceId}/whitelist/remove",
description: "Remove a single email from the resource whitelist.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: removeEmailFromResourceWhitelistParamsSchema,
body: {
content: {
"application/json": {
schema: removeEmailFromResourceWhitelistBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "post",
path: "/public-resource/{resourceId}/whitelist/remove",
description: "Remove a single email from the resource whitelist.",
tags: [OpenAPITags.PublicResource],
request: {
params: removeEmailFromResourceWhitelistParamsSchema,
@@ -102,44 +140,104 @@ export async function removeEmailFromResourceWhitelist(
);
}
if (!resource.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
// A shared policy takes precedence over the resource's inline
// (default) policy, which takes precedence over the resource's own
// direct whitelist fields. This mirrors the precedence used at
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
const policyId =
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
if (policyId !== null) {
const [policy] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, policyId));
if (!policy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Resource policy not found"
)
);
}
if (!policy.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
const existingEntry = await db
.select()
.from(resourcePolicyWhiteList)
.where(
and(
eq(resourcePolicyWhiteList.resourcePolicyId, policyId),
eq(resourcePolicyWhiteList.email, email)
)
);
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Email not found in whitelist"
)
);
}
await db
.delete(resourcePolicyWhiteList)
.where(
and(
eq(resourcePolicyWhiteList.resourcePolicyId, policyId),
eq(resourcePolicyWhiteList.email, email)
)
);
} else {
if (!resource.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
// Check if email exists in whitelist
const existingEntry = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Email not found in whitelist"
)
);
}
await db
.delete(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
}
// Check if email exists in whitelist
const existingEntry = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Email not found in whitelist"
)
);
}
await db
.delete(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
return response(res, {
data: {},
success: true,
@@ -29,6 +29,39 @@ registry.registerPath({
method: "post",
path: "/resource/{resourceId}/roles/remove",
description: "Remove a single role from a resource.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: removeRoleFromResourceParamsSchema,
body: {
content: {
"application/json": {
schema: removeRoleFromResourceBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "post",
path: "/public-resource/{resourceId}/roles/remove",
description: "Remove a single role from a resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.Role],
request: {
params: removeRoleFromResourceParamsSchema,
@@ -29,6 +29,39 @@ registry.registerPath({
method: "post",
path: "/resource/{resourceId}/users/remove",
description: "Remove a single user from a resource.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: removeUserFromResourceParamsSchema,
body: {
content: {
"application/json": {
schema: removeUserFromResourceBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "post",
path: "/public-resource/{resourceId}/users/remove",
description: "Remove a single user from a resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.User],
request: {
params: removeUserFromResourceParamsSchema,
@@ -0,0 +1,153 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources, resourceAiModels } 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";
import {
assertPublicModelListApiEligible,
assertPublicResourceModelEntriesValid,
resourceAiModelEntrySchema
} from "@server/lib/aiInferenceResource";
const setResourceAiModelsBodySchema = z.strictObject({
models: z.array(resourceAiModelEntrySchema)
});
const setResourceAiModelsParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/ai-models",
description:
"Replace the allow/block model selection for an inference resource. Requires at least one attached AI provider in select mode. Models must belong to a select-mode provider and their listType must match the provider catalog entry. An empty array clears the selection, which denies all models for select-mode providers.",
tags: [OpenAPITags.PublicResource],
request: {
params: setResourceAiModelsParamsSchema,
body: {
content: {
"application/json": {
schema: setResourceAiModelsBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function setResourceAiModels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = setResourceAiModelsBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { models } = parsedBody.data;
const parsedParams = setResourceAiModelsParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const eligibleError = await assertPublicModelListApiEligible(resource);
if (eligibleError) {
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
}
const byModelId = new Map(
models.map((m) => [m.modelId, m.listType] as const)
);
const uniqueModels = [...byModelId.entries()].map(
([modelId, listType]) => ({ modelId, listType })
);
const modelError = await assertPublicResourceModelEntriesValid({
orgId: resource.orgId,
resourceId,
models: uniqueModels
});
if (modelError) {
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
}
await db.transaction(async (trx) => {
await trx
.delete(resourceAiModels)
.where(eq(resourceAiModels.resourceId, resourceId));
if (uniqueModels.length > 0) {
await trx.insert(resourceAiModels).values(
uniqueModels.map((m) => ({
resourceId,
modelId: m.modelId,
listType: m.listType
}))
);
}
});
return response(res, {
data: {},
success: true,
error: false,
message: "AI models set for resource successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,139 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, 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";
import {
isInferenceFieldsError,
resolveProviderAttachments,
resourceAiProviderAttachmentSchema,
setPublicResourceAiProviders
} from "@server/lib/aiInferenceResource";
const setResourceAiProvidersBodySchema = z.strictObject({
providers: z.array(resourceAiProviderAttachmentSchema)
});
const setResourceAiProvidersParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/ai-providers",
description:
"Replace the AI providers attached to an inference resource. Each provider uses accessMode inherit (default, uses the provider's own allow/block lists) or select (uses the resource's selected subset of that provider's catalog). An empty list clears all providers. Effective allow model keys must be unique across attached providers.",
tags: [OpenAPITags.PublicResource],
request: {
params: setResourceAiProvidersParamsSchema,
body: {
content: {
"application/json": {
schema: setResourceAiProvidersBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function setResourceAiProviders(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = setResourceAiProvidersBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { providers } = parsedBody.data;
const parsedParams = setResourceAiProvidersParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
if (resource.mode !== "inference") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"AI providers can only be attached to inference-mode resources"
)
);
}
const attachments = await resolveProviderAttachments({
orgId: resource.orgId,
attachments: providers,
requireAtLeastOne: false
});
if (isInferenceFieldsError(attachments)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, attachments.error)
);
}
await setPublicResourceAiProviders(resourceId, attachments);
return response(res, {
data: {},
success: true,
error: false,
message: "AI providers set for resource successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -29,6 +29,40 @@ const setResourceAuthMethodsBodySchema = z.strictObject({
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/header-auth",
description:
"Set or update the header authentication for a resource. If user and password is not provided, it will remove the header authentication.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: setResourceAuthMethodsParamsSchema,
body: {
content: {
"application/json": {
schema: setResourceAuthMethodsBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "post",
path: "/public-resource/{resourceId}/header-auth",
description:
"Set or update the header authentication for a resource. If user and password is not provided, it will remove the header authentication.",
tags: [OpenAPITags.PublicResource],
@@ -27,6 +27,40 @@ const setResourceAuthMethodsBodySchema = z.strictObject({
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/password",
description:
"Set the password for a resource. Setting the password to null will remove it.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: setResourceAuthMethodsParamsSchema,
body: {
content: {
"application/json": {
schema: setResourceAuthMethodsBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "post",
path: "/public-resource/{resourceId}/password",
description:
"Set the password for a resource. Setting the password to null will remove it.",
tags: [OpenAPITags.PublicResource],
@@ -27,6 +27,40 @@ const setResourceAuthMethodsBodySchema = z.strictObject({
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/pincode",
description:
"Set the PIN code for a resource. Setting the PIN code to null will remove it.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: setResourceAuthMethodsParamsSchema,
body: {
content: {
"application/json": {
schema: setResourceAuthMethodsBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "post",
path: "/public-resource/{resourceId}/pincode",
description:
"Set the PIN code for a resource. Setting the PIN code to null will remove it.",
tags: [OpenAPITags.PublicResource],
@@ -21,6 +21,40 @@ const setResourceRolesParamsSchema = z.strictObject({
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/roles",
description:
"Set roles for a resource. This will replace all existing roles. When the resource has an inline policy defined (no shared resource policy assigned), roles are set on the inline policy instead of directly on the resource.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: setResourceRolesParamsSchema,
body: {
content: {
"application/json": {
schema: setResourceRolesBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "post",
path: "/public-resource/{resourceId}/roles",
description:
"Set roles for a resource. This will replace all existing roles. When the resource has an inline policy defined (no shared resource policy assigned), roles are set on the inline policy instead of directly on the resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.Role],
@@ -21,6 +21,40 @@ const setUserResourcesParamsSchema = z.strictObject({
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/users",
description:
"Set users for a resource. This will replace all existing users. When the resource has an inline policy defined (no shared resource policy assigned), users are set on the inline policy instead of directly on the resource.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: setUserResourcesParamsSchema,
body: {
content: {
"application/json": {
schema: setUserResourcesBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "post",
path: "/public-resource/{resourceId}/users",
description:
"Set users for a resource. This will replace all existing users. When the resource has an inline policy defined (no shared resource policy assigned), users are set on the inline policy instead of directly on the resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.User],
@@ -24,7 +24,6 @@ const setResourceWhitelistBodySchema = z.strictObject({
})
)
)
.max(50)
.transform((v) => v.map((e) => e.toLowerCase()))
});
@@ -35,6 +34,40 @@ const setResourceWhitelistParamsSchema = z.strictObject({
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/whitelist",
description:
"Set email whitelist for a resource. This will replace all existing emails.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: setResourceWhitelistParamsSchema,
body: {
content: {
"application/json": {
schema: setResourceWhitelistBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "post",
path: "/public-resource/{resourceId}/whitelist",
description:
"Set email whitelist for a resource. This will replace all existing emails.",
tags: [OpenAPITags.PublicResource],
@@ -109,13 +142,14 @@ export async function setResourceWhitelist(
);
}
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
// A shared policy takes precedence over the resource's inline
// (default) policy, which takes precedence over the resource's own
// direct whitelist fields. This mirrors the precedence used at
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
const policyId =
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
if (policyId !== null) {
const [policy] = await db
.select()
.from(resourcePolicies)
+70 -7
View File
@@ -38,7 +38,7 @@ import {
} from "@server/lib/schemas";
import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { createCertificate } from "@server/routers/certificates/createCertificate";
import {
validateAndConstructDomain,
checkWildcardDomainConflict
@@ -249,6 +249,42 @@ const updateRawResourceBodySchema = z
registry.registerPath({
method: "post",
path: "/resource/{resourceId}",
description:
"Update a resource. Policy fields (sso, mfa, pincode, password, whitelist) update the inline policy when no shared resource policy is assigned; when a shared policy is assigned those fields override the shared policy for this resource only.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: updateResourceParamsSchema,
body: {
content: {
"application/json": {
schema: updateHttpResourceBodySchema.and(
updateRawResourceBodySchema
)
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "post",
path: "/public-resource/{resourceId}",
description:
"Update a resource. Policy fields (sso, mfa, pincode, password, whitelist) update the inline policy when no shared resource policy is assigned; when a shared policy is assigned those fields override the shared policy for this resource only.",
tags: [OpenAPITags.PublicResource],
@@ -318,8 +354,10 @@ export async function updateResource(
);
}
if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
// HANDLE UPDATING HTTP RESOURCES
if (
["http", "ssh", "rdp", "vnc", "inference"].includes(resource.mode)
) {
// HANDLE UPDATING HTTP / BROWSER / INFERENCE RESOURCES
return await updateHttpResource(
{
req,
@@ -502,6 +540,20 @@ async function updateHttpResource(
}
}
// Wildcard subdomains are not allowed for inference-mode resources
if (
resource.mode === "inference" &&
updateData.subdomain &&
updateData.subdomain.includes("*")
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Wildcard subdomains are not supported for inference-mode resources."
)
);
}
// Wildcard subdomains are a paid feature
if (updateData.subdomain && updateData.subdomain.includes("*")) {
if (!isLicensed) {
@@ -567,10 +619,23 @@ async function updateHttpResource(
logger.debug(`Full domain: ${fullDomain}`);
if (fullDomain) {
// Inference resources route through the central AI gateway
// rather than normal target-based proxying, so they're allowed
// to share a full-domain with a non-inference resource (and
// vice versa) - only conflicts within the same routing category
// are rejected. mode isn't updatable here, so `resource.mode`
// reflects the resource's actual (unchanging) routing category.
const [existingDomain] = await db
.select()
.from(resources)
.where(eq(resources.fullDomain, fullDomain));
.where(
and(
eq(resources.fullDomain, fullDomain),
resource.mode === "inference"
? ne(resources.mode, "inference")
: eq(resources.mode, "inference")
)
);
if (
existingDomain &&
@@ -636,9 +701,7 @@ async function updateHttpResource(
// Update the subdomain in the update data
updateData.subdomain = finalSubdomain;
if (build != "oss") {
await createCertificate(domainId, fullDomain, db);
}
await createCertificate(domainId, fullDomain, db);
}
let requestHeaders = undefined;
+46 -55
View File
@@ -9,12 +9,11 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import {
isValidCIDR,
isValidIP,
isValidUrlGlobPattern
RESOURCE_RULE_MATCH_TYPES,
getResourceRuleValueValidationError,
ResourceRuleMatchType
} from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi";
import { isValidRegionId } from "@server/db/regions";
// Define Zod schema for request parameters validation
const updateResourceRuleParamsSchema = z.strictObject({
@@ -22,14 +21,7 @@ const updateResourceRuleParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
const resourceRuleMatchSchema = z.enum([
"CIDR",
"IP",
"PATH",
"COUNTRY",
"ASN",
"REGION"
]);
const resourceRuleMatchSchema = z.enum(RESOURCE_RULE_MATCH_TYPES);
// Define Zod schema for request body validation
const updateResourceRuleSchema = z
@@ -48,6 +40,39 @@ registry.registerPath({
method: "post",
path: "/resource/{resourceId}/rule/{ruleId}",
description: "Update a resource rule.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: updateResourceRuleParamsSchema,
body: {
content: {
"application/json": {
schema: updateResourceRuleSchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "post",
path: "/public-resource/{resourceId}/rule/{ruleId}",
description: "Update a resource rule.",
tags: [OpenAPITags.PublicResource, OpenAPITags.Rule],
request: {
params: updateResourceRuleParamsSchema,
@@ -139,13 +164,7 @@ export async function updateResourceRule(
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
let existingMatch:
| "CIDR"
| "IP"
| "PATH"
| "COUNTRY"
| "ASN"
| "REGION";
let existingMatch: ResourceRuleMatchType;
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
@@ -229,42 +248,14 @@ export async function updateResourceRule(
const { value } = updateData;
if (value !== undefined) {
if (match === "CIDR") {
if (!isValidCIDR(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid CIDR provided"
)
);
}
} else if (match === "IP") {
if (!isValidIP(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid IP provided"
)
);
}
} else if (match === "PATH") {
if (!isValidUrlGlobPattern(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid URL glob pattern provided"
)
);
}
} else if (match === "REGION") {
if (!isValidRegionId(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid region ID provided"
)
);
}
const valueValidationError = getResourceRuleValueValidationError(
match,
value
);
if (valueValidationError) {
return next(
createHttpError(HttpCode.BAD_REQUEST, valueValidationError)
);
}
}