diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 0fbb6d8f..fc561551 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -35,21 +35,20 @@ export enum ActionsEnum { listUsers = "listUsers", listSiteRoles = "listSiteRoles", listResourceRoles = "listResourceRoles", + setResourceUsers = "setResourceUsers", setResourceRoles = "setResourceRoles", - removeRoleResource = "removeRoleResource", - removeRoleSite = "removeRoleSite", + listResourceUsers = "listResourceUsers", + // removeRoleSite = "removeRoleSite", // addRoleAction = "addRoleAction", // removeRoleAction = "removeRoleAction", - listRoleSites = "listRoleSites", + // listRoleSites = "listRoleSites", listRoleResources = "listRoleResources", - listRoleActions = "listRoleActions", + // listRoleActions = "listRoleActions", addUserRole = "addUserRole", - addUserResource = "addUserResource", - addUserSite = "addUserSite", + // addUserSite = "addUserSite", // addUserAction = "addUserAction", // removeUserAction = "removeUserAction", - removeUserResource = "removeUserResource", - removeUserSite = "removeUserSite", + // removeUserSite = "removeUserSite", getOrgUser = "getOrgUser", } diff --git a/server/db/schema.ts b/server/db/schema.ts index 6b0c6e41..2796d035 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -11,7 +11,7 @@ export const sites = sqliteTable("sites", { siteId: integer("siteId").primaryKey({ autoIncrement: true }), orgId: text("orgId").references(() => orgs.orgId, { onDelete: "cascade", - }), + }).notNull(), niceId: text("niceId").notNull(), exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { onDelete: "set null", @@ -27,10 +27,10 @@ export const resources = sqliteTable("resources", { resourceId: integer("resourceId").primaryKey({ autoIncrement: true }), siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade", - }), + }).notNull(), orgId: text("orgId").references(() => orgs.orgId, { onDelete: "cascade", - }), + }).notNull(), name: text("name").notNull(), subdomain: text("subdomain").notNull(), ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), @@ -40,7 +40,7 @@ export const targets = sqliteTable("targets", { targetId: integer("targetId").primaryKey({ autoIncrement: true }), resourceId: integer("resourceId").references(() => resources.resourceId, { onDelete: "cascade", - }), + }).notNull(), ip: text("ip").notNull(), method: text("method").notNull(), port: integer("port").notNull(), @@ -144,7 +144,7 @@ export const roles = sqliteTable("roles", { roleId: integer("roleId").primaryKey({ autoIncrement: true }), orgId: text("orgId").references(() => orgs.orgId, { onDelete: "cascade", - }), + }).notNull(), isAdmin: integer("isAdmin", { mode: "boolean" }), name: text("name").notNull(), description: text("description"), @@ -214,7 +214,7 @@ export const limitsTable = sqliteTable("limits", { limitId: integer("limitId").primaryKey({ autoIncrement: true }), orgId: text("orgId").references(() => orgs.orgId, { onDelete: "cascade", - }), + }).notNull(), name: text("name").notNull(), value: integer("value").notNull(), description: text("description"), diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index b493ed56..b47ff648 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -18,4 +18,5 @@ export * from "./requestEmailVerificationCode"; export * from "./changePassword"; export * from "./requestPasswordReset"; export * from "./resetPassword"; -export * from "./verifyUserInRole"; \ No newline at end of file +export * from "./verifyUserInRole"; +export * from "./verifySetResourceUsers"; \ No newline at end of file diff --git a/server/routers/auth/verifyRoleAccess.ts b/server/routers/auth/verifyRoleAccess.ts index 7c1a9037..29e0cfb6 100644 --- a/server/routers/auth/verifyRoleAccess.ts +++ b/server/routers/auth/verifyRoleAccess.ts @@ -5,12 +5,6 @@ import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; -import { z } from "zod"; -import { fromError } from "zod-validation-error"; - -const verifyRoleAccessSchema = z.object({ - roleIds: z.array(z.number().int().positive()).optional(), -}); export async function verifyRoleAccess( req: Request, @@ -28,17 +22,7 @@ export async function verifyRoleAccess( ); } - const parsedBody = verifyRoleAccessSchema.safeParse(req.body); - if (!parsedBody.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() - ) - ); - } - - const { roleIds } = parsedBody.data; + const { roleIds } = req.body; const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]); if (allRoleIds.length === 0) { @@ -81,6 +65,33 @@ export async function verifyRoleAccess( ) ); } + + req.userOrgId = role.orgId; + } + + const orgId = req.userOrgId; + + if (!orgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Organization ID not found" + ) + ); + } + + if (!req.userOrg) { + // get the userORg + const userOrg = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId!)) + ) + .limit(1); + + req.userOrg = userOrg[0]; + req.userOrgRoleId = userOrg[0].roleId; } return next(); diff --git a/server/routers/auth/verifySetResourceUsers.ts b/server/routers/auth/verifySetResourceUsers.ts new file mode 100644 index 00000000..5ed0a4ee --- /dev/null +++ b/server/routers/auth/verifySetResourceUsers.ts @@ -0,0 +1,70 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { userOrgs } from "@server/db/schema"; +import { and, eq, inArray, or } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifySetResourceUsers( + req: Request, + res: Response, + next: NextFunction +) { + const userId = req.user!.userId; + const userIds = req.body.userIds; + + 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 user" + ) + ); + } + + if (!userIds) { + return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user IDs")); + } + + if (userIds.length === 0) { + return next(); + } + + try { + const orgId = req.userOrg.orgId; + // get all userOrgs for the users + const userOrgsData = await db + .select() + .from(userOrgs) + .where( + and( + inArray(userOrgs.userId, userIds), + eq(userOrgs.orgId, orgId) + ) + ); + + if (userOrgsData.length !== userIds.length) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this user" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error checking if user has access to this user" + ) + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 46d89645..b7d5dee6 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -21,6 +21,7 @@ import { verifyRoleAccess, verifyUserAccess, verifyUserInRole, + verifySetResourceUsers } from "./auth"; import { verifyUserHasAction } from "./auth/verifyUserHasAction"; import { ActionsEnum } from "@server/auth/actions"; @@ -143,6 +144,13 @@ authenticated.get( resource.listResourceRoles ); +authenticated.get( + "/resource/:resourceId/users", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.listResourceUsers), + resource.listResourceUsers +); + authenticated.get( "/resource/:resourceId", verifyResourceAccess, @@ -259,7 +267,15 @@ authenticated.post( verifyResourceAccess, verifyRoleAccess, verifyUserHasAction(ActionsEnum.setResourceRoles), - role.addRoleResource + resource.setResourceRoles +); + +authenticated.post( + "/resource/:resourceId/users", + verifyResourceAccess, + verifySetResourceUsers, + verifyUserHasAction(ActionsEnum.setResourceUsers), + resource.setResourceUsers ); // authenticated.get( @@ -323,20 +339,6 @@ authenticated.delete( // role.removeRoleSite // ); // authenticated.put( -// "/user/:userId/resource", -// verifyResourceAccess, -// verifyUserAccess, -// verifyUserHasAction(ActionsEnum.addRoleResource), -// role.addRoleResource -// ); -// authenticated.delete( -// "/user/:userId/resource", -// verifyResourceAccess, -// verifyUserAccess, -// verifyUserHasAction(ActionsEnum.removeRoleResource), -// role.removeRoleResource -// ); -// authenticated.put( // "/org/:orgId/user/:userId/action", // verifyOrgAccess, // verifyUserAccess, diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 46e4a9ff..a3ad0963 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -3,4 +3,7 @@ export * from "./createResource"; export * from "./deleteResource"; export * from "./updateResource"; export * from "./listResources"; -export * from "./listResourceRoles"; \ No newline at end of file +export * from "./listResourceRoles"; +export * from "./setResourceUsers"; +export * from "./setResourceRoles"; +export * from "./listResourceUsers" diff --git a/server/routers/resource/listResourceUsers.ts b/server/routers/resource/listResourceUsers.ts new file mode 100644 index 00000000..e2fc9a76 --- /dev/null +++ b/server/routers/resource/listResourceUsers.ts @@ -0,0 +1,66 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { userResources, users } from "@server/db/schema"; // Assuming these are the correct tables +import { eq } from "drizzle-orm"; +import response from "@server/utils/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const listResourceUsersSchema = z.object({ + resourceId: z.string().transform(Number).pipe(z.number().int().positive()), +}); + +async function queryUsers(resourceId: number) { + return await db + .select({ + userId: userResources.userId, + email: users.email, + }) + .from(userResources) + .innerJoin(users, eq(userResources.userId, users.userId)) + .where(eq(userResources.resourceId, resourceId)); +} + +export type ListResourceUsersResponse = { + users: NonNullable>>; +}; + +export async function listResourceUsers( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = listResourceUsersSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const resourceUsersList = await queryUsers(resourceId); + + return response(res, { + data: { + users: resourceUsersList, + }, + success: true, + error: false, + message: "Resource users 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/resource/setResourceRoles.ts b/server/routers/resource/setResourceRoles.ts index 7aa9edf1..818cb1fd 100644 --- a/server/routers/resource/setResourceRoles.ts +++ b/server/routers/resource/setResourceRoles.ts @@ -17,7 +17,7 @@ const setResourceRolesParamsSchema = z.object({ resourceId: z.string().transform(Number).pipe(z.number().int().positive()), }); -export async function addRoleResource( +export async function setResourceRoles( req: Request, res: Response, next: NextFunction diff --git a/server/routers/resource/setResourceUsers.ts b/server/routers/resource/setResourceUsers.ts new file mode 100644 index 00000000..795faba4 --- /dev/null +++ b/server/routers/resource/setResourceUsers.ts @@ -0,0 +1,78 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { userResources } from "@server/db/schema"; +import response from "@server/utils/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 } from "drizzle-orm"; + +const setUserResourcesBodySchema = z.object({ + userIds: z.array(z.string()), +}); + +const setUserResourcesParamsSchema = z.object({ + resourceId: z.string().transform(Number).pipe(z.number().int().positive()), +}); + +export async function setResourceUsers( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setUserResourcesBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userIds } = parsedBody.data; + + const parsedParams = setUserResourcesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + await db.transaction(async (trx) => { + await trx + .delete(userResources) + .where(eq(userResources.resourceId, resourceId)); + + const newUserResources = await Promise.all( + userIds.map((userId) => + trx + .insert(userResources) + .values({ userId, resourceId }) + .returning() + ) + ); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Users set for 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/user/addUserResource.ts b/server/routers/user/addUserResource.ts deleted file mode 100644 index da745ae2..00000000 --- a/server/routers/user/addUserResource.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db } from "@server/db"; -import { userResources } from "@server/db/schema"; -import response from "@server/utils/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; - -const addUserResourceSchema = z.object({ - userId: z.string(), - resourceId: z.string().transform(Number).pipe(z.number().int().positive()), -}); - -export async function addUserResource( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const parsedBody = addUserResourceSchema.safeParse(req.body); - if (!parsedBody.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() - ) - ); - } - - const { userId, resourceId } = parsedBody.data; - - const newUserResource = await db - .insert(userResources) - .values({ - userId, - resourceId, - }) - .returning(); - - return response(res, { - data: newUserResource[0], - success: true, - error: false, - message: "Resource added to user successfully", - status: HttpCode.CREATED, - }); - } catch (error) { - logger.error(error); - return next( - createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") - ); - } -} diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index fa548995..71c3aac5 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -100,7 +100,7 @@ export default function AccessControlsPage() { if (res && res.status === 200) { toast({ variant: "default", - title: "User invited", + title: "User saved", description: "The user has been updated.", }); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index 53fb25be..acb82b8d 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -8,9 +8,12 @@ import { useOrgContext } from "@app/hooks/useOrgContext"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { AxiosResponse } from "axios"; import { formatAxiosError } from "@app/lib/utils"; -import { ListResourceRolesResponse } from "@server/routers/resource"; +import { + ListResourceRolesResponse, + ListResourceUsersResponse, +} from "@server/routers/resource"; import { Button } from "@app/components/ui/button"; -import { set, z } from "zod"; +import { z } from "zod"; import { Tag } from "emblor"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -25,6 +28,7 @@ import { } from "@app/components/ui/form"; import { TagInput } from "emblor"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { ListUsersResponse } from "@server/routers/user"; const FormSchema = z.object({ roles: z.array( @@ -33,6 +37,12 @@ const FormSchema = z.object({ text: z.string(), }) ), + users: z.array( + z.object({ + id: z.string(), + text: z.string(), + }) + ), }); export default function ResourceAuthenticationPage() { @@ -43,13 +53,22 @@ export default function ResourceAuthenticationPage() { const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>( [] ); - const [activeTagIndex, setActiveTagIndex] = useState(null); + const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>( + [] + ); + + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< + number | null + >(null); + const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< + number | null + >(null); const [loading, setLoading] = useState(false); const form = useForm>({ resolver: zodResolver(FormSchema), - defaultValues: { roles: [] }, + defaultValues: { roles: [], users: [] }, }); useEffect(() => { @@ -103,6 +122,53 @@ export default function ResourceAuthenticationPage() { ), }); }); + + api.get>( + `/org/${org?.org.orgId}/users` + ) + .then((res) => { + setAllUsers( + res.data.data.users.map((user) => ({ + id: user.id.toString(), + text: user.email, + })) + ); + }) + .catch((e) => { + console.error(e); + toast({ + variant: "destructive", + title: "Failed to fetch users", + description: formatAxiosError( + e, + "An error occurred while fetching the users" + ), + }); + }); + + api.get>( + `/resource/${resource.resourceId}/users` + ) + .then((res) => { + form.setValue( + "users", + res.data.data.users.map((i) => ({ + id: i.userId.toString(), + text: i.email, + })) + ); + }) + .catch((e) => { + console.error(e); + toast({ + variant: "destructive", + title: "Failed to fetch users", + description: formatAxiosError( + e, + "An error occurred while fetching the users" + ), + }); + }); }, []); async function onSubmit(data: z.infer) { @@ -112,9 +178,13 @@ export default function ResourceAuthenticationPage() { roleIds: data.roles.map((i) => parseInt(i.id)), }); + await api.post(`/resource/${resource.resourceId}/users`, { + userIds: data.users.map((i) => i.id), + }); + toast({ - title: "Roles set", - description: "Roles set for resource successfully", + title: "Saved successfully", + description: "Authentication settings have been saved", }); } catch (e) { console.error(e); @@ -154,8 +224,10 @@ export default function ResourceAuthenticationPage() { { @@ -175,9 +247,9 @@ export default function ResourceAuthenticationPage() { tag: { body: "bg-muted hover:bg-accent text-foreground p-2", }, - input: "border-none bg-transparent text-inherit placeholder:text-inherit shadow-none" + input: "border-none bg-transparent text-inherit placeholder:text-inherit shadow-none", + inlineTagsContainer: "bg-transparent", }} - inputFieldPosition={"top"} /> @@ -189,6 +261,53 @@ export default function ResourceAuthenticationPage() { )} /> + ( + + Users + + { + form.setValue( + "users", + newUsers as [Tag, ...Tag[]] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allUsers} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + styleClasses={{ + tag: { + body: "bg-muted hover:bg-accent text-foreground p-2", + }, + input: "border-none bg-transparent text-inherit placeholder:text-inherit shadow-none", + inlineTagsContainer: "bg-transparent", + }} + /> + + + Users added here will be able to access + this resource. A user will always have + access to a resource if they have a role + that has access to it. + + + + )} + />