update olm and client routes

This commit is contained in:
miloschwartz
2025-11-06 20:12:54 -08:00
parent 999fb2fff1
commit 2274a3525b
14 changed files with 495 additions and 186 deletions

View File

@@ -27,4 +27,5 @@ export * from "./verifyDomainAccess";
export * from "./verifyClientsEnabled";
export * from "./verifyUserIsOrgOwner";
export * from "./verifySiteResourceAccess";
export * from "./logActionAudit";
export * from "./logActionAudit";
export * from "./verifyOlmAccess";

View File

@@ -0,0 +1,45 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { db, olms } from "@server/db";
import { and, eq } from "drizzle-orm";
export async function verifyOlmAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const userId = req.user!.userId;
const olmId = req.params.olmId || req.body.olmId || req.query.olmId;
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
const [existingOlm] = await db
.select()
.from(olms)
.where(and(eq(olms.olmId, olmId), eq(olms.userId, userId)));
if (!existingOlm) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this olm"
)
);
}
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error checking if user has access to this user"
)
);
}
}

View File

@@ -8,8 +8,6 @@ import {
roleClients,
userClients,
olms,
clientSites,
exitNodes,
orgs,
sites
} from "@server/db";
@@ -20,45 +18,44 @@ import logger from "@server/logger";
import { eq, and } from "drizzle-orm";
import { fromError } from "zod-validation-error";
import moment from "moment";
import { hashPassword, verifyPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP } from "@server/lib/validators";
import { hashPassword } from "@server/auth/password";
import { isValidIP } from "@server/lib/validators";
import { isIpInCidr } from "@server/lib/ip";
import { OpenAPITags, registry } from "@server/openApi";
import { listExitNodes } from "#dynamic/lib/exitNodes";
import { generateId } from "@server/auth/sessions/app";
import { OpenAPITags, registry } from "@server/openApi";
const createClientParamsSchema = z
const paramsSchema = z
.object({
orgId: z.string()
})
.strict();
const createClientSchema = z
const bodySchema = z
.object({
name: z.string().min(1).max(255),
siteIds: z.array(z.number().int().positive()),
olmId: z.string(),
secret: z.string().optional(),
secret: z.string(),
subnet: z.string(),
type: z.enum(["olm"])
})
.strict();
export type CreateClientBody = z.infer<typeof createClientSchema>;
export type CreateClientBody = z.infer<typeof bodySchema>;
export type CreateClientResponse = Client;
registry.registerPath({
method: "put",
path: "/org/{orgId}/client",
description: "Create a new client.",
description: "Create a new client for an organization.",
tags: [OpenAPITags.Client, OpenAPITags.Org],
request: {
params: createClientParamsSchema,
params: paramsSchema,
body: {
content: {
"application/json": {
schema: createClientSchema
schema: bodySchema
}
}
}
@@ -72,7 +69,7 @@ export async function createClient(
next: NextFunction
): Promise<any> {
try {
const parsedBody = createClientSchema.safeParse(req.body);
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
@@ -82,9 +79,9 @@ export async function createClient(
);
}
const { name, type, siteIds, olmId, secret, subnet } = parsedBody.data;
const { name, type, olmId, secret, subnet } = parsedBody.data;
const parsedParams = createClientParamsSchema.safeParse(req.params);
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
@@ -184,19 +181,13 @@ export async function createClient(
.where(eq(olms.olmId, olmId))
.limit(1);
// TODO: HOW DO WE WANT TO AUTH THAT YOU CAN ADOPT AN EXISTING OLM CROSS ORG OTHER THAN MAKING SURE THE SECRET IS CORRECT
if (existingOlm && secret) {
// verify the secret
const validSecret = await verifyPassword(
secret,
existingOlm.secretHash
if (existingOlm) {
return next(
createHttpError(
HttpCode.CONFLICT,
`OLM with ID ${olmId} already exists`
)
);
if (!validSecret) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect on existing olm")
);
}
}
await db.transaction(async (trx) => {
@@ -237,21 +228,11 @@ export async function createClient(
if (req.user && req.userOrgRoleId != adminRole.roleId) {
// make sure the user can access the client
trx.insert(userClients).values({
userId: req.user?.userId!,
userId: req.user.userId,
clientId: newClient.clientId
});
}
// Create site to client associations
if (siteIds && siteIds.length > 0) {
await trx.insert(clientSites).values(
siteIds.map((siteId) => ({
clientId: newClient.clientId,
siteId
}))
);
}
let secretToUse = secret;
if (!secretToUse) {
secretToUse = generateId(48);

View File

@@ -0,0 +1,240 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import {
roles,
Client,
clients,
roleClients,
userClients,
olms,
orgs,
sites
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { eq, and } from "drizzle-orm";
import { fromError } from "zod-validation-error";
import { isValidIP } from "@server/lib/validators";
import { isIpInCidr } from "@server/lib/ip";
import { listExitNodes } from "#dynamic/lib/exitNodes";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z
.object({
orgId: z.string(),
userId: z.string()
})
.strict();
const bodySchema = z
.object({
name: z.string().min(1).max(255),
olmId: z.string(),
subnet: z.string(),
type: z.enum(["olm"])
})
.strict();
export type CreateClientAndOlmBody = z.infer<typeof bodySchema>;
export type CreateClientAndOlmResponse = Client;
registry.registerPath({
method: "put",
path: "/org/{orgId}/user/{userId}/client",
description:
"Create a new client for a user and associate it with an existing olm.",
tags: [OpenAPITags.Client, OpenAPITags.Org, OpenAPITags.User],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function createUserClient(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { name, type, olmId, subnet } = parsedBody.data;
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, userId } = parsedParams.data;
if (!isValidIP(subnet)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid subnet format. Please provide a valid CIDR notation."
)
);
}
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
if (!org) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
)
);
}
if (!org.subnet) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Organization with ID ${orgId} has no subnet defined`
)
);
}
if (!isIpInCidr(subnet, org.subnet)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"IP is not in the CIDR range of the subnet."
)
);
}
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org
// make sure the subnet is unique
const subnetExistsClients = await db
.select()
.from(clients)
.where(
and(eq(clients.subnet, updatedSubnet), eq(clients.orgId, orgId))
)
.limit(1);
if (subnetExistsClients.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Subnet ${updatedSubnet} already exists in clients`
)
);
}
const subnetExistsSites = await db
.select()
.from(sites)
.where(
and(eq(sites.address, updatedSubnet), eq(sites.orgId, orgId))
)
.limit(1);
if (subnetExistsSites.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Subnet ${updatedSubnet} already exists in sites`
)
);
}
// check if the olmId already exists
const [existingOlm] = await db
.select()
.from(olms)
.where(eq(olms.olmId, olmId))
.limit(1);
if (!existingOlm) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`OLM with ID ${olmId} does not exist`
)
);
}
await db.transaction(async (trx) => {
// TODO: more intelligent way to pick the exit node
const exitNodesList = await listExitNodes(orgId);
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
const [adminRole] = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (!adminRole) {
return next(
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
);
}
const [newClient] = await trx
.insert(clients)
.values({
exitNodeId: randomExitNode.exitNodeId,
orgId,
name,
subnet: updatedSubnet,
type,
olmId, // this is to lock it to a specific olm even if the olm moves across clients
userId
})
.returning();
await trx.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: newClient.clientId
});
trx.insert(userClients).values({
userId,
clientId: newClient.clientId
});
return response<CreateClientAndOlmResponse>(res, {
data: newClient,
success: true,
error: false,
message: "Site created successfully",
status: HttpCode.CREATED
});
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -67,9 +67,14 @@ export async function deleteClient(
.where(eq(clientSites.clientId, clientId));
// Then delete the client itself
await trx
.delete(clients)
.where(eq(clients.clientId, clientId));
await trx.delete(clients).where(eq(clients.clientId, clientId));
// this is a machine client
if (!client.userId && client.olmId) {
await trx
.delete(clients)
.where(eq(clients.olmId, client.olmId));
}
});
return response(res, {

View File

@@ -3,4 +3,5 @@ export * from "./createClient";
export * from "./deleteClient";
export * from "./listClients";
export * from "./updateClient";
export * from "./getClient";
export * from "./getClient";
export * from "./createUserClient";

View File

@@ -39,7 +39,8 @@ import {
verifyClientsEnabled,
verifyUserHasAction,
verifyUserIsOrgOwner,
verifySiteResourceAccess
verifySiteResourceAccess,
verifyOlmAccess
} from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions";
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
@@ -160,6 +161,7 @@ authenticated.put(
client.createClient,
);
// TODO: Separate into a deleteUserClient (for user clients) and deleteClient (for machine clients)
authenticated.delete(
"/client/:clientId",
verifyClientsEnabled,
@@ -758,22 +760,23 @@ authenticated.delete(
// createNewt
// );
// only for logged in user
authenticated.put(
"/olm",
olm.createOlm
"/user/:userId/olm",
verifyIsLoggedInUser,
olm.createUserOlm
);
// only for logged in user
authenticated.get(
"/olms",
olm.listOlms
"/user/:userId/olms",
verifyIsLoggedInUser,
olm.listUserOlms
);
// only for logged in user
authenticated.delete(
"/olm/:olmId",
olm.deleteOlm
"/user/:userId/olm/:olmId",
verifyIsLoggedInUser,
verifyOlmAccess,
olm.deleteUserOlm
);
authenticated.put(

View File

@@ -11,7 +11,6 @@ import * as accessToken from "./accessToken";
import * as apiKeys from "./apiKeys";
import * as idp from "./idp";
import * as siteResource from "./siteResource";
import * as olm from "./olm";
import {
verifyApiKey,
verifyApiKeyOrgAccess,
@@ -589,13 +588,6 @@ authenticated.delete(
// newt.createNewt
// );
authenticated.put(
"/user/:userId/olm",
verifyApiKeyUserAccess,
verifyApiKeyHasAction(ActionsEnum.createOlm),
olm.createOlm
);
authenticated.get(
`/org/:orgId/api-keys`,
verifyApiKeyIsRoot,
@@ -728,6 +720,16 @@ authenticated.put(
client.createClient
);
authenticated.put(
"/org/:orgId/user/:userId/client",
verifyClientsEnabled,
verifyApiKeyOrgAccess,
verifyApiKeyUserAccess,
verifyApiKeyHasAction(ActionsEnum.createClient),
logActionAudit(ActionsEnum.createClient),
client.createUserClient
);
authenticated.delete(
"/client/:clientId",
verifyClientsEnabled,

View File

@@ -1,46 +1,58 @@
import { NextFunction, Request, Response } from "express";
import { db } from "@server/db";
import { hash } from "@node-rs/argon2";
import HttpCode from "@server/types/HttpCode";
import { z } from "zod";
import { olms } from "@server/db";
import createHttpError from "http-errors";
import response from "@server/lib/response";
import { SqliteError } from "better-sqlite3";
import moment from "moment";
import { generateId, generateSessionToken } from "@server/auth/sessions/app";
import { createOlmSession } from "@server/auth/sessions/olm";
import { generateId } from "@server/auth/sessions/app";
import { fromError } from "zod-validation-error";
import { hashPassword } from "@server/auth/password";
import { OpenAPITags, registry } from "@server/openApi";
export const createOlmBodySchema = z.object({});
export type CreateOlmBody = z.infer<typeof createOlmBodySchema>;
export type CreateOlmResponse = {
// token: string;
olmId: string;
secret: string;
};
const createOlmSchema = z
const bodySchema = z
.object({
name: z.string().min(1).max(255)
})
.strict();
const createOlmParamsSchema = z
.object({
userId: z.string().optional()
});
const paramsSchema = z.object({
userId: z.string()
});
export async function createOlm(
export type CreateOlmBody = z.infer<typeof bodySchema>;
export type CreateOlmResponse = {
olmId: string;
secret: string;
};
registry.registerPath({
method: "put",
path: "/user/{userId}/olm",
description: "Create a new olm for a user.",
tags: [OpenAPITags.User, OpenAPITags.Client],
request: {
body: {
content: {
"application/json": {
schema: bodySchema
}
}
},
params: paramsSchema
},
responses: {}
});
export async function createUserOlm(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = createOlmSchema.safeParse(req.body);
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
@@ -52,7 +64,7 @@ export async function createOlm(
const { name } = parsedBody.data;
const parsedParams = createOlmParamsSchema.safeParse(req.params);
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
@@ -63,20 +75,6 @@ export async function createOlm(
}
const { userId } = parsedParams.data;
let userIdFinal = userId;
if (req.user) { // overwrite the user with the one calling because we want to assign the olm to the user creating it
userIdFinal = req.user.userId;
}
if (!userIdFinal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Either userId must be provided or request must be authenticated"
)
);
}
const olmId = generateId(15);
const secret = generateId(48);
@@ -85,20 +83,16 @@ export async function createOlm(
await db.insert(olms).values({
olmId: olmId,
userId: userIdFinal,
userId,
name,
secretHash,
dateCreated: moment().toISOString()
});
// const token = generateSessionToken();
// await createOlmSession(token, olmId);
return response<CreateOlmResponse>(res, {
data: {
olmId,
secret
// token,
},
success: true,
error: false,

View File

@@ -8,28 +8,33 @@ import response from "@server/lib/response";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
const deleteOlmParamsSchema = z
const paramsSchema = z
.object({
userId: z.string(),
olmId: z.string()
})
.strict();
export async function deleteOlm(
registry.registerPath({
method: "delete",
path: "/user/{userId}/olm/{olmId}",
description: "Delete an olm for a user.",
tags: [OpenAPITags.User, OpenAPITags.Client],
request: {
params: paramsSchema
},
responses: {}
});
export async function deleteUserOlm(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const userId = req.user?.userId;
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
const parsedParams = deleteOlmParamsSchema.safeParse(req.params);
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
@@ -41,31 +46,6 @@ export async function deleteOlm(
const { olmId } = parsedParams.data;
// Verify the OLM belongs to the current user
const [existingOlm] = await db
.select()
.from(olms)
.where(eq(olms.olmId, olmId))
.limit(1);
if (!existingOlm) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Olm with ID ${olmId} not found`
)
);
}
if (existingOlm.userId !== userId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"You do not have permission to delete this device"
)
);
}
// Delete associated clients and the OLM in a transaction
await db.transaction(async (trx) => {
// Find all clients associated with this OLM
@@ -83,9 +63,7 @@ export async function deleteOlm(
// Delete all associated clients
if (associatedClients.length > 0) {
await trx
.delete(clients)
.where(eq(clients.olmId, olmId));
await trx.delete(clients).where(eq(clients.olmId, olmId));
}
// Finally, delete the OLM itself
@@ -109,4 +87,3 @@ export async function deleteOlm(
);
}
}

View File

@@ -1,7 +1,8 @@
export * from "./handleOlmRegisterMessage";
export * from "./getOlmToken";
export * from "./createOlm";
export * from "./createUserOlm";
export * from "./handleOlmRelayMessage";
export * from "./handleOlmPingMessage";
export * from "./listOlms";
export * from "./deleteOlm";
export * from "./deleteUserOlm";
export * from "./listUserOlms";
export * from "./deleteUserOlm";

View File

@@ -8,8 +8,9 @@ import response from "@server/lib/response";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
const listOlmsSchema = z.object({
const querySchema = z.object({
limit: z
.string()
.optional()
@@ -24,7 +25,25 @@ const listOlmsSchema = z.object({
.pipe(z.number().int().nonnegative())
});
export type ListOlmsResponse = {
const paramsSchema = z
.object({
userId: z.string()
})
.strict();
registry.registerPath({
method: "delete",
path: "/user/{userId}/olms",
description: "List all olms for a user.",
tags: [OpenAPITags.User, OpenAPITags.Client],
request: {
query: querySchema,
params: paramsSchema
},
responses: {}
});
export type ListUserOlmsResponse = {
olms: Array<{
olmId: string;
dateCreated: string;
@@ -40,21 +59,13 @@ export type ListOlmsResponse = {
};
};
export async function listOlms(
export async function listUserOlms(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const userId = req.user?.userId;
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
const parsedQuery = listOlmsSchema.safeParse(req.query);
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
@@ -66,6 +77,18 @@ export async function listOlms(
const { limit, offset } = parsedQuery.data;
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { userId } = parsedParams.data;
// Get total count
const [totalCountResult] = await db
.select({ count: count() })
@@ -90,7 +113,7 @@ export async function listOlms(
.limit(limit)
.offset(offset);
return response<ListOlmsResponse>(res, {
return response<ListUserOlmsResponse>(res, {
data: {
olms: userOlms,
pagination: {
@@ -101,7 +124,7 @@ export async function listOlms(
},
success: true,
error: false,
message: "OLMs retrieved successfully",
message: "Olms retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
@@ -114,4 +137,3 @@ export async function listOlms(
);
}
}