Merge branch 'dev' into auth-providers-clients

This commit is contained in:
Owen
2025-05-03 11:45:11 -04:00
38 changed files with 455 additions and 204 deletions

View File

@@ -15,7 +15,6 @@ import {
} from "@server/middlewares";
import { authenticated, unauthenticated } from "@server/routers/integration";
import { logIncomingMiddleware } from "./middlewares/logIncoming";
import { csrfProtectionMiddleware } from "./middlewares/csrfProtection";
import helmet from "helmet";
import swaggerUi from "swagger-ui-express";
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
@@ -37,7 +36,6 @@ export function createIntegrationApiServer() {
if (!dev) {
apiServer.use(helmet());
apiServer.use(csrfProtectionMiddleware);
}
apiServer.use(cookieParser());

View File

@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.2.0";
export const APP_VERSION = "1.3.0";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);

View File

@@ -13,12 +13,19 @@ import moment from "moment";
import { setHostMeta } from "@server/setup/setHostMeta";
import { encrypt, decrypt } from "@server/lib/crypto";
const keyTypes = ["HOST", "SITES"] as const;
type KeyType = (typeof keyTypes)[number];
const keyTiers = ["PROFESSIONAL", "ENTERPRISE"] as const;
type KeyTier = (typeof keyTiers)[number];
export type LicenseStatus = {
isHostLicensed: boolean; // Are there any license keys?
isLicenseValid: boolean; // Is the license key valid?
hostId: string; // Host ID
maxSites?: number;
usedSites?: number;
tier?: KeyTier;
};
export type LicenseKeyCache = {
@@ -26,7 +33,8 @@ export type LicenseKeyCache = {
licenseKeyEncrypted: string;
valid: boolean;
iat?: Date;
type?: "LICENSE" | "SITES";
type?: KeyType;
tier?: KeyTier;
numSites?: number;
};
@@ -54,7 +62,8 @@ type ValidateLicenseAPIResponse = {
type TokenPayload = {
valid: boolean;
type: "LICENSE" | "SITES";
type: KeyType;
tier: KeyTier;
quantity: number;
terminateAt: string; // ISO
iat: number; // Issued at
@@ -182,11 +191,12 @@ LQIDAQAB
licenseKeyEncrypted: key.licenseKeyId,
valid: payload.valid,
type: payload.type,
tier: payload.tier,
numSites: payload.quantity,
iat: new Date(payload.iat * 1000)
});
if (payload.type === "LICENSE") {
if (payload.type === "HOST") {
foundHostKey = true;
}
} catch (e) {
@@ -273,6 +283,7 @@ LQIDAQAB
);
cached.valid = payload.valid;
cached.type = payload.type;
cached.tier = payload.tier;
cached.numSites = payload.quantity;
cached.iat = new Date(payload.iat * 1000);
@@ -311,8 +322,9 @@ LQIDAQAB
logger.debug("Checking key", cached);
if (cached.type === "LICENSE") {
if (cached.type === "HOST") {
status.isLicenseValid = cached.valid;
status.tier = cached.tier;
}
if (!cached.valid) {

View File

@@ -172,9 +172,20 @@ export async function listAccessTokens(
)
);
}
const { orgId, resourceId } = parsedParams.data;
const { resourceId } = parsedParams.data;
if (orgId && orgId !== req.userOrgId) {
const orgId =
parsedParams.data.orgId ||
req.userOrg?.orgId ||
req.apiKeyOrg?.orgId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
@@ -183,21 +194,29 @@ export async function listAccessTokens(
);
}
const accessibleResources = await db
.select({
resourceId: sql<number>`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})`
})
.from(userResources)
.fullJoin(
roleResources,
eq(userResources.resourceId, roleResources.resourceId)
)
.where(
or(
eq(userResources.userId, req.user!.userId),
eq(roleResources.roleId, req.userOrgRoleId!)
let accessibleResources;
if (req.user) {
accessibleResources = await db
.select({
resourceId: sql<number>`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})`
})
.from(userResources)
.fullJoin(
roleResources,
eq(userResources.resourceId, roleResources.resourceId)
)
);
.where(
or(
eq(userResources.userId, req.user!.userId),
eq(roleResources.roleId, req.userOrgRoleId!)
)
);
} else {
accessibleResources = await db
.select({ resourceId: resources.resourceId })
.from(resources)
.where(eq(resources.orgId, orgId));
}
const accessibleResourceIds = accessibleResources.map(
(resource) => resource.resourceId

View File

@@ -23,7 +23,7 @@ import { oidcAutoProvision } from "./oidcAutoProvision";
import license from "@server/license/license";
const ensureTrailingSlash = (url: string): string => {
return url.endsWith('/') ? url : `${url}/`;
return url.endsWith("/") ? url : `${url}/`;
};
const paramsSchema = z
@@ -228,6 +228,16 @@ export async function validateOidcCallback(
req,
res
});
return response<ValidateOidcUrlCallbackResponse>(res, {
data: {
redirectUrl: postAuthRedirectUrl
},
success: true,
error: false,
message: "OIDC callback validated successfully",
status: HttpCode.CREATED
});
} else {
if (!existingUser) {
return next(

View File

@@ -49,7 +49,7 @@ export async function createNewt(
const { newtId, secret } = parsedBody.data;
if (!req.userOrgRoleId) {
if (req.user && !req.userOrgRoleId) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);

View File

@@ -3,13 +3,16 @@ import { z } from "zod";
import { db } from "@server/db";
import { eq } from "drizzle-orm";
import {
apiKeyOrg,
apiKeys,
domains,
Org,
orgDomains,
orgs,
roleActions,
roles,
userOrgs
userOrgs,
users
} from "@server/db/schemas";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -57,7 +60,7 @@ export async function createOrg(
try {
// should this be in a middleware?
if (config.getRawConfig().flags?.disable_user_create_org) {
if (!req.user?.serverAdmin) {
if (req.user && !req.user?.serverAdmin) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
@@ -171,12 +174,33 @@ export async function createOrg(
}))
);
await trx.insert(userOrgs).values({
userId: req.user!.userId,
orgId: newOrg[0].orgId,
roleId: roleId,
isOwner: true
});
if (req.user) {
await trx.insert(userOrgs).values({
userId: req.user!.userId,
orgId: newOrg[0].orgId,
roleId: roleId,
isOwner: true
});
} else {
// if org created by root api key, set the server admin as the owner
const [serverAdmin] = await trx
.select()
.from(users)
.where(eq(users.serverAdmin, true));
if (!serverAdmin) {
error = "Server admin not found";
trx.rollback();
return;
}
await trx.insert(userOrgs).values({
userId: serverAdmin.userId,
orgId: newOrg[0].orgId,
roleId: roleId,
isOwner: true
});
}
const memberRole = await trx
.insert(roles)
@@ -194,6 +218,18 @@ export async function createOrg(
orgId
}))
);
const rootApiKeys = await trx
.select()
.from(apiKeys)
.where(eq(apiKeys.isRoot, true));
for (const apiKey of rootApiKeys) {
await trx.insert(apiKeyOrg).values({
apiKeyId: apiKey.apiKeyId,
orgId: newOrg[0].orgId
});
}
});
if (!org) {

View File

@@ -29,16 +29,16 @@ const listOrgsSchema = z.object({
.pipe(z.number().int().nonnegative())
});
registry.registerPath({
method: "get",
path: "/user/{userId}/orgs",
description: "List all organizations for a user.",
tags: [OpenAPITags.Org, OpenAPITags.User],
request: {
query: listOrgsSchema
},
responses: {}
});
// registry.registerPath({
// method: "get",
// path: "/user/{userId}/orgs",
// description: "List all organizations for a user.",
// tags: [OpenAPITags.Org, OpenAPITags.User],
// request: {
// query: listOrgsSchema
// },
// responses: {}
// });
export type ListUserOrgsResponse = {
orgs: Org[];

View File

@@ -39,6 +39,7 @@ const createHttpResourceSchema = z
isBaseDomain: z.boolean().optional(),
siteId: z.number(),
http: z.boolean(),
protocol: z.string(),
domainId: z.string()
})
.strict()
@@ -129,7 +130,7 @@ export async function createResource(
const { siteId, orgId } = parsedParams.data;
if (!req.userOrgRoleId) {
if (req.user && !req.userOrgRoleId) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
@@ -202,7 +203,7 @@ async function createHttpResource(
);
}
const { name, subdomain, isBaseDomain, http, domainId } =
const { name, subdomain, isBaseDomain, http, protocol, domainId } =
parsedBody.data;
const [orgDomain] = await db
@@ -261,7 +262,7 @@ async function createHttpResource(
name,
subdomain,
http,
protocol: "tcp",
protocol,
ssl: true,
isBaseDomain
})
@@ -284,7 +285,7 @@ async function createHttpResource(
resourceId: newResource[0].resourceId
});
if (req.userOrgRoleId != adminRole[0].roleId) {
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
// make sure the user can access the resource
await trx.insert(userResources).values({
userId: req.user?.userId!,

View File

@@ -69,9 +69,7 @@ function queryResources(
http: resources.http,
protocol: resources.protocol,
proxyPort: resources.proxyPort,
enabled: resources.enabled,
tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader
enabled: resources.enabled
})
.from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId))
@@ -105,9 +103,7 @@ function queryResources(
http: resources.http,
protocol: resources.protocol,
proxyPort: resources.proxyPort,
enabled: resources.enabled,
tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader
enabled: resources.enabled
})
.from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId))
@@ -187,9 +183,17 @@ export async function listResources(
)
);
}
const { siteId, orgId } = parsedParams.data;
const { siteId } = parsedParams.data;
if (orgId && orgId !== req.userOrgId) {
const orgId = parsedParams.data.orgId || req.userOrg?.orgId || req.apiKeyOrg?.orgId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
@@ -198,7 +202,9 @@ export async function listResources(
);
}
const accessibleResources = await db
let accessibleResources;
if (req.user) {
accessibleResources = await db
.select({
resourceId: sql<number>`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})`
})
@@ -213,6 +219,11 @@ export async function listResources(
eq(roleResources.roleId, req.userOrgRoleId!)
)
);
} else {
accessibleResources = await db.select({
resourceId: resources.resourceId
}).from(resources).where(eq(resources.orgId, orgId));
}
const accessibleResourceIds = accessibleResources.map(
(resource) => resource.resourceId

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { roleResources, roles } from "@server/db/schemas";
import { apiKeys, roleResources, roles } from "@server/db/schemas";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -74,6 +74,17 @@ export async function setResourceRoles(
const { resourceId } = parsedParams.data;
const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId;
if (!orgId) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Organization not found"
)
);
}
// get this org's admin role
const adminRole = await db
.select()
@@ -81,7 +92,7 @@ export async function setResourceRoles(
.where(
and(
eq(roles.name, "Admin"),
eq(roles.orgId, req.userOrg!.orgId)
eq(roles.orgId, orgId)
)
)
.limit(1);
@@ -136,3 +147,4 @@ export async function setResourceRoles(
);
}
}

