diff --git a/messages/en-US.json b/messages/en-US.json index d714dbc2..c74719f3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -671,7 +671,7 @@ "resourcePincodeSetupTitle": "Set Pincode", "resourcePincodeSetupTitleDescription": "Set a pincode to protect this resource", "resourceRoleDescription": "Admins can always access this resource.", - "resourceUsersRoles": "Users & Roles", + "resourceUsersRoles": "Access Controls", "resourceUsersRolesDescription": "Configure which users and roles can visit this resource", "resourceUsersRolesSubmit": "Save Users & Roles", "resourceWhitelistSave": "Saved successfully", @@ -2153,5 +2153,8 @@ "selectedResources": "Selected Resources", "enableSelected": "Enable Selected", "disableSelected": "Disable Selected", - "checkSelectedStatus": "Check Status of Selected" + "checkSelectedStatus": "Check Status of Selected", + "clients": "Clients", + "accessClientSelect": "Select machine clients", + "resourceClientDescription": "Machine clients that can access this resource" } diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index a0333540..d818f86b 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -213,6 +213,15 @@ export const siteResources = pgTable("siteResources", { alias: varchar("alias") }); +export const clientSiteResources = pgTable("clientSiteResources", { + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) +}); + export const roleSiteResources = pgTable("roleSiteResources", { roleId: integer("roleId") .notNull() diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 59ad76db..1c8a99ee 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -228,12 +228,21 @@ export const siteResources = sqliteTable("siteResources", { mode: text("mode").notNull(), // "host" | "cidr" | "port" protocol: text("protocol"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode - destinationPort: integer("destinationPort"), // only for port mode + destinationPort: integer("destinationPort"), // only for port mode destination: text("destination").notNull(), // ip, cidr, hostname enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), alias: text("alias") }); +export const clientSiteResources = sqliteTable("clientSiteResources", { + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) +}); + export const roleSiteResources = sqliteTable("roleSiteResources", { roleId: integer("roleId") .notNull() @@ -350,7 +359,7 @@ export const clients = sqliteTable("clients", { type: text("type").notNull(), // "olm" online: integer("online", { mode: "boolean" }).notNull().default(false), // endpoint: text("endpoint"), - lastHolePunch: integer("lastHolePunch") + lastHolePunch: integer("lastHolePunch"), }); export const clientSites = sqliteTable("clientSites", { diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index 4f8755d7..32ef1861 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -11,6 +11,7 @@ export * from "./verifyRoleAccess"; export * from "./verifyUserAccess"; export * from "./verifyAdmin"; export * from "./verifySetResourceUsers"; +export * from "./verifySetResourceClients"; export * from "./verifyUserInRole"; export * from "./verifyAccessTokenAccess"; export * from "./requestTimeout"; diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index 747cddee..d44eb5a3 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -7,6 +7,7 @@ export * from "./verifyApiKeyTargetAccess"; export * from "./verifyApiKeyRoleAccess"; export * from "./verifyApiKeyUserAccess"; export * from "./verifyApiKeySetResourceUsers"; +export * from "./verifyApiKeySetResourceClients"; export * from "./verifyAccessTokenAccess"; export * from "./verifyApiKeyIsRoot"; export * from "./verifyApiKeyApiKeyAccess"; diff --git a/server/middlewares/integration/verifyApiKeySetResourceClients.ts b/server/middlewares/integration/verifyApiKeySetResourceClients.ts new file mode 100644 index 00000000..cbcb33ae --- /dev/null +++ b/server/middlewares/integration/verifyApiKeySetResourceClients.ts @@ -0,0 +1,73 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { clients } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeySetResourceClients( + req: Request, + res: Response, + next: NextFunction +) { + const apiKey = req.apiKey; + const singleClientId = req.params.clientId || req.body.clientId || req.query.clientId; + const { clientIds } = req.body; + const allClientIds = clientIds || (singleClientId ? [parseInt(singleClientId as string)] : []); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (apiKey.isRoot) { + // Root keys can access any client in any org + return next(); + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + if (allClientIds.length === 0) { + return next(); + } + + try { + const orgId = req.apiKeyOrg.orgId; + const clientsData = await db + .select() + .from(clients) + .where( + and( + inArray(clients.clientId, allClientIds), + eq(clients.orgId, orgId) + ) + ); + + if (clientsData.length !== allClientIds.length) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to one or more specified clients" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error checking if key has access to the specified clients" + ) + ); + } +} + diff --git a/server/middlewares/verifySetResourceClients.ts b/server/middlewares/verifySetResourceClients.ts new file mode 100644 index 00000000..d078391d --- /dev/null +++ b/server/middlewares/verifySetResourceClients.ts @@ -0,0 +1,69 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { clients } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifySetResourceClients( + req: Request, + res: Response, + next: NextFunction +) { + const userId = req.user!.userId; + const singleClientId = req.params.clientId || req.body.clientId || req.query.clientId; + const { clientIds } = req.body; + const allClientIds = clientIds || (singleClientId ? [parseInt(singleClientId as string)] : []); + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + if (allClientIds.length === 0) { + return next(); + } + + try { + const orgId = req.userOrg.orgId; + // get all clients for the clientIds + const clientsData = await db + .select() + .from(clients) + .where( + and( + inArray(clients.clientId, allClientIds), + eq(clients.orgId, orgId) + ) + ); + + if (clientsData.length !== allClientIds.length) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to one or more specified clients" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error checking if user has access to the specified clients" + ) + ); + } +} + diff --git a/server/routers/client/createUserClient.ts b/server/routers/client/createUserClient.ts index c6f535eb..f49a0783 100644 --- a/server/routers/client/createUserClient.ts +++ b/server/routers/client/createUserClient.ts @@ -182,6 +182,15 @@ export async function createUserClient( ); } + if (existingOlm.userId !== userId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `OLM with ID ${olmId} does not belong to user with ID ${userId}` + ) + ); + } + await db.transaction(async (trx) => { // TODO: more intelligent way to pick the exit node const exitNodesList = await listExitNodes(orgId); diff --git a/server/routers/client/deleteClient.ts b/server/routers/client/deleteClient.ts index e4752175..74d2a4fc 100644 --- a/server/routers/client/deleteClient.ts +++ b/server/routers/client/deleteClient.ts @@ -60,6 +60,15 @@ export async function deleteClient( ); } + if (client.userId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Cannot delete a user client with this endpoint` + ) + ); + } + await db.transaction(async (trx) => { // Delete the client-site associations first await trx diff --git a/server/routers/external.ts b/server/routers/external.ts index 6b84c3ab..fff37aba 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -29,6 +29,7 @@ import { verifyTargetAccess, verifyRoleAccess, verifySetResourceUsers, + verifySetResourceClients, verifyUserAccess, getUserOrgs, verifyUserIsServerAdmin, @@ -301,6 +302,13 @@ authenticated.get( siteResource.listSiteResourceUsers ); +authenticated.get( + "/site-resource/:siteResourceId/clients", + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.listResourceUsers), + siteResource.listSiteResourceClients +); + authenticated.post( "/site-resource/:siteResourceId/roles", verifySiteResourceAccess, @@ -319,6 +327,33 @@ authenticated.post( siteResource.setSiteResourceUsers, ); +authenticated.post( + "/site-resource/:siteResourceId/clients", + verifySiteResourceAccess, + verifySetResourceClients, + verifyUserHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.setSiteResourceClients, +); + +authenticated.post( + "/site-resource/:siteResourceId/clients/add", + verifySiteResourceAccess, + verifySetResourceClients, + verifyUserHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.addClientToSiteResource, +); + +authenticated.post( + "/site-resource/:siteResourceId/clients/remove", + verifySiteResourceAccess, + verifySetResourceClients, + verifyUserHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.removeClientFromSiteResource, +); + authenticated.put( "/org/:orgId/resource", verifyOrgAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index f2de363a..d09a28c6 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -25,7 +25,8 @@ import { verifyApiKeyIsRoot, verifyApiKeyClientAccess, verifyClientsEnabled, - verifyApiKeySiteResourceAccess + verifyApiKeySiteResourceAccess, + verifyApiKeySetResourceClients } from "@server/middlewares"; import HttpCode from "@server/types/HttpCode"; import { Router } from "express"; @@ -211,6 +212,13 @@ authenticated.get( siteResource.listSiteResourceUsers ); +authenticated.get( + "/site-resource/:siteResourceId/clients", + verifyApiKeySiteResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceUsers), + siteResource.listSiteResourceClients +); + authenticated.post( "/site-resource/:siteResourceId/roles", verifyApiKeySiteResourceAccess, @@ -265,6 +273,33 @@ authenticated.post( siteResource.removeUserFromSiteResource ); +authenticated.post( + "/site-resource/:siteResourceId/clients", + verifyApiKeySiteResourceAccess, + verifyApiKeySetResourceClients, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.setSiteResourceClients +); + +authenticated.post( + "/site-resource/:siteResourceId/clients/add", + verifyApiKeySiteResourceAccess, + verifyApiKeySetResourceClients, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.addClientToSiteResource +); + +authenticated.post( + "/site-resource/:siteResourceId/clients/remove", + verifyApiKeySiteResourceAccess, + verifyApiKeySetResourceClients, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.removeClientFromSiteResource +); + authenticated.put( "/org/:orgId/resource", verifyApiKeyOrgAccess, diff --git a/server/routers/siteResource/addClientToSiteResource.ts b/server/routers/siteResource/addClientToSiteResource.ts new file mode 100644 index 00000000..2938ec78 --- /dev/null +++ b/server/routers/siteResource/addClientToSiteResource.ts @@ -0,0 +1,156 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources, clients, clientSiteResources } 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 { fromError } from "zod-validation-error"; +import { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildSiteClientAssociations } from "@server/lib/rebuildSiteClientAssociations"; + +const addClientToSiteResourceBodySchema = z + .object({ + clientId: z.number().int().positive() + }) + .strict(); + +const addClientToSiteResourceParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/clients/add", + description: "Add a single client to a site resource. Clients with a userId cannot be added.", + tags: [OpenAPITags.Resource, OpenAPITags.Client], + request: { + params: addClientToSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addClientToSiteResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function addClientToSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = addClientToSiteResourceBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { clientId } = parsedBody.data; + + const parsedParams = addClientToSiteResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + // get the site resource + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + // Check if client exists and has a userId + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Client not found") + ); + } + + if (client.userId !== null) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot add clients that are associated with a user" + ) + ); + } + + // Check if client already exists in site resource + const existingEntry = await db + .select() + .from(clientSiteResources) + .where( + and( + eq(clientSiteResources.siteResourceId, siteResourceId), + eq(clientSiteResources.clientId, clientId) + ) + ); + + if (existingEntry.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Client already assigned to site resource" + ) + ); + } + + await db.transaction(async (trx) => { + await trx.insert(clientSiteResources).values({ + clientId, + siteResourceId + }); + + await rebuildSiteClientAssociations(siteResource, trx); + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Client added to site resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/siteResource/index.ts b/server/routers/siteResource/index.ts index 5d9eeb09..9494843b 100644 --- a/server/routers/siteResource/index.ts +++ b/server/routers/siteResource/index.ts @@ -6,9 +6,13 @@ export * from "./listSiteResources"; export * from "./listAllSiteResourcesByOrg"; export * from "./listSiteResourceRoles"; export * from "./listSiteResourceUsers"; +export * from "./listSiteResourceClients"; export * from "./setSiteResourceRoles"; export * from "./setSiteResourceUsers"; export * from "./addRoleToSiteResource"; export * from "./removeRoleFromSiteResource"; export * from "./addUserToSiteResource"; export * from "./removeUserFromSiteResource"; +export * from "./setSiteResourceClients"; +export * from "./addClientToSiteResource"; +export * from "./removeClientFromSiteResource"; diff --git a/server/routers/siteResource/listSiteResourceClients.ts b/server/routers/siteResource/listSiteResourceClients.ts new file mode 100644 index 00000000..9b04ac32 --- /dev/null +++ b/server/routers/siteResource/listSiteResourceClients.ts @@ -0,0 +1,85 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clientSiteResources, clients } 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 listSiteResourceClientsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +async function queryClients(siteResourceId: number) { + return await db + .select({ + clientId: clientSiteResources.clientId, + name: clients.name, + subnet: clients.subnet + }) + .from(clientSiteResources) + .innerJoin(clients, eq(clientSiteResources.clientId, clients.clientId)) + .where(eq(clientSiteResources.siteResourceId, siteResourceId)); +} + +export type ListSiteResourceClientsResponse = { + clients: NonNullable>>; +}; + +registry.registerPath({ + method: "get", + path: "/site-resource/{siteResourceId}/clients", + description: "List all clients for a site resource.", + tags: [OpenAPITags.Resource, OpenAPITags.Client], + request: { + params: listSiteResourceClientsSchema + }, + responses: {} +}); + +export async function listSiteResourceClients( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = listSiteResourceClientsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + const siteResourceClientsList = await queryClients(siteResourceId); + + return response(res, { + data: { + clients: siteResourceClientsList + }, + success: true, + error: false, + message: "Site resource clients retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/siteResource/removeClientFromSiteResource.ts b/server/routers/siteResource/removeClientFromSiteResource.ts new file mode 100644 index 00000000..c7eae230 --- /dev/null +++ b/server/routers/siteResource/removeClientFromSiteResource.ts @@ -0,0 +1,162 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources, clients, clientSiteResources } 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 { fromError } from "zod-validation-error"; +import { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildSiteClientAssociations } from "@server/lib/rebuildSiteClientAssociations"; + +const removeClientFromSiteResourceBodySchema = z + .object({ + clientId: z.number().int().positive() + }) + .strict(); + +const removeClientFromSiteResourceParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/clients/remove", + description: "Remove a single client from a site resource. Clients with a userId cannot be removed.", + tags: [OpenAPITags.Resource, OpenAPITags.Client], + request: { + params: removeClientFromSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeClientFromSiteResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function removeClientFromSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = removeClientFromSiteResourceBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { clientId } = parsedBody.data; + + const parsedParams = removeClientFromSiteResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + // get the site resource + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + // Check if client exists and has a userId + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Client not found") + ); + } + + if (client.userId !== null) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot remove clients that are associated with a user" + ) + ); + } + + // Check if client exists in site resource + const existingEntry = await db + .select() + .from(clientSiteResources) + .where( + and( + eq(clientSiteResources.siteResourceId, siteResourceId), + eq(clientSiteResources.clientId, clientId) + ) + ); + + if (existingEntry.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Client not found in site resource" + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(clientSiteResources) + .where( + and( + eq(clientSiteResources.siteResourceId, siteResourceId), + eq(clientSiteResources.clientId, clientId) + ) + ); + + await rebuildSiteClientAssociations(siteResource, trx); + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Client removed from site resource successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/siteResource/setSiteResourceClients.ts b/server/routers/siteResource/setSiteResourceClients.ts new file mode 100644 index 00000000..aa44d658 --- /dev/null +++ b/server/routers/siteResource/setSiteResourceClients.ts @@ -0,0 +1,149 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources, clients, clientSiteResources } 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 { fromError } from "zod-validation-error"; +import { eq, inArray } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildSiteClientAssociations } from "@server/lib/rebuildSiteClientAssociations"; + +const setSiteResourceClientsBodySchema = z + .object({ + clientIds: z.array(z.number().int().positive()) + }) + .strict(); + +const setSiteResourceClientsParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/clients", + description: + "Set clients for a site resource. This will replace all existing clients. Clients with a userId cannot be added.", + tags: [OpenAPITags.Resource, OpenAPITags.Client], + request: { + params: setSiteResourceClientsParamsSchema, + body: { + content: { + "application/json": { + schema: setSiteResourceClientsBodySchema + } + } + } + }, + responses: {} +}); + +export async function setSiteResourceClients( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setSiteResourceClientsBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { clientIds } = parsedBody.data; + + const parsedParams = setSiteResourceClientsParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + // get the site resource + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Site resource not found" + ) + ); + } + + // Check if any clients have a userId (associated with a user) + if (clientIds.length > 0) { + const clientsWithUsers = await db + .select() + .from(clients) + .where( + inArray(clients.clientId, clientIds) + ); + + const clientsWithUserId = clientsWithUsers.filter( + (client) => client.userId !== null + ); + + if (clientsWithUserId.length > 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot add clients that are associated with a user" + ) + ); + } + } + + await db.transaction(async (trx) => { + await trx + .delete(clientSiteResources) + .where(eq(clientSiteResources.siteResourceId, siteResourceId)); + + if (clientIds.length > 0) { + await Promise.all( + clientIds.map((clientId) => + trx + .insert(clientSiteResources) + .values({ clientId, siteResourceId }) + .returning() + ) + ); + } + + await rebuildSiteClientAssociations(siteResource, trx); + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Clients set for site resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/src/components/ClientsTable.tsx b/src/components/ClientsTable.tsx index c096f108..426c4549 100644 --- a/src/components/ClientsTable.tsx +++ b/src/components/ClientsTable.tsx @@ -36,7 +36,7 @@ import { } from "lucide-react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { toast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api"; @@ -214,12 +214,20 @@ export default function ClientsTable({ userId: false }; - const [userColumnVisibility, setUserColumnVisibility] = useState( - () => getStoredColumnVisibility("user-clients", defaultUserColumnVisibility) - ); - const [machineColumnVisibility, setMachineColumnVisibility] = useState( - () => getStoredColumnVisibility("machine-clients", defaultMachineColumnVisibility) - ); + const [userColumnVisibility, setUserColumnVisibility] = + useState(() => + getStoredColumnVisibility( + "user-clients", + defaultUserColumnVisibility + ) + ); + const [machineColumnVisibility, setMachineColumnVisibility] = + useState(() => + getStoredColumnVisibility( + "machine-clients", + defaultMachineColumnVisibility + ) + ); const currentView = searchParams.get("view") || defaultView; @@ -276,9 +284,7 @@ export default function ClientsTable({ placeholder={t("resourcesSearch")} value={machineGlobalFilter ?? ""} onChange={(e) => - machineTable.setGlobalFilter( - String(e.target.value) - ) + machineTable.setGlobalFilter(String(e.target.value)) } className="w-full pl-8" /> @@ -318,8 +324,14 @@ export default function ClientsTable({ return null; }; + // Check if there are any rows without userIds in the current view's data + const hasRowsWithoutUserId = useMemo(() => { + const currentData = currentView === "machine" ? machineClients : userClients; + return currentData?.some((client) => !client.userId) ?? false; + }, [currentView, machineClients, userClients]); - const columns: ColumnDef[] = [ + const columns: ColumnDef[] = useMemo(() => { + const baseColumns: ColumnDef[] = [ { accessorKey: "name", header: ({ column }) => { @@ -513,52 +525,59 @@ export default function ClientsTable({ ); } }, - { - id: "actions", - header: () => ({t("actions")}), - cell: ({ row }) => { - const clientRow = row.original; - return ( -
- - - - - - - - - {/* */} - {/* */} - {/* View settings */} - {/* */} - {/* */} - { - setSelectedClient(clientRow); - setIsDeleteModalOpen(true); - }} - > - Delete - - - -
- ); - } + + + + + + + {/* */} + {/* */} + {/* View settings */} + {/* */} + {/* */} + { + setSelectedClient(clientRow); + setIsDeleteModalOpen(true); + }} + > + Delete + + + + + ) : null; + } + }); } - ]; + + return baseColumns; + }, [hasRowsWithoutUserId, t]); const userTable = useReactTable({ data: userClients || [], @@ -674,80 +693,122 @@ export default function ClientsTable({
- {currentView === "user" && userTable.getAllColumns().some((column) => column.getCanHide()) && ( - - - - - - - {t("toggleColumns") || "Toggle columns"} - - - {userTable - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - return ( - - column.toggleVisibility(!!value) - } - > - {typeof column.columnDef.header === "string" - ? column.columnDef.header - : column.id} - - ); - })} - - - )} - {currentView === "machine" && machineTable.getAllColumns().some((column) => column.getCanHide()) && ( - - - - - - - {t("toggleColumns") || "Toggle columns"} - - - {machineTable - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - return ( - - column.toggleVisibility(!!value) - } - > - {typeof column.columnDef.header === "string" - ? column.columnDef.header - : column.id} - - ); - })} - - - )} + {currentView === "user" && + userTable + .getAllColumns() + .some((column) => + column.getCanHide() + ) && ( + + + + + + + {t("toggleColumns") || + "Toggle columns"} + + + {userTable + .getAllColumns() + .filter((column) => + column.getCanHide() + ) + .map((column) => { + return ( + + column.toggleVisibility( + !!value + ) + } + > + {typeof column + .columnDef + .header === + "string" + ? column + .columnDef + .header + : column.id} + + ); + })} + + + )} + {currentView === "machine" && + machineTable + .getAllColumns() + .some((column) => + column.getCanHide() + ) && ( + + + + + + + {t("toggleColumns") || + "Toggle columns"} + + + {machineTable + .getAllColumns() + .filter((column) => + column.getCanHide() + ) + .map((column) => { + return ( + + column.toggleVisibility( + !!value + ) + } + > + {typeof column + .columnDef + .header === + "string" + ? column + .columnDef + .header + : column.id} + + ); + })} + + + )}
diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 8ebc0661..679cbb9e 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -41,6 +41,8 @@ import { ListRolesResponse } from "@server/routers/role"; import { ListUsersResponse } from "@server/routers/user"; import { ListSiteResourceRolesResponse } from "@server/routers/siteResource/listSiteResourceRoles"; import { ListSiteResourceUsersResponse } from "@server/routers/siteResource/listSiteResourceUsers"; +import { ListSiteResourceClientsResponse } from "@server/routers/siteResource/listSiteResourceClients"; +import { ListClientsResponse } from "@server/routers/client/listClients"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { AxiosResponse } from "axios"; import { UserType } from "@server/types/UserTypes"; @@ -97,6 +99,12 @@ export default function EditInternalResourceDialog({ id: z.string(), text: z.string() }) + ).optional(), + clients: z.array( + z.object({ + id: z.string(), + text: z.string() + }) ).optional() }) .refine( @@ -140,9 +148,12 @@ export default function EditInternalResourceDialog({ const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>([]); const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>([]); + const [allClients, setAllClients] = useState<{ id: string; text: string }[]>([]); const [activeRolesTagIndex, setActiveRolesTagIndex] = useState(null); const [activeUsersTagIndex, setActiveUsersTagIndex] = useState(null); + const [activeClientsTagIndex, setActiveClientsTagIndex] = useState(null); const [loadingRolesUsers, setLoadingRolesUsers] = useState(false); + const [hasMachineClients, setHasMachineClients] = useState(false); const form = useForm({ resolver: zodResolver(formSchema), @@ -155,7 +166,8 @@ export default function EditInternalResourceDialog({ destinationPort: resource.destinationPort ?? undefined, alias: resource.alias ?? null, roles: [], - users: [] + users: [], + clients: [] } }); @@ -168,7 +180,8 @@ export default function EditInternalResourceDialog({ rolesResponse, resourceRolesResponse, usersResponse, - resourceUsersResponse + resourceUsersResponse, + clientsResponse ] = await Promise.all([ api.get>(`/org/${orgId}/roles`), api.get>( @@ -177,9 +190,29 @@ export default function EditInternalResourceDialog({ api.get>(`/org/${orgId}/users`), api.get>( `/site-resource/${resource.id}/users` - ) + ), + api.get>(`/org/${orgId}/clients?filter=machine&limit=1000`) ]); + let resourceClientsResponse: AxiosResponse>; + try { + resourceClientsResponse = await api.get>( + `/site-resource/${resource.id}/clients` + ); + } catch { + resourceClientsResponse = { + data: { + data: { + clients: [] + } + }, + status: 200, + statusText: "OK", + headers: {} as any, + config: {} as any + } as any; + } + setAllRoles( rolesResponse.data.data.roles .map((role) => ({ @@ -213,8 +246,27 @@ export default function EditInternalResourceDialog({ text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` })) ); + + const machineClients = clientsResponse.data.data.clients + .filter((client) => !client.userId) + .map((client) => ({ + id: client.clientId.toString(), + text: client.name + })); + + setAllClients(machineClients); + + const existingClients = resourceClientsResponse.data.data.clients.map((c: { clientId: number; name: string }) => ({ + id: c.clientId.toString(), + text: c.name + })); + + form.setValue("clients", existingClients); + + // Show clients tag input if there are machine clients OR existing client access + setHasMachineClients(machineClients.length > 0 || existingClients.length > 0); } catch (error) { - console.error("Error fetching roles and users:", error); + console.error("Error fetching roles, users, and clients:", error); } finally { setLoadingRolesUsers(false); } @@ -231,7 +283,8 @@ export default function EditInternalResourceDialog({ destinationPort: resource.destinationPort ?? undefined, alias: resource.alias ?? null, roles: [], - users: [] + users: [], + clients: [] }); fetchRolesAndUsers(); } @@ -252,13 +305,16 @@ export default function EditInternalResourceDialog({ alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : null }); - // Update roles and users + // Update roles, users, and clients await Promise.all([ api.post(`/site-resource/${resource.id}/roles`, { roleIds: (data.roles || []).map((r) => parseInt(r.id)) }), api.post(`/site-resource/${resource.id}/users`, { userIds: (data.users || []).map((u) => u.id) + }), + api.post(`/site-resource/${resource.id}/clients`, { + clientIds: (data.clients || []).map((c) => parseInt(c.id)) }) ]); @@ -530,6 +586,42 @@ export default function EditInternalResourceDialog({ )} /> + {hasMachineClients && ( + ( + + {t("clients")} + + { + form.setValue( + "clients", + newClients as [Tag, ...Tag[]] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allClients} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={true} + sortTags={true} + /> + + + + {t("resourceClientDescription") || "Machine clients that can access this resource"} + + + )} + /> + )} )}