From 4d803a40c968594f43dba858680ee311bb74d049 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 25 Feb 2026 06:00:19 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 2 + server/openApi.ts | 1 + .../routers/policy/createResourcePolicy.ts | 71 ++++- .../routers/policy/updateResourcePolicy.ts | 165 +++++++++++ .../resource-policy/EditPolicyForm.tsx | 263 ++++++++++++++++++ 5 files changed, 492 insertions(+), 10 deletions(-) create mode 100644 server/private/routers/policy/updateResourcePolicy.ts create mode 100644 src/components/resource-policy/EditPolicyForm.tsx diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index edc7702e6..66b0755f5 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1142,3 +1142,5 @@ export type WebauthnChallenge = InferSelectModel; export type DeviceWebAuthCode = InferSelectModel; export type RequestAuditLog = InferSelectModel; export type ResourcePolicy = InferSelectModel; +export type RolePolicy = InferSelectModel; +export type UserPolicy = InferSelectModel; diff --git a/server/openApi.ts b/server/openApi.ts index 68b05a30d..27c1e6bef 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -6,6 +6,7 @@ export enum OpenAPITags { Site = "Site", Org = "Organization", Resource = "Resource", + Policy = "Policy", Role = "Role", User = "User", Invitation = "Invitation", diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts index 88b06264c..6cf710810 100644 --- a/server/private/routers/policy/createResourcePolicy.ts +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -10,10 +10,12 @@ import { resourcePolicies, rolePolicies, roles, + userOrgs, userPolicies, + users, type ResourcePolicy } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray, not, type InferInsertModel } from "drizzle-orm"; import logger from "@server/logger"; import { getUniqueResourcePolicyName } from "@server/db/names"; import response from "@server/lib/response"; @@ -26,21 +28,24 @@ 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([]), + roleIds: z + .array(z.string().transform(Number).pipe(z.int().positive())) + .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], + description: "Create a resource policy.", + tags: [OpenAPITags.Org, OpenAPITags.Policy], request: { params: createResourcePolicyParamsSchema, body: { content: { "application/json": { - schema: createResourcePolicyParamsSchema + schema: createResourcePolicyBodySchema } } } @@ -125,6 +130,28 @@ export async function createResourcePolicy( ); } + 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(inArray(users.userId, userIds))); + const niceId = await getUniqueResourcePolicyName(orgId); const policy = await db.transaction(async (trx) => { @@ -138,19 +165,43 @@ export async function createResourcePolicy( }) .returning(); - await trx.insert(rolePolicies).values({ - roleId: adminRole[0].roleId, - resourcePolicyId: newPolicy.resourcePolicyId - }); + const rolesToAdd = [ + { + roleId: adminRole[0].roleId, + resourcePolicyId: newPolicy.resourcePolicyId + } + ] satisfies InferInsertModel[]; + + rolesToAdd.push( + ...existingRoles.map((role) => ({ + roleId: role.roleId, + resourcePolicyId: newPolicy.resourcePolicyId + })) + ); + + await trx.insert(rolePolicies).values(rolesToAdd); + + const usersToAdd: InferInsertModel[] = []; if (req.user && req.userOrgRoleId != adminRole[0].roleId) { // make sure the user can access the policy - await trx.insert(userPolicies).values({ + 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); + } + return newPolicy; }); diff --git a/server/private/routers/policy/updateResourcePolicy.ts b/server/private/routers/policy/updateResourcePolicy.ts new file mode 100644 index 000000000..58ea688cc --- /dev/null +++ b/server/private/routers/policy/updateResourcePolicy.ts @@ -0,0 +1,165 @@ +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, + userPolicies, + type ResourcePolicy, + type ResourcePolicy +} from "@server/db"; +import { and, eq } from "drizzle-orm"; +import logger from "@server/logger"; +import response from "@server/lib/response"; + +const updateResourcePolicyParamsSchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +const updateResourcePolicyBodySchema = z.strictObject({ + name: z.string().min(1).max(255).optional(), + niceId: z.string().min(1).max(255).optional() +}); + +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}", + description: "Update a resource policy.", + tags: [OpenAPITags.Org, OpenAPITags.Policy], + request: { + params: updateResourcePolicyParamsSchema, + body: { + content: { + "application/json": { + schema: updateResourcePolicyBodySchema + } + } + } + }, + responses: {} +}); + +export async function updateResourcePolicy( + req: Request, + res: Response, + next: NextFunction +) { + try { + const parsedParams = updateResourcePolicyParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + if (req.user && !req.userOrgRoleId) { + return next( + createHttpError(HttpCode.FORBIDDEN, "User does not have a role") + ); + } + + const { resourcePolicyId } = parsedParams.data; + const [result] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .leftJoin(orgs, eq(resourcePolicies.orgId, orgs.orgId)); + + const policy = result?.resourcePolicies; + const org = result?.orgs; + + if (!policy || !org) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource Policy with ID ${resourcePolicyId} not found` + ) + ); + } + + const parsedBody = updateResourcePolicyBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const updateData = parsedBody.data; + + if (updateData.niceId) { + const [existingPolicy] = await db + .select() + .from(resourcePolicies) + .where( + and( + eq(resourcePolicies.niceId, updateData.niceId), + eq(resourcePolicies.orgId, policy.orgId) + ) + ); + + if ( + existingPolicy && + existingPolicy.resourcePolicyId !== policy.resourcePolicyId + ) { + return next( + createHttpError( + HttpCode.CONFLICT, + `A resource policy with niceId "${updateData.niceId}" already exists` + ) + ); + } + } + + const updatedPolicy = await db.transaction(async (trx) => { + const [updated] = await trx + .update(resourcePolicies) + .set({ + ...updateData + }) + .where( + eq( + resourcePolicies.resourcePolicyId, + policy.resourcePolicyId + ) + ) + .returning(); + + return updated; + }); + + if (!updatedPolicy) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to update policy" + ) + ); + } + + return response(res, { + data: updatedPolicy, + success: true, + error: false, + message: "Resource policy updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/components/resource-policy/EditPolicyForm.tsx b/src/components/resource-policy/EditPolicyForm.tsx new file mode 100644 index 000000000..3f09f7ef8 --- /dev/null +++ b/src/components/resource-policy/EditPolicyForm.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; + +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { orgQueries } from "@app/lib/queries"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { build } from "@server/build"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { UserType } from "@server/types/UserTypes"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; + +import { useActionState, useMemo } from "react"; +import { useForm } from "react-hook-form"; +import z from "zod"; +import { + PolicyAuthMethodsSection, + PolicyOtpEmailSection, + PolicyRulesSection, + 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 ───────────────────────────────────────────────────────── + +export type EditPolicyFormProps = { + policy: ResourcePolicy; +}; + +export function EditPolicyForm({}: EditPolicyFormProps) { + 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 + ); + const isMaxmindAsnAvailable = !!( + env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0 + ); + + const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery( + orgQueries.roles({ orgId: org.org.orgId }) + ); + const { data: orgUsers = [], isLoading: isLoadingOrgUsers } = useQuery( + orgQueries.users({ orgId: org.org.orgId }) + ); + const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery( + orgQueries.identityProviders({ + orgId: org.org.orgId, + useOrgOnlyIdp: env.app.identityProviderMode === "org" + }) + ); + + const form = useForm({ + resolver: zodResolver(createPolicySchema) as any, + defaultValues: { + name: "", + sso: true, + skipToIdpId: null, + emailWhitelistEnabled: false, + roles: [], + users: [], + emails: [], + applyRules: false, + rules: [], + password: null, + headerAuth: null, + pincode: null + } + }); + + async function onSubmit() { + 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( + () => + orgRoles + .map((role) => ({ + id: role.roleId.toString(), + text: role.name + })) + .filter((role) => role.text !== "Admin"), + [orgRoles] + ); + + const allUsers = useMemo( + () => + orgUsers.map((user) => ({ + id: user.id.toString(), + text: `${getUserDisplayName({ email: user.email, username: user.username })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` + })), + [orgUsers] + ); + + const allIdps = useMemo(() => { + if (build === "saas") { + if (isPaidUser(tierMatrix.orgOidc)) { + return orgIdps.map((idp) => ({ + id: idp.idpId, + text: idp.name + })); + } + } else { + return orgIdps.map((idp) => ({ id: idp.idpId, text: idp.name })); + } + return []; + }, [orgIdps, isPaidUser]); + + if (isLoadingOrgRoles || isLoadingOrgUsers || isLoadingOrgIdps) { + return <>; + } + + return ( +
+ + + {/* Name */} + + + + {t("resourcePolicyName")} + + + {t("resourcePolicyNameDescription")} + + + + + ( + + {t("name")} + + + + + + )} + /> + + + + + + + + + + +
+ +
+
+ + ); +}