Merge branch 'feat/resource-policies' into resource-policies

This commit is contained in:
Owen
2026-05-04 14:40:44 -07:00
66 changed files with 11356 additions and 1169 deletions

View File

@@ -31,6 +31,8 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks";
import * as resource from "#private/routers/resource";
import * as policy from "#private/routers/policy";
import {
verifyOrgAccess,
@@ -44,7 +46,8 @@ import {
verifyUserCanSetUserOrgRoles,
verifySiteProvisioningKeyAccess,
verifyIsLoggedInUser,
verifyAdmin
verifyAdmin,
verifyResourcePolicyAccess
} from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions";
import {
@@ -382,6 +385,39 @@ authenticated.get(
approval.countApprovals
);
authenticated.delete(
"/resource-policy/:resourcePolicyId",
verifyResourcePolicyAccess,
verifyValidLicense,
// verifyValidSubscription(tierMatrix.loginPageDomain), // todo: use the correct subscription ?
verifyLimits,
verifyUserHasAction(ActionsEnum.deleteResourcePolicy),
logActionAudit(ActionsEnum.deleteResourcePolicy),
policy.deleteResourcePolicy
);
authenticated.get(
"/org/:orgId/resource-policies",
verifyValidLicense,
// verifyValidSubscription(tierMatrix.loginPageDomain), // todo: use the correct subscription ?
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.listResourcePolicies),
logActionAudit(ActionsEnum.listResourcePolicies),
policy.listResourcePolicies
);
authenticated.post(
"/org/:orgId/resource-policy",
verifyValidLicense,
// verifyValidSubscription(tierMatrix.loginPageDomain), // todo: use the correct subscription ?
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createResourcePolicy),
logActionAudit(ActionsEnum.createResourcePolicy),
policy.createResourcePolicy
);
authenticated.put(
"/org/:orgId/approvals/:approvalId",
verifyValidLicense,

View File

@@ -0,0 +1,401 @@
import { hashPassword } from "@server/auth/password";
import {
db,
idp,
idpOrg,
orgs,
resourcePolicies,
resourcePolicyHeaderAuth,
resourcePolicyPassword,
resourcePolicyPincode,
resourcePolicyRules,
resourcePolicyWhiteList,
rolePolicies,
roles,
userOrgs,
userPolicies,
users,
type ResourcePolicy
} from "@server/db";
import { getUniqueResourcePolicyName } from "@server/db/names";
import response from "@server/lib/response";
import {
isValidCIDR,
isValidIP,
isValidUrlGlobPattern
} from "@server/lib/validators";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import { and, eq, inArray, type InferInsertModel } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import z from "zod";
import { fromError } from "zod-validation-error";
const createResourcePolicyParamsSchema = z.strictObject({
orgId: z.string()
});
const ruleSchema = z.strictObject({
action: z.enum(["ACCEPT", "DROP", "PASS"]).openapi({
type: "string",
enum: ["ACCEPT", "DROP", "PASS"],
description: "rule action"
}),
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
type: "string",
enum: ["CIDR", "IP", "PATH"],
description: "rule match"
}),
value: z.string().min(1),
priority: z.int().openapi({
type: "integer",
description: "Rule priority"
}),
enabled: z.boolean().optional()
});
const createResourcePolicyBodySchema = z.strictObject({
name: z.string().min(1).max(255),
// Access control
sso: z.boolean().default(true),
skipToIdpId: z
.int()
.positive()
.optional()
.nullable()
.openapi({ type: "integer" }),
roleIds: z
.array(z.string().transform(Number).pipe(z.int().positive()))
.optional()
.default([]),
userIds: z.array(z.string()).optional().default([]),
// auth methods
password: z.string().min(4).max(100).nullable().optional(),
pincode: z
.string()
.regex(/^\d{6}$/)
.or(z.null())
.optional(),
headerAuth: z
.object({
user: z.string().min(4).max(100),
password: z.string().min(4).max(100),
extendedCompatibility: z.boolean()
})
.nullable()
.optional(),
// email OTP
emailWhitelistEnabled: z.boolean().optional().default(false),
emails: z
.array(
z.email().or(
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
error: "Invalid email address. Wildcard (*) must be the entire local part."
})
)
)
.max(50)
.transform((v) => v.map((e) => e.toLowerCase()))
.optional()
.default([]),
// rules
applyRules: z.boolean().default(false),
rules: z.array(ruleSchema).optional().default([])
});
registry.registerPath({
method: "post",
path: "/org/{orgId}/resource-policy",
description: "Create a resource policy.",
tags: [OpenAPITags.Org, OpenAPITags.Policy],
request: {
params: createResourcePolicyParamsSchema,
body: {
content: {
"application/json": {
schema: createResourcePolicyBodySchema
}
}
}
},
responses: {}
});
export async function createResourcePolicy(
req: Request,
res: Response,
next: NextFunction
) {
try {
// Validate request params
const parsedParams = createResourcePolicyParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
if (req.user && !req.userOrgRoleId) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
}
// get the org
const org = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (org.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
)
);
}
const parsedBody = createResourcePolicyBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const {
name,
sso,
userIds,
roleIds,
skipToIdpId,
applyRules,
emailWhitelistEnabled,
password,
pincode,
headerAuth,
emails,
rules
} = parsedBody.data;
// Check if Identity provider in `skipToIdpId` exists
if (skipToIdpId) {
const [provider] = await db
.select()
.from(idp)
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
.where(and(eq(idp.idpId, skipToIdpId), eq(idpOrg.orgId, orgId)))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Identity provider not found in this organization"
)
);
}
}
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`)
);
}
const existingRoles = await db
.select()
.from(roles)
.where(and(inArray(roles.roleId, roleIds)));
const hasAdminRole = existingRoles.some((role) => role.isAdmin);
if (hasAdminRole) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Admin role cannot be assigned to resource policy"
)
);
}
const existingUsers = await db
.select()
.from(users)
.innerJoin(userOrgs, eq(userOrgs.userId, users.userId))
.where(
and(eq(userOrgs.orgId, orgId), inArray(users.userId, userIds))
);
const niceId = await getUniqueResourcePolicyName(orgId);
for (const rule of rules) {
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid CIDR provided"
)
);
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
);
} else if (
rule.match === "PATH" &&
!isValidUrlGlobPattern(rule.value)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid URL glob pattern provided"
)
);
}
}
const policy = await db.transaction(async (trx) => {
const [newPolicy] = await trx
.insert(resourcePolicies)
.values({
niceId,
orgId,
name,
sso,
idpId: skipToIdpId,
applyRules,
emailWhitelistEnabled
})
.returning();
const rolesToAdd = [
{
roleId: adminRole[0].roleId,
resourcePolicyId: newPolicy.resourcePolicyId
}
] satisfies InferInsertModel<typeof rolePolicies>[];
rolesToAdd.push(
...existingRoles.map((role) => ({
roleId: role.roleId,
resourcePolicyId: newPolicy.resourcePolicyId
}))
);
await trx.insert(rolePolicies).values(rolesToAdd);
const usersToAdd: InferInsertModel<typeof userPolicies>[] = [];
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
// make sure the user can access the policy
usersToAdd.push({
userId: req.user?.userId!,
resourcePolicyId: newPolicy.resourcePolicyId
});
}
usersToAdd.push(
...existingUsers.map(({ user }) => ({
userId: user.userId,
resourcePolicyId: newPolicy.resourcePolicyId
}))
);
if (usersToAdd.length > 0) {
await trx.insert(userPolicies).values(usersToAdd);
}
if (password) {
const passwordHash = await hashPassword(password);
await trx.insert(resourcePolicyPassword).values({
resourcePolicyId: newPolicy.resourcePolicyId,
passwordHash
});
}
if (pincode) {
const pincodeHash = await hashPassword(pincode);
await trx.insert(resourcePolicyPincode).values({
resourcePolicyId: newPolicy.resourcePolicyId,
pincodeHash,
digitLength: 6
});
}
if (headerAuth) {
const headerAuthHash = await hashPassword(
Buffer.from(
`${headerAuth.user}:${headerAuth.password}`
).toString("base64")
);
await trx.insert(resourcePolicyHeaderAuth).values({
resourcePolicyId: newPolicy.resourcePolicyId,
headerAuthHash,
extendedCompatibility: headerAuth.extendedCompatibility
});
}
if (emailWhitelistEnabled && emails.length > 0) {
await trx.insert(resourcePolicyWhiteList).values(
emails.map((email) => ({
email,
resourcePolicyId: newPolicy.resourcePolicyId
}))
);
}
if (rules.length > 0) {
await trx.insert(resourcePolicyRules).values(
rules.map((rule) => ({
resourcePolicyId: newPolicy.resourcePolicyId,
...rule
}))
);
}
return newPolicy;
});
if (!policy) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create policy"
)
);
}
return response<ResourcePolicy>(res, {
data: policy,
success: true,
error: false,
message: "resource policy created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,107 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db, resourcePolicies, resources } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import { eq } from "drizzle-orm";
import type { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import z from "zod";
import { fromError } from "zod-validation-error";
// Define Zod schema for request parameters validation
const deleteResourcePolicySchema = z.strictObject({
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
});
registry.registerPath({
method: "delete",
path: "/resource-policy/{resourcePolicyId}",
description: "Delete a resource policy.",
tags: [OpenAPITags.Policy],
request: {
params: deleteResourcePolicySchema
},
responses: {}
});
export async function deleteResourcePolicy(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = deleteResourcePolicySchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourcePolicyId } = parsedParams.data;
const [existingResource] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
if (!existingResource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource Policy with ID ${resourcePolicyId} not found`
)
);
}
const totalAffectedResources = await db.$count(
db
.select()
.from(resources)
.where(eq(resources.resourcePolicyId, resourcePolicyId))
);
if (totalAffectedResources > 0) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
`Cannot delete Policy '${existingResource.name}' as it's being used by at least one resource`
)
);
}
// delete policy
await db
.delete(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
return response(res, {
data: null,
success: true,
error: false,
message: "Resource Policy deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,16 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
export * from "./createResourcePolicy";
export * from "./listResourcePolicies";
export * from "./deleteResourcePolicy";

View File

@@ -0,0 +1,261 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import {
db,
resourcePolicies,
resources,
rolePolicies,
userPolicies
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import type {
ListResourcePoliciesResponse,
ResourcePolicyWithResources
} from "@server/routers/resource/types";
import HttpCode from "@server/types/HttpCode";
import { and, asc, eq, inArray, like, or, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
const listResourcePoliciesParamsSchema = z.strictObject({
orgId: z.string()
});
const listResourcePoliciesSchema = z.object({
pageSize: z.coerce
.number<string>() // for prettier formatting
.int()
.positive()
.optional()
.catch(20)
.default(20),
page: z.coerce
.number<string>() // for prettier formatting
.int()
.min(0)
.optional()
.catch(1)
.default(1),
query: z.string().optional()
});
function queryResourcePoliciesBase() {
return db
.select({
resourcePolicyId: resourcePolicies.resourcePolicyId,
name: resourcePolicies.name,
niceId: resourcePolicies.niceId,
orgId: resourcePolicies.orgId
})
.from(resourcePolicies);
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/resource-policies",
description: "List resource policies for an organization.",
tags: [OpenAPITags.Org, OpenAPITags.Policy],
request: {
params: z.object({
orgId: z.string()
}),
query: listResourcePoliciesSchema
},
responses: {}
});
export async function listResourcePolicies(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listResourcePoliciesSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsedQuery.error)
)
);
}
const { page, pageSize, query } = parsedQuery.data;
const parsedParams = listResourcePoliciesParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsedParams.error)
)
);
}
const orgId =
parsedParams.data.orgId ||
req.userOrg?.orgId ||
req.apiKeyOrg?.orgId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
let accessibleResourcePolicies: Array<{ resourcePolicyId: number }>;
if (req.user) {
accessibleResourcePolicies = await db
.select({
resourcePolicyId: sql<number>`COALESCE(${userPolicies.resourcePolicyId}, ${rolePolicies.resourcePolicyId})`
})
.from(userPolicies)
.fullJoin(
rolePolicies,
eq(
userPolicies.resourcePolicyId,
rolePolicies.resourcePolicyId
)
)
.where(
or(
eq(userPolicies.userId, req.user!.userId),
eq(rolePolicies.roleId, req.userOrgRoleId!)
)
);
} else {
accessibleResourcePolicies = await db
.select({
resourcePolicyId: resourcePolicies.resourcePolicyId
})
.from(resourcePolicies)
.where(eq(resourcePolicies.orgId, orgId));
}
const accessibleResourceIds = accessibleResourcePolicies.map(
(resource) => resource.resourcePolicyId
);
const conditions = [
and(
inArray(
resourcePolicies.resourcePolicyId,
accessibleResourceIds
),
eq(resourcePolicies.orgId, orgId),
eq(resourcePolicies.scope, "global")
)
];
if (query) {
conditions.push(
or(
like(
sql`LOWER(${resourcePolicies.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${resourcePolicies.niceId})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
const baseQuery = queryResourcePoliciesBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(baseQuery.as("filtered_policies"));
const [rows, totalCount] = await Promise.all([
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(resourcePolicies.resourcePolicyId)),
countQuery
]);
const attachedResources =
rows.length === 0
? []
: await db
.select({
resourceId: resources.resourceId,
name: resources.name,
fullDomain: resources.fullDomain,
resourcePolicyId: resources.resourcePolicyId
})
.from(resources)
.where(
inArray(
resources.resourcePolicyId,
rows.map((row) => row.resourcePolicyId)
)
);
// avoids TS issues with reduce/never[]
const map = new Map<number, ResourcePolicyWithResources>();
for (const row of rows) {
let entry = map.get(row.resourcePolicyId);
if (!entry) {
entry = {
...row,
resources: []
};
map.set(row.resourcePolicyId, entry);
}
entry.resources = attachedResources.filter(
(r) => r.resourcePolicyId === entry?.resourcePolicyId
);
}
const policiesList = Array.from(map.values());
return response<ListResourcePoliciesResponse>(res, {
data: {
policies: policiesList,
pagination: {
total: totalCount,
pageSize,
page
}
},
success: true,
error: false,
message: "Resources retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}