View File

@@ -45,8 +45,8 @@ const updateHttpResourceBodySchema = z
domainId: z.string().optional(),
enabled: z.boolean().optional(),
stickySession: z.boolean().optional(),
tlsServerName: z.string().optional(),
setHostHeader: z.string().optional()
tlsServerName: z.string().nullable().optional(),
setHostHeader: z.string().nullable().optional()
})
.strict()
.refine((data) => Object.keys(data).length > 0, {
@@ -81,7 +81,10 @@ const updateHttpResourceBodySchema = z
}
return true;
},
{ message: "Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name." }
{
message:
"Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name."
}
)
.refine(
(data) => {
@@ -90,7 +93,10 @@ const updateHttpResourceBodySchema = z
}
return true;
},
{ message: "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header." }
{
message:
"Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header."
}
);
export type UpdateResourceResponse = Resource;
@@ -300,7 +306,22 @@ async function updateHttpResource(
const updatedResource = await db
.update(resources)
.set(updatePayload)
.set({
name: updatePayload.name,
subdomain: updatePayload.subdomain,
ssl: updatePayload.ssl,
sso: updatePayload.sso,
blockAccess: updatePayload.blockAccess,
emailWhitelistEnabled: updatePayload.emailWhitelistEnabled,
isBaseDomain: updatePayload.isBaseDomain,
applyRules: updatePayload.applyRules,
domainId: updatePayload.domainId,
enabled: updatePayload.enabled,
stickySession: updatePayload.stickySession,
tlsServerName: updatePayload.tlsServerName || null,
setHostHeader: updatePayload.setHostHeader || null,
fullDomain: updatePayload.fullDomain
})
.where(eq(resources.resourceId, resource.resourceId))
.returning();

View File

@@ -103,7 +103,7 @@ export async function createSite(
const { orgId } = parsedParams.data;
if (!req.userOrgRoleId) {
if (req.user && !req.userOrgRoleId) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
@@ -235,7 +235,7 @@ export async function createSite(
siteId: newSite.siteId
});
if (req.userOrgRoleId != adminRole[0].roleId) {
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
// make sure the user can access the site
trx.insert(userSites).values({
userId: req.user?.userId!,

View File

@@ -101,7 +101,7 @@ export async function listSites(
}
const { orgId } = parsedParams.data;
if (orgId && orgId !== req.userOrgId) {
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
@@ -110,18 +110,26 @@ export async function listSites(
);
}
const accessibleSites = await db
.select({
siteId: sql<number>`COALESCE(${userSites.siteId}, ${roleSites.siteId})`
})
.from(userSites)
.fullJoin(roleSites, eq(userSites.siteId, roleSites.siteId))
.where(
or(
eq(userSites.userId, req.user!.userId),
eq(roleSites.roleId, req.userOrgRoleId!)
)
);
let accessibleSites;
if (req.user) {
accessibleSites = await db
.select({
siteId: sql<number>`COALESCE(${userSites.siteId}, ${roleSites.siteId})`
})
.from(userSites)
.fullJoin(roleSites, eq(userSites.siteId, roleSites.siteId))
.where(
or(
eq(userSites.userId, req.user!.userId),
eq(roleSites.roleId, req.userOrgRoleId!)
)
);
} else {
accessibleSites = await db
.select({ siteId: sites.siteId })
.from(sites)
.where(eq(sites.orgId, orgId));
}
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
const baseQuery = querySites(orgId, accessibleSiteIds);

View File

@@ -49,7 +49,7 @@ export async function addUserRole(
const { userId, roleId } = parsedParams.data;
if (!req.userOrg) {
if (req.user && !req.userOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
@@ -58,7 +58,13 @@ export async function addUserRole(
);
}
const orgId = req.userOrg.orgId;
const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const existingUser = await db
.select()

View File

@@ -19,7 +19,15 @@ const paramsSchema = z
const bodySchema = z
.object({
email: z.string().email().optional(),
email: z
.string()
.optional()
.refine((data) => {
if (data) {
return z.string().email().safeParse(data).success;
}
return true;
}),
username: z.string().nonempty(),
name: z.string().optional(),
type: z.enum(["internal", "oidc"]).optional(),

View File

@@ -106,7 +106,7 @@ export async function getOrgUser(
);
}
if (user.userId !== req.userOrg.userId) {
if (req.user && user.userId !== req.userOrg.userId) {
const hasPermission = await checkUserActionPermission(
ActionsEnum.getOrgUser,
req

View File

@@ -8,8 +8,6 @@ import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
const version = "1.3.0";
const location = path.join(APP_PATH, "db", "db.sqlite");
await migration();
export default async function migration() {
console.log(`Running setup script ${version}...`);