From 1d709b551a3414ea6532888737dd9a6c1d591071 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 24 Feb 2026 06:31:43 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20create=20policy=20endpoitn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 4 + server/auth/actions.ts | 8 +- server/db/names.ts | 37 +++- server/private/routers/external.ts | 13 +- .../routers/policy/createResourcePolicy.ts | 178 ++++++++++++++++++ server/private/routers/policy/index.ts | 15 ++ .../listResourcePolicies.ts | 0 .../routers/resource/createResourcePolicy.ts | 12 -- server/private/routers/resource/index.ts | 2 - server/routers/resource/createResource.ts | 33 ++-- src/components/ResourcePoliciesTable.tsx | 11 +- .../resource-policy/CreatePolicyForm.tsx | 54 ++++++ 12 files changed, 324 insertions(+), 43 deletions(-) create mode 100644 server/private/routers/policy/createResourcePolicy.ts create mode 100644 server/private/routers/policy/index.ts rename server/private/routers/{resource => policy}/listResourcePolicies.ts (100%) delete mode 100644 server/private/routers/resource/createResourcePolicy.ts diff --git a/messages/en-US.json b/messages/en-US.json index 3f0c1982c..fb827ffe3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -637,6 +637,10 @@ "rulesNoOne": "No rules. Add a rule using the form.", "rulesOrder": "Rules are evaluated by priority in ascending order.", "rulesSubmit": "Save Rules", + "policyErrorCreate": "Error creating policy", + "policyErrorCreateDescription": "An error occurred when creating the policy", + "policyErrorCreateMessageDescription": "An unexpected error occurred", + "policyCreatedSuccess": "Resource policy succesfully created", "resourceErrorCreate": "Error creating resource", "resourceErrorCreateDescription": "An error occurred when creating the resource", "resourceErrorCreateMessage": "Error creating resource:", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 9c94c6a6a..6e863829e 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -133,13 +133,13 @@ export enum ActionsEnum { listApprovals = "listApprovals", updateApprovals = "updateApprovals", listResourcePolicies = "listResourcePolicies", - createResourcePolicies = "createResourcePolicies", - updateResourcePolicies = "updateResourcePolicies", - deleteResourcePolicies = "deleteResourcePolicies", + createResourcePolicy = "createResourcePolicy", + updateResourcePolicy = "updateResourcePolicy", + deleteResourcePolicy = "deleteResourcePolicy", listResourcePolicyRoles = "listResourcePolicyRoles", setResourcePolicyRoles = "setResourcePolicyRoles", listResourcePolicyUsers = "listResourcePolicyUsers", - setResourcePolicyUsers = "setResourcePolicyUsers", + setResourcePolicyUsers = "setResourcePolicyUsers" } export async function checkUserActionPermission( diff --git a/server/db/names.ts b/server/db/names.ts index 6f9e12305..ebe38573d 100644 --- a/server/db/names.ts +++ b/server/db/names.ts @@ -1,6 +1,12 @@ import { join } from "path"; import { readFileSync } from "fs"; -import { clients, db, resources, siteResources } from "@server/db"; +import { + clients, + db, + resourcePolicies, + resources, + siteResources +} from "@server/db"; import { randomInt } from "crypto"; import { exitNodes, sites } from "@server/db"; import { eq, and } from "drizzle-orm"; @@ -107,6 +113,35 @@ export async function getUniqueResourceName(orgId: string): Promise { } } +export async function getUniqueResourcePolicyName( + orgId: string +): Promise { + let loops = 0; + while (true) { + if (loops > 100) { + throw new Error("Could not generate a unique name"); + } + + const name = generateName(); + const policyCount = await db + .select({ + niceId: resourcePolicies.niceId, + orgId: resourcePolicies.orgId + }) + .from(resourcePolicies) + .where( + and( + eq(resourcePolicies.niceId, name), + eq(resourcePolicies.orgId, orgId) + ) + ); + if (policyCount.length === 0) { + return name; + } + loops++; + } +} + export async function getUniqueSiteResourceName( orgId: string ): Promise { diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 31724e8cd..87a53f83d 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -26,6 +26,7 @@ import * as misc from "#private/routers/misc"; import * as reKey from "#private/routers/re-key"; import * as approval from "#private/routers/approvals"; import * as resource from "#private/routers/resource"; +import * as policy from "#private/routers/policy"; import { verifyOrgAccess, @@ -349,9 +350,19 @@ authenticated.get( verifyLimits, verifyUserHasAction(ActionsEnum.listResourcePolicies), logActionAudit(ActionsEnum.listResourcePolicies), - resource.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", diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts new file mode 100644 index 000000000..88b06264c --- /dev/null +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -0,0 +1,178 @@ +import { Request, Response, NextFunction } from "express"; +import z from "zod"; +import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { fromError } from "zod-validation-error"; +import { + db, + orgs, + resourcePolicies, + rolePolicies, + roles, + userPolicies, + type ResourcePolicy +} from "@server/db"; +import { and, eq } from "drizzle-orm"; +import logger from "@server/logger"; +import { getUniqueResourcePolicyName } from "@server/db/names"; +import response from "@server/lib/response"; + +const createResourcePolicyParamsSchema = z.strictObject({ + orgId: z.string() +}); + +const createResourcePolicyBodySchema = z.strictObject({ + name: z.string().min(1).max(255), + sso: z.boolean(), + skipToIdpId: z.string().optional(), + roleIds: z.array(z.string()).optional().default([]), + userIds: z.array(z.string()).optional().default([]) +}); + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/resource-policy", + description: "Create a resource.", + tags: [OpenAPITags.Org, OpenAPITags.Resource], + request: { + params: createResourcePolicyParamsSchema, + body: { + content: { + "application/json": { + schema: createResourcePolicyParamsSchema + } + } + } + }, + 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 } = parsedBody.data; + + const isAuthEnabeld = sso; // other conditions will follow + + if (!isAuthEnabeld) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "At least one authentication policy must be set: platform SSO, an authentication method, one-time password, or a rule." + ) + ); + } + + 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 niceId = await getUniqueResourcePolicyName(orgId); + + const policy = await db.transaction(async (trx) => { + const [newPolicy] = await trx + .insert(resourcePolicies) + .values({ + niceId, + orgId, + name, + sso + }) + .returning(); + + await trx.insert(rolePolicies).values({ + roleId: adminRole[0].roleId, + resourcePolicyId: newPolicy.resourcePolicyId + }); + + if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + // make sure the user can access the policy + await trx.insert(userPolicies).values({ + userId: req.user?.userId!, + resourcePolicyId: newPolicy.resourcePolicyId + }); + } + + return newPolicy; + }); + + if (!policy) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create policy" + ) + ); + } + return response(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") + ); + } +} diff --git a/server/private/routers/policy/index.ts b/server/private/routers/policy/index.ts new file mode 100644 index 000000000..88302bcac --- /dev/null +++ b/server/private/routers/policy/index.ts @@ -0,0 +1,15 @@ +/* + * 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"; diff --git a/server/private/routers/resource/listResourcePolicies.ts b/server/private/routers/policy/listResourcePolicies.ts similarity index 100% rename from server/private/routers/resource/listResourcePolicies.ts rename to server/private/routers/policy/listResourcePolicies.ts diff --git a/server/private/routers/resource/createResourcePolicy.ts b/server/private/routers/resource/createResourcePolicy.ts deleted file mode 100644 index 76d0133ef..000000000 --- a/server/private/routers/resource/createResourcePolicy.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Request, Response, NextFunction } from "express"; -import z from "zod"; - -const createResourcePolicyParamsSchema = z.strictObject({ - orgId: z.string() -}); - -export async function createResourcePolicy( - req: Request, - res: Response, - next: NextFunction -) {} diff --git a/server/private/routers/resource/index.ts b/server/private/routers/resource/index.ts index 0d217b671..f82b55524 100644 --- a/server/private/routers/resource/index.ts +++ b/server/private/routers/resource/index.ts @@ -12,5 +12,3 @@ */ export * from "./getMaintenanceInfo"; -export * from "./listResourcePolicies"; -export * from "./createResourcePolicy"; diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 232cea266..8795b188d 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -1,9 +1,8 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db, loginPage } from "@server/db"; +import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; +import { build } from "@server/build"; import { - domains, - orgDomains, + db, + loginPage, orgs, Resource, resources, @@ -11,19 +10,19 @@ import { roles, userResources } from "@server/db"; -import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import { eq, and } from "drizzle-orm"; -import { fromError } from "zod-validation-error"; -import logger from "@server/logger"; -import { subdomainSchema } from "@server/lib/schemas"; -import config from "@server/lib/config"; -import { OpenAPITags, registry } from "@server/openApi"; -import { build } from "@server/build"; -import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { getUniqueResourceName } from "@server/db/names"; +import config from "@server/lib/config"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; +import response from "@server/lib/response"; +import { subdomainSchema } from "@server/lib/schemas"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } 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 createResourceParamsSchema = z.strictObject({ orgId: z.string() @@ -298,7 +297,7 @@ async function createHttpResource( ); } - if (build != "oss") { + if (build !== "oss") { await createCertificate(domainId, fullDomain, db); } diff --git a/src/components/ResourcePoliciesTable.tsx b/src/components/ResourcePoliciesTable.tsx index 01f2a8cdc..32860a464 100644 --- a/src/components/ResourcePoliciesTable.tsx +++ b/src/components/ResourcePoliciesTable.tsx @@ -5,9 +5,14 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; import type { PaginationState } from "@tanstack/react-table"; +import { ArrowRight, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import { Button } from "./ui/button"; +import { ControlledDataTable } from "./ui/controlled-data-table"; import type { ExtendedColumnDef } from "./ui/data-table"; import { DropdownMenu, @@ -15,12 +20,6 @@ import { DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; -import { Button } from "./ui/button"; -import { MoreHorizontal, ArrowRight } from "lucide-react"; -import Link from "next/link"; -import { ControlledDataTable } from "./ui/controlled-data-table"; -import { useDebouncedCallback } from "use-debounce"; -import { Badge } from "./ui/badge"; type ResourcePolicyRow = ListResourcePoliciesResponse["policies"][number]; diff --git a/src/components/resource-policy/CreatePolicyForm.tsx b/src/components/resource-policy/CreatePolicyForm.tsx index 05e7aea99..531a751d9 100644 --- a/src/components/resource-policy/CreatePolicyForm.tsx +++ b/src/components/resource-policy/CreatePolicyForm.tsx @@ -42,6 +42,11 @@ import { PolicyUsersRolesSection } from "./ResourcePolicySubForms"; import { type PolicyFormValues, createPolicySchema } from "."; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { orgs, type ResourcePolicy } from "@server/db"; +import type { AxiosResponse } from "axios"; +import { useRouter } from "next/navigation"; // ─── CreatePolicyForm ───────────────────────────────────────────────────────── @@ -51,9 +56,12 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { const { org } = useOrgContext(); const t = useTranslations(); const { env } = useEnvContext(); + const api = createApiClient({ env }); const [, formAction, isSubmitting] = useActionState(onSubmit, null); const { isPaidUser } = usePaidStatus(); + const router = useRouter(); + const isMaxmindAvailable = !!( env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0 ); @@ -96,6 +104,52 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { const isValid = await form.trigger(); if (!isValid) return; + + const payload = form.getValues(); + + try { + const res = await api + .post>( + `/org/${org.org.orgId}/resource-policy/`, + { + name: payload.name, + sso: payload.sso, + roleIds: payload.roles.map((r) => r.id), + userIds: payload.users.map((u) => u.id) + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorCreate"), + description: formatAxiosError( + e, + t("policyErrorCreateDescription") + ) + }); + }); + + if (res && res.status === 201) { + const id = res.data.data.resourcePolicyId; + const niceId = res.data.data.niceId; + + router.push(`/${org.org.orgId}/settings/policies/resources/`); + // should redirect to the details page + // router.push( + // `/${org.org.orgId}/settings/policies/resources/${niceId}` + // ); + toast({ + title: t("success"), + description: t("policyCreatedSuccess") + }); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorCreate"), + description: t("policyErrorCreateMessageDescription") + }); + } } const allRoles = useMemo(