add users and roles to site resources

This commit is contained in:
miloschwartz
2025-11-05 12:24:50 -08:00
parent c73f8c88f7
commit e51b6b545e
13 changed files with 1017 additions and 33 deletions

View File

@@ -211,6 +211,24 @@ export const siteResources = pgTable("siteResources", {
enabled: boolean("enabled").notNull().default(true)
});
export const roleSiteResources = pgTable("roleSiteResources", {
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" }),
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, { onDelete: "cascade" })
});
export const userSiteResources = pgTable("userSiteResources", {
userId: varchar("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, { onDelete: "cascade" })
});
export const users = pgTable("user", {
userId: varchar("id").primaryKey(),
email: varchar("email"),

View File

@@ -232,6 +232,24 @@ export const siteResources = sqliteTable("siteResources", {
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
});
export const roleSiteResources = sqliteTable("roleSiteResources", {
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" }),
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, { onDelete: "cascade" })
});
export const userSiteResources = sqliteTable("userSiteResources", {
userId: text("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, { onDelete: "cascade" })
});
export const users = sqliteTable("user", {
userId: text("id").primaryKey(),
email: text("email"),
@@ -356,7 +374,8 @@ export const olms = sqliteTable("olms", {
// we will switch this depending on the current org it wants to connect to
onDelete: "set null"
}),
userId: text("userId").references(() => users.userId, { // optionally tied to a user and in this case delete when the user deletes
userId: text("userId").references(() => users.userId, {
// optionally tied to a user and in this case delete when the user deletes
onDelete: "cascade"
})
});
@@ -822,7 +841,9 @@ export const deviceWebAuthCodes = sqliteTable("deviceWebAuthCodes", {
expiresAt: integer("expiresAt").notNull(),
createdAt: integer("createdAt").notNull(),
verified: integer("verified", { mode: "boolean" }).notNull().default(false),
userId: text("userId").references(() => users.userId, { onDelete: "cascade" })
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
})
});
export type Org = InferSelectModel<typeof orgs>;

View File

@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { db, roleSiteResources, userOrgs, userSiteResources } from "@server/db";
import { siteResources } from "@server/db";
import { eq, and } from "drizzle-orm";
import createHttpError from "http-errors";
@@ -12,44 +12,128 @@ export async function verifySiteResourceAccess(
next: NextFunction
): Promise<any> {
try {
const siteResourceId = parseInt(req.params.siteResourceId);
const siteId = parseInt(req.params.siteId);
const orgId = req.params.orgId;
const userId = req.user!.userId;
const siteResourceId =
req.params.siteResourceId ||
req.body.siteResourceId ||
req.query.siteResourceId;
if (!siteResourceId || !siteId || !orgId) {
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
if (!siteResourceId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Missing required parameters"
"Site resource ID is required"
)
);
}
const siteResourceIdNum = parseInt(siteResourceId as string, 10);
if (isNaN(siteResourceIdNum)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid site resource ID"
)
);
}
// Check if the site resource exists and belongs to the specified site and org
const [siteResource] = await db
.select()
.from(siteResources)
.where(and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId)
))
.where(eq(siteResources.siteResourceId, siteResourceIdNum))
.limit(1);
if (!siteResource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Site resource not found"
`Site resource with ID ${siteResourceIdNum} not found`
)
);
}
if (!siteResource.orgId) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Site resource with ID ${siteResourceIdNum} does not have an organization ID`
)
);
}
if (!req.userOrg) {
const userOrgRole = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, siteResource.orgId)
)
)
.limit(1);
req.userOrg = userOrgRole[0];
}
if (!req.userOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
const userOrgRoleId = req.userOrg.roleId;
req.userOrgRoleId = userOrgRoleId;
req.userOrgId = siteResource.orgId;
// Attach the siteResource to the request for use in the next middleware/route
// @ts-ignore - Extending Request type
req.siteResource = siteResource;
next();
const roleResourceAccess = await db
.select()
.from(roleSiteResources)
.where(
and(
eq(roleSiteResources.siteResourceId, siteResourceIdNum),
eq(roleSiteResources.roleId, userOrgRoleId)
)
)
.limit(1);
if (roleResourceAccess.length > 0) {
return next();
}
const userResourceAccess = await db
.select()
.from(userSiteResources)
.where(
and(
eq(userSiteResources.userId, userId),
eq(userSiteResources.siteResourceId, siteResourceIdNum)
)
)
.limit(1);
if (userResourceAccess.length > 0) {
return next();
}
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this resource"
)
);
} catch (error) {
logger.error("Error verifying site resource access:", error);
return next(

View File

@@ -285,6 +285,38 @@ authenticated.delete(
siteResource.deleteSiteResource,
);
authenticated.get(
"/site-resource/:siteResourceId/roles",
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.listResourceRoles),
siteResource.listSiteResourceRoles
);
authenticated.get(
"/site-resource/:siteResourceId/users",
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.listResourceUsers),
siteResource.listSiteResourceUsers
);
authenticated.post(
"/site-resource/:siteResourceId/roles",
verifySiteResourceAccess,
verifyRoleAccess,
verifyUserHasAction(ActionsEnum.setResourceRoles),
logActionAudit(ActionsEnum.setResourceRoles),
siteResource.setSiteResourceRoles,
);
authenticated.post(
"/site-resource/:siteResourceId/users",
verifySiteResourceAccess,
verifySetResourceUsers,
verifyUserHasAction(ActionsEnum.setResourceUsers),
logActionAudit(ActionsEnum.setResourceUsers),
siteResource.setSiteResourceUsers,
);
authenticated.put(
"/org/:orgId/resource",
verifyOrgAccess,

View File

@@ -198,6 +198,38 @@ authenticated.delete(
siteResource.deleteSiteResource
);
authenticated.get(
"/site-resource/:siteResourceId/roles",
verifyApiKeySiteResourceAccess,
verifyApiKeyHasAction(ActionsEnum.listResourceRoles),
siteResource.listSiteResourceRoles
);
authenticated.get(
"/site-resource/:siteResourceId/users",
verifyApiKeySiteResourceAccess,
verifyApiKeyHasAction(ActionsEnum.listResourceUsers),
siteResource.listSiteResourceUsers
);
authenticated.post(
"/site-resource/:siteResourceId/roles",
verifyApiKeySiteResourceAccess,
verifyApiKeyRoleAccess,
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
logActionAudit(ActionsEnum.setResourceRoles),
siteResource.setSiteResourceRoles
);
authenticated.post(
"/site-resource/:siteResourceId/users",
verifyApiKeySiteResourceAccess,
verifyApiKeySetResourceUsers,
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
logActionAudit(ActionsEnum.setResourceUsers),
siteResource.setSiteResourceUsers
);
authenticated.put(
"/org/:orgId/resource",
verifyApiKeyOrgAccess,

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, newts } from "@server/db";
import { db, newts, roleResources, roles, roleSiteResources } from "@server/db";
import { siteResources, sites, orgs, SiteResource } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -140,6 +140,23 @@ export async function createSiteResource(
})
.returning();
const adminRole = await db
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (adminRole.length === 0) {
return next(
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
);
}
await db.insert(roleSiteResources).values({
roleId: adminRole[0].roleId,
siteResourceId: newSiteResource.siteResourceId
});
const [newt] = await db
.select()
.from(newts)
@@ -150,7 +167,13 @@ export async function createSiteResource(
return next(createHttpError(HttpCode.NOT_FOUND, "Newt not found"));
}
await addTargets(newt.newtId, destinationIp, destinationPort, protocol, proxyPort);
await addTargets(
newt.newtId,
destinationIp,
destinationPort,
protocol,
proxyPort
);
logger.info(
`Created site resource ${newSiteResource.siteResourceId} for site ${siteId}`

View File

@@ -4,3 +4,7 @@ export * from "./getSiteResource";
export * from "./updateSiteResource";
export * from "./listSiteResources";
export * from "./listAllSiteResourcesByOrg";
export * from "./listSiteResourceRoles";
export * from "./listSiteResourceUsers";
export * from "./setSiteResourceRoles";
export * from "./setSiteResourceUsers";

View File

@@ -0,0 +1,86 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { roleSiteResources, roles } 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 listSiteResourceRolesSchema = z
.object({
siteResourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
async function query(siteResourceId: number) {
return await db
.select({
roleId: roles.roleId,
name: roles.name,
description: roles.description,
isAdmin: roles.isAdmin
})
.from(roleSiteResources)
.innerJoin(roles, eq(roleSiteResources.roleId, roles.roleId))
.where(eq(roleSiteResources.siteResourceId, siteResourceId));
}
export type ListSiteResourceRolesResponse = {
roles: NonNullable<Awaited<ReturnType<typeof query>>>;
};
registry.registerPath({
method: "get",
path: "/site-resource/{siteResourceId}/roles",
description: "List all roles for a site resource.",
tags: [OpenAPITags.Resource, OpenAPITags.Role],
request: {
params: listSiteResourceRolesSchema
},
responses: {}
});
export async function listSiteResourceRoles(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = listSiteResourceRolesSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { siteResourceId } = parsedParams.data;
const siteResourceRolesList = await query(siteResourceId);
return response<ListSiteResourceRolesResponse>(res, {
data: {
roles: siteResourceRolesList
},
success: true,
error: false,
message: "Site resource roles retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,89 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { idp, userSiteResources, users } 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 listSiteResourceUsersSchema = z
.object({
siteResourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
async function queryUsers(siteResourceId: number) {
return await db
.select({
userId: userSiteResources.userId,
username: users.username,
type: users.type,
idpName: idp.name,
idpId: users.idpId,
email: users.email
})
.from(userSiteResources)
.innerJoin(users, eq(userSiteResources.userId, users.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.where(eq(userSiteResources.siteResourceId, siteResourceId));
}
export type ListSiteResourceUsersResponse = {
users: NonNullable<Awaited<ReturnType<typeof queryUsers>>>;
};
registry.registerPath({
method: "get",
path: "/site-resource/{siteResourceId}/users",
description: "List all users for a site resource.",
tags: [OpenAPITags.Resource, OpenAPITags.User],
request: {
params: listSiteResourceUsersSchema
},
responses: {}
});
export async function listSiteResourceUsers(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = listSiteResourceUsersSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { siteResourceId } = parsedParams.data;
const siteResourceUsersList = await queryUsers(siteResourceId);
return response<ListSiteResourceUsersResponse>(res, {
data: {
users: siteResourceUsersList
},
success: true,
error: false,
message: "Site resource users retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,155 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, siteResources } from "@server/db";
import { roleSiteResources, roles } 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, ne } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
const setSiteResourceRolesBodySchema = z
.object({
roleIds: z.array(z.number().int().positive())
})
.strict();
const setSiteResourceRolesParamsSchema = z
.object({
siteResourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
registry.registerPath({
method: "post",
path: "/site-resource/{siteResourceId}/roles",
description:
"Set roles for a site resource. This will replace all existing roles.",
tags: [OpenAPITags.Resource, OpenAPITags.Role],
request: {
params: setSiteResourceRolesParamsSchema,
body: {
content: {
"application/json": {
schema: setSiteResourceRolesBodySchema
}
}
}
},
responses: {}
});
export async function setSiteResourceRoles(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = setSiteResourceRolesBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { roleIds } = parsedBody.data;
const parsedParams = setSiteResourceRolesParamsSchema.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"
)
);
}
// get this org's admin role
const adminRole = await db
.select()
.from(roles)
.where(
and(
eq(roles.name, "Admin"),
eq(roles.orgId, siteResource.orgId)
)
)
.limit(1);
if (!adminRole.length) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Admin role not found"
)
);
}
if (roleIds.includes(adminRole[0].roleId)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Admin role cannot be assigned to site resources"
)
);
}
await db.transaction(async (trx) => {
await trx.delete(roleSiteResources).where(
and(
eq(roleSiteResources.siteResourceId, siteResourceId),
ne(roleSiteResources.roleId, adminRole[0].roleId) // delete all but the admin role
)
);
await Promise.all(
roleIds.map((roleId) =>
trx
.insert(roleSiteResources)
.values({ roleId, siteResourceId })
.returning()
)
);
});
return response(res, {
data: {},
success: true,
error: false,
message: "Roles set for site resource successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,106 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { userSiteResources } 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 } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
const setSiteResourceUsersBodySchema = z
.object({
userIds: z.array(z.string())
})
.strict();
const setSiteResourceUsersParamsSchema = z
.object({
siteResourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
registry.registerPath({
method: "post",
path: "/site-resource/{siteResourceId}/users",
description:
"Set users for a site resource. This will replace all existing users.",
tags: [OpenAPITags.Resource, OpenAPITags.User],
request: {
params: setSiteResourceUsersParamsSchema,
body: {
content: {
"application/json": {
schema: setSiteResourceUsersBodySchema
}
}
}
},
responses: {}
});
export async function setSiteResourceUsers(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = setSiteResourceUsersBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { userIds } = parsedBody.data;
const parsedParams = setSiteResourceUsersParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { siteResourceId } = parsedParams.data;
await db.transaction(async (trx) => {
await trx
.delete(userSiteResources)
.where(eq(userSiteResources.siteResourceId, siteResourceId));
await Promise.all(
userIds.map((userId) =>
trx
.insert(userSiteResources)
.values({ userId, siteResourceId })
.returning()
)
);
});
return response(res, {
data: {},
success: true,
error: false,
message: "Users set for site resource successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}